From 778d3ea3a7e41b69bf084b496676e1bc8549417e Mon Sep 17 00:00:00 2001 From: shivani170 Date: Thu, 19 Jun 2025 13:08:24 +0530 Subject: [PATCH 01/90] chore: Generalize user preferences as per resource kind --- .../Hooks/useUserPreferences/service.ts | 34 ++++++++++++++----- src/Shared/Hooks/useUserPreferences/types.ts | 20 +++++++---- .../useUserPreferences/useUserPrefrences.tsx | 20 ++++++----- src/Shared/Hooks/useUserPreferences/utils.tsx | 12 ++++--- 4 files changed, 57 insertions(+), 29 deletions(-) diff --git a/src/Shared/Hooks/useUserPreferences/service.ts b/src/Shared/Hooks/useUserPreferences/service.ts index 88d38eab6..6aac661dc 100644 --- a/src/Shared/Hooks/useUserPreferences/service.ts +++ b/src/Shared/Hooks/useUserPreferences/service.ts @@ -39,7 +39,11 @@ import { getUserPreferenceResourcesMetadata } from './utils' * @description This function fetches the user preferences from the server. It uses the `get` method to make a request to the server and retrieves the user preferences based on the `USER_PREFERENCES_ATTRIBUTE_KEY`. The result is parsed and returned as a `UserPreferencesType` object. * @throws Will throw an error if the request fails or if the result is not in the expected format. */ -export const getUserPreferences = async (): Promise => { +export const getUserPreferences = async ({ + resourceKindType, +}: { + resourceKindType: ResourceKindType +}): Promise => { const queryParamsPayload: Pick = { key: USER_PREFERENCES_ATTRIBUTE_KEY, } @@ -58,6 +62,23 @@ export const getUserPreferences = async (): Promise => { ? ViewIsPipelineRBACConfiguredRadioTabs.ACCESS_ONLY : ViewIsPipelineRBACConfiguredRadioTabs.ALL_ENVIRONMENTS + const getPreferredResourcesData = () => { + let resources + switch (resourceKindType) { + case ResourceKindType.devtronApplication: + resources = { + [UserPreferenceResourceActions.RECENTLY_VISITED]: + parsedResult.resources?.[ResourceKindType.devtronApplication]?.[ + UserPreferenceResourceActions.RECENTLY_VISITED + ] || ([] as BaseAppMetaData[]), + } + break + default: + resources = {} + } + return resources + } + return { pipelineRBACViewSelectedTab, themePreference: @@ -65,16 +86,10 @@ export const getUserPreferences = async (): Promise => { ? THEME_PREFERENCE_MAP.auto : parsedResult.computedAppTheme, resources: { - [ResourceKindType.devtronApplication]: { - [UserPreferenceResourceActions.RECENTLY_VISITED]: - parsedResult.resources?.[ResourceKindType.devtronApplication]?.[ - UserPreferenceResourceActions.RECENTLY_VISITED - ] || ([] as BaseAppMetaData[]), - }, + [resourceKindType]: getPreferredResourcesData(), }, } } - /** * @description This function updates the user preferences in the server. It constructs a payload with the updated user preferences and sends a PATCH request to the server. If the request is successful, it returns true. If an error occurs, it shows an error message and returns false. * @param updatedUserPreferences - The updated user preferences to be sent to the server. @@ -87,6 +102,7 @@ export const getUserPreferences = async (): Promise => { const getUserPreferencePayload = async ({ path, value, + resourceKind, }: UserPathValueMapType): Promise> => { switch (path) { case 'themePreference': @@ -102,7 +118,7 @@ const getUserPreferencePayload = async ({ case 'resources': return { - resources: getUserPreferenceResourcesMetadata(value as BaseAppMetaData[]), + resources: getUserPreferenceResourcesMetadata(value as BaseAppMetaData[], resourceKind), } default: return {} diff --git a/src/Shared/Hooks/useUserPreferences/types.ts b/src/Shared/Hooks/useUserPreferences/types.ts index 18d70f79e..14c185177 100644 --- a/src/Shared/Hooks/useUserPreferences/types.ts +++ b/src/Shared/Hooks/useUserPreferences/types.ts @@ -31,11 +31,19 @@ export enum ViewIsPipelineRBACConfiguredRadioTabs { export enum UserPreferenceResourceActions { RECENTLY_VISITED = 'recently-visited', } +export type PreferredResourceKindType = ResourceKindType.devtronApplication + +export interface UserPreferenceRecentlyVisitedAppsTypes { + appId: number + appName: string + resourceKind: PreferredResourceKindType +} + export interface UserResourceKindActionType { [UserPreferenceResourceActions.RECENTLY_VISITED]: BaseAppMetaData[] } -export interface UserPreferenceResourceType { - [ResourceKindType.devtronApplication]: UserResourceKindActionType +export type UserPreferenceResourceType = { + [key in ResourceKindType]?: UserResourceKindActionType } export interface GetUserPreferencesParsedDTO { viewPermittedEnvOnly?: boolean @@ -83,25 +91,23 @@ export type UserPathValueMapType = | { path: 'themePreference' value: Required> + resourceKind?: never } | { path: 'pipelineRBACViewSelectedTab' value: Required> + resourceKind?: never } | { path: 'resources' value: Required + resourceKind: PreferredResourceKindType } export type UserPreferenceResourceProps = UserPathValueMapType & { shouldThrowError?: boolean } -export interface UserPreferenceRecentlyVisitedAppsTypes { - appId: number - appName: string -} - export interface UserPreferenceFilteredListTypes extends UserPreferenceRecentlyVisitedAppsTypes { userPreferencesResponse: UserPreferencesType } diff --git a/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx b/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx index 9b108a0cd..8a4ebef91 100644 --- a/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx +++ b/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx @@ -18,7 +18,6 @@ import { useState } from 'react' import { ServerErrors } from '@Common/ServerError' import { useTheme } from '@Shared/Providers' -import { ResourceKindType } from '@Shared/types' import { getUserPreferences, updateUserPreferences } from './service' import { @@ -36,26 +35,31 @@ export const useUserPreferences = ({ migrateUserPreferences }: UseUserPreference const { handleThemeSwitcherDialogVisibilityChange, handleThemePreferenceChange } = useTheme() - const fetchRecentlyVisitedParsedApps = async ({ appId, appName }: UserPreferenceRecentlyVisitedAppsTypes) => { - const userPreferencesResponse = await getUserPreferences() + const fetchRecentlyVisitedParsedApps = async ({ + appId, + appName, + resourceKind, + }: UserPreferenceRecentlyVisitedAppsTypes) => { + const userPreferencesResponse = await getUserPreferences({ resourceKindType: resourceKind }) const uniqueFilteredApps = getFilteredUniqueAppList({ userPreferencesResponse, appId, appName, + resourceKind, }) setUserPreferences((prev) => ({ ...prev, resources: { ...prev?.resources, - [ResourceKindType.devtronApplication]: { - ...prev?.resources?.[ResourceKindType.devtronApplication], + [resourceKind]: { + ...prev?.resources?.[resourceKind], [UserPreferenceResourceActions.RECENTLY_VISITED]: uniqueFilteredApps, }, }, })) - await updateUserPreferences({ path: 'resources', value: uniqueFilteredApps }) + await updateUserPreferences({ path: 'resources', value: uniqueFilteredApps, resourceKind }) } const handleInitializeUserPreferencesFromResponse = (userPreferencesResponse: UserPreferencesType) => { @@ -67,10 +71,10 @@ export const useUserPreferences = ({ migrateUserPreferences }: UseUserPreference setUserPreferences(userPreferencesResponse) } - const handleFetchUserPreferences = async () => { + const handleFetchUserPreferences = async (resourceKind: UserPreferenceRecentlyVisitedAppsTypes['resourceKind']) => { try { setUserPreferencesError(null) - const userPreferencesResponse = await getUserPreferences() + const userPreferencesResponse = await getUserPreferences({ resourceKindType: resourceKind }) if (migrateUserPreferences) { const migratedUserPreferences = await migrateUserPreferences(userPreferencesResponse) handleInitializeUserPreferencesFromResponse(migratedUserPreferences) diff --git a/src/Shared/Hooks/useUserPreferences/utils.tsx b/src/Shared/Hooks/useUserPreferences/utils.tsx index 1e077272b..c1bce6705 100644 --- a/src/Shared/Hooks/useUserPreferences/utils.tsx +++ b/src/Shared/Hooks/useUserPreferences/utils.tsx @@ -3,8 +3,11 @@ import { ResourceKindType } from '@Shared/types' import { UserPreferenceFilteredListTypes, UserPreferenceResourceActions, UserPreferenceResourceType } from './types' -export const getUserPreferenceResourcesMetadata = (recentlyVisited: BaseAppMetaData[]): UserPreferenceResourceType => ({ - [ResourceKindType.devtronApplication]: { +export const getUserPreferenceResourcesMetadata = ( + recentlyVisited: BaseAppMetaData[], + resourceKind: ResourceKindType, +): UserPreferenceResourceType => ({ + [resourceKind]: { [UserPreferenceResourceActions.RECENTLY_VISITED]: recentlyVisited.map(({ appId, appName }) => ({ appId, appName, @@ -16,11 +19,10 @@ export const getFilteredUniqueAppList = ({ userPreferencesResponse, appId, appName, + resourceKind, }: UserPreferenceFilteredListTypes) => { const _recentApps = - userPreferencesResponse?.resources?.[ResourceKindType.devtronApplication]?.[ - UserPreferenceResourceActions.RECENTLY_VISITED - ] || [] + userPreferencesResponse?.resources?.[resourceKind]?.[UserPreferenceResourceActions.RECENTLY_VISITED] || [] const isInvalidApp = appId && !appName From dd3130a3fecb30c38a133bd5d9c06147399fde97 Mon Sep 17 00:00:00 2001 From: shivani170 Date: Thu, 19 Jun 2025 13:46:43 +0530 Subject: [PATCH 02/90] chore: resource kind pass dynamically in payload --- src/Shared/Hooks/useUserPreferences/service.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Shared/Hooks/useUserPreferences/service.ts b/src/Shared/Hooks/useUserPreferences/service.ts index 6aac661dc..daebe0163 100644 --- a/src/Shared/Hooks/useUserPreferences/service.ts +++ b/src/Shared/Hooks/useUserPreferences/service.ts @@ -128,12 +128,15 @@ const getUserPreferencePayload = async ({ export const updateUserPreferences = async ({ path, value, + resourceKind, shouldThrowError = false, }: UserPreferenceResourceProps): Promise => { try { const payload: UpdateUserPreferencesPayloadType = { key: USER_PREFERENCES_ATTRIBUTE_KEY, - value: JSON.stringify(await getUserPreferencePayload({ path, value } as UserPathValueMapType)), + value: JSON.stringify( + await getUserPreferencePayload({ path, value, resourceKind } as UserPathValueMapType), + ), } await patch(`${ROUTES.ATTRIBUTES_USER}/${ROUTES.PATCH}`, payload) From a57ec47c7baeeb8c37719652c45dc5078676c12c Mon Sep 17 00:00:00 2001 From: shivani170 Date: Thu, 19 Jun 2025 16:06:02 +0530 Subject: [PATCH 03/90] chore: getUserPreferences props passed --- src/Shared/Hooks/useUserPreferences/service.ts | 4 ++-- src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Shared/Hooks/useUserPreferences/service.ts b/src/Shared/Hooks/useUserPreferences/service.ts index daebe0163..a53e6b34d 100644 --- a/src/Shared/Hooks/useUserPreferences/service.ts +++ b/src/Shared/Hooks/useUserPreferences/service.ts @@ -42,7 +42,7 @@ import { getUserPreferenceResourcesMetadata } from './utils' export const getUserPreferences = async ({ resourceKindType, }: { - resourceKindType: ResourceKindType + resourceKindType?: ResourceKindType }): Promise => { const queryParamsPayload: Pick = { key: USER_PREFERENCES_ATTRIBUTE_KEY, @@ -86,7 +86,7 @@ export const getUserPreferences = async ({ ? THEME_PREFERENCE_MAP.auto : parsedResult.computedAppTheme, resources: { - [resourceKindType]: getPreferredResourcesData(), + [resourceKindType]: resourceKindType ? getPreferredResourcesData() : {}, }, } } diff --git a/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx b/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx index 8a4ebef91..0e62ce21a 100644 --- a/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx +++ b/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx @@ -71,10 +71,10 @@ export const useUserPreferences = ({ migrateUserPreferences }: UseUserPreference setUserPreferences(userPreferencesResponse) } - const handleFetchUserPreferences = async (resourceKind: UserPreferenceRecentlyVisitedAppsTypes['resourceKind']) => { + const handleFetchUserPreferences = async () => { try { setUserPreferencesError(null) - const userPreferencesResponse = await getUserPreferences({ resourceKindType: resourceKind }) + const userPreferencesResponse = await getUserPreferences({}) if (migrateUserPreferences) { const migratedUserPreferences = await migrateUserPreferences(userPreferencesResponse) handleInitializeUserPreferencesFromResponse(migratedUserPreferences) From 67af0c3f552de0bbdaf451e4e5830c2735968371 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Mon, 23 Jun 2025 12:27:03 +0530 Subject: [PATCH 04/90] feat: refactor ActionMenuItem and add TrailingItem component for improved rendering --- .../Components/ActionMenu/ActionMenuItem.tsx | 62 +------------------ src/Shared/Components/ActionMenu/types.ts | 51 +-------------- .../TrailingItem/TrailingItem.component.tsx | 61 ++++++++++++++++++ src/Shared/Components/TrailingItem/index.ts | 2 + src/Shared/Components/TrailingItem/types.ts | 57 +++++++++++++++++ 5 files changed, 125 insertions(+), 108 deletions(-) create mode 100644 src/Shared/Components/TrailingItem/TrailingItem.component.tsx create mode 100644 src/Shared/Components/TrailingItem/index.ts create mode 100644 src/Shared/Components/TrailingItem/types.ts diff --git a/src/Shared/Components/ActionMenu/ActionMenuItem.tsx b/src/Shared/Components/ActionMenu/ActionMenuItem.tsx index b140129d1..8d9970c03 100644 --- a/src/Shared/Components/ActionMenu/ActionMenuItem.tsx +++ b/src/Shared/Components/ActionMenu/ActionMenuItem.tsx @@ -2,14 +2,11 @@ import { LegacyRef, MouseEvent, Ref } from 'react' import { Link } from 'react-router-dom' import { Tooltip } from '@Common/Tooltip' -import { ComponentSizeType } from '@Shared/constants' -import { Button, ButtonProps, ButtonVariantType } from '../Button' import { Icon } from '../Icon' -import { NumbersCount } from '../NumbersCount' import { getTooltipProps } from '../SelectPicker/common' -import { DTSwitch, DTSwitchProps } from '../Switch' -import { ActionMenuItemProps, ActionMenuItemType } from './types' +import { TrailingItem } from '../TrailingItem' +import { ActionMenuItemProps } from './types' const COMMON_ACTION_MENU_ITEM_CLASS = 'w-100 flex left top dc__gap-8 py-6 px-8' @@ -48,24 +45,6 @@ export const ActionMenuItem = ({ onClick(item, e) } - const handleTrailingSwitchChange = - ({ type: trailingItemType, config }: ActionMenuItemType['trailingItem']): DTSwitchProps['onChange'] => - (e) => { - if (trailingItemType === 'switch') { - e.stopPropagation() - config.onChange(e) - } - } - - const handleTrailingButtonClick = - ({ type: trailingItemType, config }: ActionMenuItemType['trailingItem']): ButtonProps['onClick'] => - (e) => { - e.stopPropagation() - if (trailingItemType === 'button' && config.onClick) { - config.onClick(e) - } - } - // RENDERERS const renderIcon = (iconProps: typeof startIcon) => iconProps && ( @@ -79,42 +58,7 @@ export const ActionMenuItem = ({ return null } - const { type: trailingItemType, config } = trailingItem - - switch (trailingItemType) { - case 'icon': - return renderIcon(config) - case 'text': { - const { value, icon } = config - return ( - - {value} - {icon && } - - ) - } - case 'counter': - return - case 'switch': - return ( - - ) - case 'button': - return ( - + + {node.trailingItem && } + + + + {isExpanded && ( +
+ {!node.items?.length ? ( + {node.noItemsText || 'No items found.'} + ) : ( +
+ {node.items.map((nodeItem) => ( + + ))} +
+ )} +
+ )} + + ) + } + + const isSelected = selectedId === node.id + + const content = ( + + {node.startIconConfig && ( + + + + )} + + {/* TODO: Tooltip */} + + {/* TODO: Strike through */} + {node.title} + {node.subtitle && {node.subtitle}} + + + ) + + return ( +
+ {node.as === 'link' ? ( + { + // Prevent navigation to the same page + if (node.href === pathname) { + e.preventDefault() + } + node.onClick?.(e) + onSelect(node) + }} + > + {content} + + ) : ( + + )} + + {node.trailingItem && } +
+ ) + })} + + ) +} + +export default TreeView diff --git a/src/Shared/Components/TreeView/TreeView.scss b/src/Shared/Components/TreeView/TreeView.scss new file mode 100644 index 000000000..e8cb71be5 --- /dev/null +++ b/src/Shared/Components/TreeView/TreeView.scss @@ -0,0 +1,72 @@ +.tree-view { + &__container { + &[aria-selected="true"] &--title { + color: var(--B500); + font-weight: 600; + background: var(--B100); + } + } + + &__heading-group-wrapper { + position: relative; + + &--expanded::before { + content: ""; + position: absolute; + left: 17px; + top: 24px; + bottom: 0; + width: 1px; + background-color: var(--N200); + z-index: 0; + } + } + + &__group { + margin-left: 24px; + border-left: 1px solid transparent; + padding-top: 8px; + padding-bottom: 8px; + position: relative; + } + + &__item { + position: relative; + padding-left: 16px; + margin: 4px 0; + + &::before { + content: ""; + position: absolute; + left: -8px; + top: 0; + bottom: 0; + width: 1px; + background-color: var(--N200); + z-index: 1; + } + + &::after { + content: ""; + position: absolute; + left: -8px; + top: 0; + width: 1px; + height: 100%; + z-index: 2; + opacity: 0; + background-image: linear-gradient(to bottom, + var(--bg-primary) 0px, + var(--bg-primary) 4px, + var(--B500) 4px, + var(--B500) calc(100% - 4px), + var(--bg-primary) calc(100% - 4px), + var(--bg-primary) 100%); + transition: opacity 0.2s ease; + } + + &:hover::after { + opacity: 1; + } + } +} \ No newline at end of file diff --git a/src/Shared/Components/TreeView/index.ts b/src/Shared/Components/TreeView/index.ts new file mode 100644 index 000000000..f64ae9a11 --- /dev/null +++ b/src/Shared/Components/TreeView/index.ts @@ -0,0 +1 @@ +export { default as TreeView } from './TreeView.component' diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts new file mode 100644 index 000000000..87c28fd32 --- /dev/null +++ b/src/Shared/Components/TreeView/types.ts @@ -0,0 +1,78 @@ +import { IconsProps } from '../Icon' +import { TrailingItemProps } from '../TrailingItem' + +// eslint-disable-next-line no-use-before-define +export type TreeNode = TreeHeading | TreeItem + +interface BaseNode { + id: string + /** + * The title of the list item. + */ + title: string + /** + * The subtitle of the list item. + */ + subtitle?: string + /** + * If true, the title will be rendered with line-through. + */ + strikeThrough?: boolean + startIconConfig?: Pick & { + tooltipContent?: string + } + trailingItem?: TrailingItemProps +} + +export interface TreeHeading extends BaseNode { + type: 'heading' + items?: TreeNode[] + /** + * Text to display when there are no items in the list. + * @default 'No items found.' + */ + noItemsText?: string +} + +export type TreeItem = BaseNode & { + type: 'item' + /** + * @default false + */ + isDisabled?: boolean +} & ( + | { + as?: 'button' + /** + * The callback function to handle click events on the button. + */ + onClick?: (e: React.MouseEvent) => void + href?: never + clearQueryParamsOnNavigation?: never + } + | { + as: 'link' + href: string + /** + * The callback function to handle click events on the nav link. + */ + onClick?: (e: React.MouseEvent) => void + /** + * If `true`, clears query parameters during navigation. + * @default false + */ + clearQueryParamsOnNavigation?: boolean + } + ) + +export interface TreeViewProps { + nodes: TreeNode[] + expandedMap: Record + selectedId?: string + onToggle: (item: TreeHeading) => void + onSelect: (item: TreeItem) => void + /** + * WARNING: For internal use only. + */ + depth?: number +} diff --git a/src/Shared/Components/index.ts b/src/Shared/Components/index.ts index b085ca5bc..25fa4ff67 100644 --- a/src/Shared/Components/index.ts +++ b/src/Shared/Components/index.ts @@ -99,6 +99,7 @@ export * from './TargetPlatforms' export * from './Textarea' export * from './ThemeSwitcher' export * from './ToggleResolveScopedVariables' +export * from './TreeView' export * from './Typewriter' export * from './UnsavedChanges' export * from './UnsavedChangesDialog' From 3d9334e7a1d69170154c6001d8c1f4ede943031b Mon Sep 17 00:00:00 2001 From: shivani170 Date: Tue, 24 Jun 2025 06:16:56 +0530 Subject: [PATCH 06/90] chore: version bump to 1.15.3-beta-3 --- package-lock.json | 4 +- package.json | 2 +- .../Hooks/useUserPreferences/service.ts | 55 +++++++++---------- src/Shared/Hooks/useUserPreferences/types.ts | 11 +++- .../useUserPreferences/useUserPrefrences.tsx | 11 +++- src/Shared/types.ts | 1 + 6 files changed, 48 insertions(+), 36 deletions(-) diff --git a/package-lock.json b/package-lock.json index 75466eab9..c71d97b45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.15.3-pre-4", + "version": "1.15.3-beta-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.15.3-pre-4", + "version": "1.15.3-beta-3", "hasInstallScript": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index be84c550e..2c660d715 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.15.3-pre-4", + "version": "1.15.3-beta-3", "description": "Supporting common component library", "type": "module", "main": "dist/index.js", diff --git a/src/Shared/Hooks/useUserPreferences/service.ts b/src/Shared/Hooks/useUserPreferences/service.ts index a53e6b34d..dea97f5b0 100644 --- a/src/Shared/Hooks/useUserPreferences/service.ts +++ b/src/Shared/Hooks/useUserPreferences/service.ts @@ -18,7 +18,6 @@ import { ROUTES } from '@Common/Constants' import { get, getUrlWithSearchParams, patch, showError } from '@Common/index' import { THEME_PREFERENCE_MAP } from '@Shared/Providers/ThemeProvider/types' import { BaseAppMetaData } from '@Shared/Services' -import { ResourceKindType } from '@Shared/types' import { USER_PREFERENCES_ATTRIBUTE_KEY } from './constants' import { @@ -39,11 +38,7 @@ import { getUserPreferenceResourcesMetadata } from './utils' * @description This function fetches the user preferences from the server. It uses the `get` method to make a request to the server and retrieves the user preferences based on the `USER_PREFERENCES_ATTRIBUTE_KEY`. The result is parsed and returned as a `UserPreferencesType` object. * @throws Will throw an error if the request fails or if the result is not in the expected format. */ -export const getUserPreferences = async ({ - resourceKindType, -}: { - resourceKindType?: ResourceKindType -}): Promise => { +export const getUserPreferences = async (): Promise => { const queryParamsPayload: Pick = { key: USER_PREFERENCES_ATTRIBUTE_KEY, } @@ -62,32 +57,13 @@ export const getUserPreferences = async ({ ? ViewIsPipelineRBACConfiguredRadioTabs.ACCESS_ONLY : ViewIsPipelineRBACConfiguredRadioTabs.ALL_ENVIRONMENTS - const getPreferredResourcesData = () => { - let resources - switch (resourceKindType) { - case ResourceKindType.devtronApplication: - resources = { - [UserPreferenceResourceActions.RECENTLY_VISITED]: - parsedResult.resources?.[ResourceKindType.devtronApplication]?.[ - UserPreferenceResourceActions.RECENTLY_VISITED - ] || ([] as BaseAppMetaData[]), - } - break - default: - resources = {} - } - return resources - } - return { pipelineRBACViewSelectedTab, themePreference: parsedResult.computedAppTheme === 'system-dark' || parsedResult.computedAppTheme === 'system-light' ? THEME_PREFERENCE_MAP.auto : parsedResult.computedAppTheme, - resources: { - [resourceKindType]: resourceKindType ? getPreferredResourcesData() : {}, - }, + resources: parsedResult.resources, } } /** @@ -103,6 +79,7 @@ const getUserPreferencePayload = async ({ path, value, resourceKind, + userPreferencesResponse, }: UserPathValueMapType): Promise> => { switch (path) { case 'themePreference': @@ -116,10 +93,24 @@ const getUserPreferencePayload = async ({ value.pipelineRBACViewSelectedTab === ViewIsPipelineRBACConfiguredRadioTabs.ACCESS_ONLY, } - case 'resources': + case 'resources': { + const existingResources = userPreferencesResponse?.resources || {} + + const updatedResources = { + ...existingResources, + [resourceKind]: { + ...existingResources[resourceKind], + [UserPreferenceResourceActions.RECENTLY_VISITED]: + getUserPreferenceResourcesMetadata(value as BaseAppMetaData[], resourceKind)[resourceKind]?.[ + UserPreferenceResourceActions.RECENTLY_VISITED + ] || [], + }, + } + return { - resources: getUserPreferenceResourcesMetadata(value as BaseAppMetaData[], resourceKind), + resources: updatedResources, } + } default: return {} } @@ -130,12 +121,18 @@ export const updateUserPreferences = async ({ value, resourceKind, shouldThrowError = false, + userPreferencesResponse, }: UserPreferenceResourceProps): Promise => { try { const payload: UpdateUserPreferencesPayloadType = { key: USER_PREFERENCES_ATTRIBUTE_KEY, value: JSON.stringify( - await getUserPreferencePayload({ path, value, resourceKind } as UserPathValueMapType), + await getUserPreferencePayload({ + path, + value, + resourceKind, + userPreferencesResponse, + } as UserPathValueMapType), ), } diff --git a/src/Shared/Hooks/useUserPreferences/types.ts b/src/Shared/Hooks/useUserPreferences/types.ts index 14c185177..589c14795 100644 --- a/src/Shared/Hooks/useUserPreferences/types.ts +++ b/src/Shared/Hooks/useUserPreferences/types.ts @@ -31,7 +31,11 @@ export enum ViewIsPipelineRBACConfiguredRadioTabs { export enum UserPreferenceResourceActions { RECENTLY_VISITED = 'recently-visited', } -export type PreferredResourceKindType = ResourceKindType.devtronApplication +export type PreferredResourceKindType = + | ResourceKindType.devtronApplication + | ResourceKindType.job + | ResourceKindType.appGroup + | ResourceKindType.cluster export interface UserPreferenceRecentlyVisitedAppsTypes { appId: number @@ -84,6 +88,7 @@ export interface UserPreferencesType { export interface UpdatedUserPreferencesType extends UserPreferencesType, Pick {} export interface UseUserPreferencesProps { + userPreferenceResourceKind?: ResourceKindType migrateUserPreferences?: (userPreferencesResponse: UserPreferencesType) => Promise } @@ -92,20 +97,24 @@ export type UserPathValueMapType = path: 'themePreference' value: Required> resourceKind?: never + userPreferencesResponse?: never } | { path: 'pipelineRBACViewSelectedTab' value: Required> resourceKind?: never + userPreferencesResponse?: never } | { path: 'resources' value: Required resourceKind: PreferredResourceKindType + userPreferencesResponse? } export type UserPreferenceResourceProps = UserPathValueMapType & { shouldThrowError?: boolean + userPreferencesResponse? } export interface UserPreferenceFilteredListTypes extends UserPreferenceRecentlyVisitedAppsTypes { diff --git a/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx b/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx index 0e62ce21a..a4b116003 100644 --- a/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx +++ b/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx @@ -40,7 +40,7 @@ export const useUserPreferences = ({ migrateUserPreferences }: UseUserPreference appName, resourceKind, }: UserPreferenceRecentlyVisitedAppsTypes) => { - const userPreferencesResponse = await getUserPreferences({ resourceKindType: resourceKind }) + const userPreferencesResponse = await getUserPreferences() const uniqueFilteredApps = getFilteredUniqueAppList({ userPreferencesResponse, @@ -59,7 +59,12 @@ export const useUserPreferences = ({ migrateUserPreferences }: UseUserPreference }, }, })) - await updateUserPreferences({ path: 'resources', value: uniqueFilteredApps, resourceKind }) + await updateUserPreferences({ + path: 'resources', + value: uniqueFilteredApps, + resourceKind, + userPreferencesResponse, + }) } const handleInitializeUserPreferencesFromResponse = (userPreferencesResponse: UserPreferencesType) => { @@ -74,7 +79,7 @@ export const useUserPreferences = ({ migrateUserPreferences }: UseUserPreference const handleFetchUserPreferences = async () => { try { setUserPreferencesError(null) - const userPreferencesResponse = await getUserPreferences({}) + const userPreferencesResponse = await getUserPreferences() if (migrateUserPreferences) { const migratedUserPreferences = await migrateUserPreferences(userPreferencesResponse) handleInitializeUserPreferencesFromResponse(migratedUserPreferences) diff --git a/src/Shared/types.ts b/src/Shared/types.ts index 5c74fe3aa..b389c0e8b 100644 --- a/src/Shared/types.ts +++ b/src/Shared/types.ts @@ -499,6 +499,7 @@ export enum ResourceKindType { cdPipeline = 'cd-pipeline', ciPipeline = 'ci-pipeline', project = 'project', + appGroup = 'app-group', } /** From 51576c9633460f625e3637570ca57c8ab4e34de1 Mon Sep 17 00:00:00 2001 From: shivani170 Date: Tue, 24 Jun 2025 15:08:47 +0530 Subject: [PATCH 07/90] chore: context switcher moved here --- .../ContextSwitcher/ContextSwitcher.tsx | 41 +++++++++++++++++++ .../Components/ContextSwitcher/index.ts | 3 ++ .../Components/ContextSwitcher/types.ts | 30 ++++++++++++++ .../Components/ContextSwitcher/utils.ts | 14 +++++++ src/Shared/Components/index.ts | 1 + .../Hooks/useUserPreferences/service.ts | 8 ++-- src/Shared/Hooks/useUserPreferences/types.ts | 12 +++--- .../useUserPreferences/useUserPrefrences.tsx | 4 +- src/Shared/Hooks/useUserPreferences/utils.tsx | 10 +++-- src/Shared/types.ts | 1 - 10 files changed, 107 insertions(+), 17 deletions(-) create mode 100644 src/Shared/Components/ContextSwitcher/ContextSwitcher.tsx create mode 100644 src/Shared/Components/ContextSwitcher/index.ts create mode 100644 src/Shared/Components/ContextSwitcher/types.ts create mode 100644 src/Shared/Components/ContextSwitcher/utils.ts diff --git a/src/Shared/Components/ContextSwitcher/ContextSwitcher.tsx b/src/Shared/Components/ContextSwitcher/ContextSwitcher.tsx new file mode 100644 index 000000000..e61a91dcb --- /dev/null +++ b/src/Shared/Components/ContextSwitcher/ContextSwitcher.tsx @@ -0,0 +1,41 @@ +import { getNoMatchingResultText, SelectPicker, SelectPickerVariantType } from '@Shared/Components' +import { ComponentSizeType } from '@Shared/constants' + +import { ContextSwitcherTypes } from './types' +import { customSelect, getDisabledOptions } from './utils' + +export const ContextSwitcher = ({ + inputId, + options = [], + inputValue, + onInputChange, + isLoading, + value, + onChange, + placeholder, + filterOption, + formatOptionLabel, +}: ContextSwitcherTypes) => { + const selectedOptions = options?.map((section) => ({ + ...section, + options: section?.label === 'Recently Visited' ? section.options?.slice(1) : section.options, + })) + return ( + + ) +} diff --git a/src/Shared/Components/ContextSwitcher/index.ts b/src/Shared/Components/ContextSwitcher/index.ts new file mode 100644 index 000000000..43181ffa6 --- /dev/null +++ b/src/Shared/Components/ContextSwitcher/index.ts @@ -0,0 +1,3 @@ +export { ContextSwitcher } from './ContextSwitcher' +export type { ContextSwitcherTypes, RecentlyVisitedGroupedOptionsType, RecentlyVisitedOptions } from './types' +export { getMinCharSearchPlaceholderGroup } from './utils' diff --git a/src/Shared/Components/ContextSwitcher/types.ts b/src/Shared/Components/ContextSwitcher/types.ts new file mode 100644 index 000000000..10bf49f6f --- /dev/null +++ b/src/Shared/Components/ContextSwitcher/types.ts @@ -0,0 +1,30 @@ +import { GroupBase } from 'react-select' + +import { SelectPickerOptionType, SelectPickerProps } from '../SelectPicker' + +export interface ContextSwitcherTypes + extends Pick< + SelectPickerProps, + | 'placeholder' + | 'onChange' + | 'value' + | 'isLoading' + | 'onInputChange' + | 'inputValue' + | 'inputId' + | 'formatOptionLabel' + | 'filterOption' + > { + options: GroupBase>[] + isAppDataAvailable?: boolean +} + +export interface RecentlyVisitedOptions extends SelectPickerOptionType { + isDisabled?: boolean + isRecentlyVisited?: boolean +} + +export interface RecentlyVisitedGroupedOptionsType extends GroupBase> { + label: string + options: RecentlyVisitedOptions[] +} diff --git a/src/Shared/Components/ContextSwitcher/utils.ts b/src/Shared/Components/ContextSwitcher/utils.ts new file mode 100644 index 000000000..fa23bbda7 --- /dev/null +++ b/src/Shared/Components/ContextSwitcher/utils.ts @@ -0,0 +1,14 @@ +import { SelectPickerProps } from '../SelectPicker' +import { RecentlyVisitedGroupedOptionsType, RecentlyVisitedOptions } from './types' + +export const getDisabledOptions = (option: RecentlyVisitedOptions): SelectPickerProps['isDisabled'] => option.isDisabled + +export const customSelect: SelectPickerProps['filterOption'] = (option, searchText: string) => { + const label = option.data.label as string + return option.data.value === 0 || label.toLowerCase().includes(searchText.toLowerCase()) +} + +export const getMinCharSearchPlaceholderGroup = (resourceKind: string): RecentlyVisitedGroupedOptionsType => ({ + label: `All ${resourceKind}`, + options: [{ value: 0, label: 'Type 3 characters to search', isDisabled: true }], +}) diff --git a/src/Shared/Components/index.ts b/src/Shared/Components/index.ts index b085ca5bc..c79d6a3df 100644 --- a/src/Shared/Components/index.ts +++ b/src/Shared/Components/index.ts @@ -37,6 +37,7 @@ export * from './CollapsibleList' export * from './CommitChipCell' export * from './Confetti' export * from './ConfirmationModal' +export * from './ContextSwitcher' export * from './CountrySelect' export * from './CustomInput' export * from './DatePicker' diff --git a/src/Shared/Hooks/useUserPreferences/service.ts b/src/Shared/Hooks/useUserPreferences/service.ts index dea97f5b0..5413eb016 100644 --- a/src/Shared/Hooks/useUserPreferences/service.ts +++ b/src/Shared/Hooks/useUserPreferences/service.ts @@ -17,10 +17,10 @@ import { ROUTES } from '@Common/Constants' import { get, getUrlWithSearchParams, patch, showError } from '@Common/index' import { THEME_PREFERENCE_MAP } from '@Shared/Providers/ThemeProvider/types' -import { BaseAppMetaData } from '@Shared/Services' import { USER_PREFERENCES_ATTRIBUTE_KEY } from './constants' import { + BaseRecentlyVisitedEntitiesTypes, GetUserPreferencesParsedDTO, GetUserPreferencesQueryParamsType, UpdateUserPreferencesPayloadType, @@ -101,9 +101,9 @@ const getUserPreferencePayload = async ({ [resourceKind]: { ...existingResources[resourceKind], [UserPreferenceResourceActions.RECENTLY_VISITED]: - getUserPreferenceResourcesMetadata(value as BaseAppMetaData[], resourceKind)[resourceKind]?.[ - UserPreferenceResourceActions.RECENTLY_VISITED - ] || [], + getUserPreferenceResourcesMetadata(value as BaseRecentlyVisitedEntitiesTypes[], resourceKind)[ + resourceKind + ]?.[UserPreferenceResourceActions.RECENTLY_VISITED] || [], }, } diff --git a/src/Shared/Hooks/useUserPreferences/types.ts b/src/Shared/Hooks/useUserPreferences/types.ts index 589c14795..394c645a7 100644 --- a/src/Shared/Hooks/useUserPreferences/types.ts +++ b/src/Shared/Hooks/useUserPreferences/types.ts @@ -34,7 +34,7 @@ export enum UserPreferenceResourceActions { export type PreferredResourceKindType = | ResourceKindType.devtronApplication | ResourceKindType.job - | ResourceKindType.appGroup + | 'app-group' | ResourceKindType.cluster export interface UserPreferenceRecentlyVisitedAppsTypes { @@ -46,9 +46,7 @@ export interface UserPreferenceRecentlyVisitedAppsTypes { export interface UserResourceKindActionType { [UserPreferenceResourceActions.RECENTLY_VISITED]: BaseAppMetaData[] } -export type UserPreferenceResourceType = { - [key in ResourceKindType]?: UserResourceKindActionType -} +export type UserPreferenceResourceType = Partial> export interface GetUserPreferencesParsedDTO { viewPermittedEnvOnly?: boolean /** @@ -88,7 +86,7 @@ export interface UserPreferencesType { export interface UpdatedUserPreferencesType extends UserPreferencesType, Pick {} export interface UseUserPreferencesProps { - userPreferenceResourceKind?: ResourceKindType + userPreferenceResourceKind?: PreferredResourceKindType migrateUserPreferences?: (userPreferencesResponse: UserPreferencesType) => Promise } @@ -109,12 +107,12 @@ export type UserPathValueMapType = path: 'resources' value: Required resourceKind: PreferredResourceKindType - userPreferencesResponse? + userPreferencesResponse?: UserPreferencesType } export type UserPreferenceResourceProps = UserPathValueMapType & { shouldThrowError?: boolean - userPreferencesResponse? + userPreferencesResponse?: UserPreferencesType } export interface UserPreferenceFilteredListTypes extends UserPreferenceRecentlyVisitedAppsTypes { diff --git a/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx b/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx index a4b116003..02498936c 100644 --- a/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx +++ b/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx @@ -35,7 +35,7 @@ export const useUserPreferences = ({ migrateUserPreferences }: UseUserPreference const { handleThemeSwitcherDialogVisibilityChange, handleThemePreferenceChange } = useTheme() - const fetchRecentlyVisitedParsedApps = async ({ + const fetchRecentlyVisitedParsedEntities = async ({ appId, appName, resourceKind, @@ -112,6 +112,6 @@ export const useUserPreferences = ({ migrateUserPreferences }: UseUserPreference handleFetchUserPreferences, handleUpdatePipelineRBACViewSelectedTab, handleUpdateUserThemePreference, - fetchRecentlyVisitedParsedApps, + fetchRecentlyVisitedParsedEntities, } } diff --git a/src/Shared/Hooks/useUserPreferences/utils.tsx b/src/Shared/Hooks/useUserPreferences/utils.tsx index c1bce6705..f3a56af20 100644 --- a/src/Shared/Hooks/useUserPreferences/utils.tsx +++ b/src/Shared/Hooks/useUserPreferences/utils.tsx @@ -1,11 +1,15 @@ import { BaseAppMetaData } from '@Shared/Services' -import { ResourceKindType } from '@Shared/types' -import { UserPreferenceFilteredListTypes, UserPreferenceResourceActions, UserPreferenceResourceType } from './types' +import { + PreferredResourceKindType, + UserPreferenceFilteredListTypes, + UserPreferenceResourceActions, + UserPreferenceResourceType, +} from './types' export const getUserPreferenceResourcesMetadata = ( recentlyVisited: BaseAppMetaData[], - resourceKind: ResourceKindType, + resourceKind: PreferredResourceKindType, ): UserPreferenceResourceType => ({ [resourceKind]: { [UserPreferenceResourceActions.RECENTLY_VISITED]: recentlyVisited.map(({ appId, appName }) => ({ diff --git a/src/Shared/types.ts b/src/Shared/types.ts index b389c0e8b..5c74fe3aa 100644 --- a/src/Shared/types.ts +++ b/src/Shared/types.ts @@ -499,7 +499,6 @@ export enum ResourceKindType { cdPipeline = 'cd-pipeline', ciPipeline = 'ci-pipeline', project = 'project', - appGroup = 'app-group', } /** From 92672b021846bdb158d54ad276ec64b0b14f2a99 Mon Sep 17 00:00:00 2001 From: shivani170 Date: Tue, 24 Jun 2025 16:26:06 +0530 Subject: [PATCH 08/90] chore: recently visited apps & fetch from useUserPreferences --- .../Hooks/useUserPreferences/service.ts | 13 ++++- src/Shared/Hooks/useUserPreferences/types.ts | 18 ++++--- .../useUserPreferences/useUserPrefrences.tsx | 47 +++++++++++-------- src/Shared/Hooks/useUserPreferences/utils.tsx | 25 +++++----- 4 files changed, 64 insertions(+), 39 deletions(-) diff --git a/src/Shared/Hooks/useUserPreferences/service.ts b/src/Shared/Hooks/useUserPreferences/service.ts index 5413eb016..13d5b45ae 100644 --- a/src/Shared/Hooks/useUserPreferences/service.ts +++ b/src/Shared/Hooks/useUserPreferences/service.ts @@ -33,6 +33,17 @@ import { } from './types' import { getUserPreferenceResourcesMetadata } from './utils' +export const getParsedResourcesMap = (resources: GetUserPreferencesParsedDTO['resources']) => { + const resourcesMap = resources || {} + const parsedResourcesMap: UserPreferencesType['resources'] = {} + + Object.entries(resourcesMap).forEach(([resourceKind, resourceActions]) => { + parsedResourcesMap[resourceKind] = resourceActions + }) + + return parsedResourcesMap +} + /** * @returns UserPreferencesType * @description This function fetches the user preferences from the server. It uses the `get` method to make a request to the server and retrieves the user preferences based on the `USER_PREFERENCES_ATTRIBUTE_KEY`. The result is parsed and returned as a `UserPreferencesType` object. @@ -63,7 +74,7 @@ export const getUserPreferences = async (): Promise => { parsedResult.computedAppTheme === 'system-dark' || parsedResult.computedAppTheme === 'system-light' ? THEME_PREFERENCE_MAP.auto : parsedResult.computedAppTheme, - resources: parsedResult.resources, + resources: getParsedResourcesMap(parsedResult.resources), } } /** diff --git a/src/Shared/Hooks/useUserPreferences/types.ts b/src/Shared/Hooks/useUserPreferences/types.ts index 394c645a7..8b126a267 100644 --- a/src/Shared/Hooks/useUserPreferences/types.ts +++ b/src/Shared/Hooks/useUserPreferences/types.ts @@ -16,7 +16,6 @@ import { USER_PREFERENCES_ATTRIBUTE_KEY } from '@Shared/Hooks/useUserPreferences/constants' import { AppThemeType, ThemeConfigType, ThemePreferenceType } from '@Shared/Providers/ThemeProvider/types' -import { BaseAppMetaData } from '@Shared/Services' import { ResourceKindType } from '@Shared/types' export interface GetUserPreferencesQueryParamsType { @@ -37,14 +36,16 @@ export type PreferredResourceKindType = | 'app-group' | ResourceKindType.cluster -export interface UserPreferenceRecentlyVisitedAppsTypes { - appId: number - appName: string +export interface BaseRecentlyVisitedEntitiesTypes { + id: number + name: string +} +export interface UserPreferenceRecentlyVisitedAppsTypes extends BaseRecentlyVisitedEntitiesTypes { resourceKind: PreferredResourceKindType } export interface UserResourceKindActionType { - [UserPreferenceResourceActions.RECENTLY_VISITED]: BaseAppMetaData[] + [UserPreferenceResourceActions.RECENTLY_VISITED]: BaseRecentlyVisitedEntitiesTypes[] } export type UserPreferenceResourceType = Partial> export interface GetUserPreferencesParsedDTO { @@ -85,9 +86,14 @@ export interface UserPreferencesType { export interface UpdatedUserPreferencesType extends UserPreferencesType, Pick {} +export interface RecentlyVisitedFetchConfigType extends UserPreferenceRecentlyVisitedAppsTypes { + isDataAvailable?: boolean +} + export interface UseUserPreferencesProps { userPreferenceResourceKind?: PreferredResourceKindType migrateUserPreferences?: (userPreferencesResponse: UserPreferencesType) => Promise + recentlyVisitedFetchConfig?: RecentlyVisitedFetchConfigType } export type UserPathValueMapType = @@ -105,7 +111,7 @@ export type UserPathValueMapType = } | { path: 'resources' - value: Required + value: Required resourceKind: PreferredResourceKindType userPreferencesResponse?: UserPreferencesType } diff --git a/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx b/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx index 02498936c..98fa77cc3 100644 --- a/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx +++ b/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx @@ -16,12 +16,13 @@ import { useState } from 'react' +import { useAsync } from '@Common/Helper' import { ServerErrors } from '@Common/ServerError' import { useTheme } from '@Shared/Providers' import { getUserPreferences, updateUserPreferences } from './service' import { - UserPreferenceRecentlyVisitedAppsTypes, + BaseRecentlyVisitedEntitiesTypes, UserPreferenceResourceActions, UserPreferencesType, UseUserPreferencesProps, @@ -29,44 +30,51 @@ import { } from './types' import { getFilteredUniqueAppList } from './utils' -export const useUserPreferences = ({ migrateUserPreferences }: UseUserPreferencesProps) => { +export const useUserPreferences = ({ migrateUserPreferences, recentlyVisitedFetchConfig }: UseUserPreferencesProps) => { const [userPreferences, setUserPreferences] = useState(null) const [userPreferencesError, setUserPreferencesError] = useState(null) + const { id, name, resourceKind, isDataAvailable } = recentlyVisitedFetchConfig ?? {} + const { handleThemeSwitcherDialogVisibilityChange, handleThemePreferenceChange } = useTheme() - const fetchRecentlyVisitedParsedEntities = async ({ - appId, - appName, - resourceKind, - }: UserPreferenceRecentlyVisitedAppsTypes) => { + const fetchRecentlyVisitedParsedEntities = async (): Promise => { const userPreferencesResponse = await getUserPreferences() const uniqueFilteredApps = getFilteredUniqueAppList({ userPreferencesResponse, - appId, - appName, + id, + name, resourceKind, }) - setUserPreferences((prev) => ({ - ...prev, - resources: { - ...prev?.resources, - [resourceKind]: { - ...prev?.resources?.[resourceKind], - [UserPreferenceResourceActions.RECENTLY_VISITED]: uniqueFilteredApps, - }, - }, - })) await updateUserPreferences({ path: 'resources', value: uniqueFilteredApps, resourceKind, userPreferencesResponse, }) + + const updatedUserPreferences = { + ...userPreferencesResponse, + resources: { + ...userPreferencesResponse?.resources, + [resourceKind]: { + ...userPreferencesResponse?.resources?.[resourceKind], + [UserPreferenceResourceActions.RECENTLY_VISITED]: uniqueFilteredApps, + }, + }, + } + + return updatedUserPreferences } + const [, recentResourcesResult] = useAsync(() => fetchRecentlyVisitedParsedEntities(), [id, name], isDataAvailable) + + const recentlyVisitedResources = + recentResourcesResult?.resources?.[resourceKind]?.[UserPreferenceResourceActions.RECENTLY_VISITED] || + ([] as BaseRecentlyVisitedEntitiesTypes[]) + const handleInitializeUserPreferencesFromResponse = (userPreferencesResponse: UserPreferencesType) => { if (!userPreferencesResponse?.themePreference) { handleThemeSwitcherDialogVisibilityChange(true) @@ -113,5 +121,6 @@ export const useUserPreferences = ({ migrateUserPreferences }: UseUserPreference handleUpdatePipelineRBACViewSelectedTab, handleUpdateUserThemePreference, fetchRecentlyVisitedParsedEntities, + recentlyVisitedResources, } } diff --git a/src/Shared/Hooks/useUserPreferences/utils.tsx b/src/Shared/Hooks/useUserPreferences/utils.tsx index f3a56af20..834bf9279 100644 --- a/src/Shared/Hooks/useUserPreferences/utils.tsx +++ b/src/Shared/Hooks/useUserPreferences/utils.tsx @@ -1,6 +1,5 @@ -import { BaseAppMetaData } from '@Shared/Services' - import { + BaseRecentlyVisitedEntitiesTypes, PreferredResourceKindType, UserPreferenceFilteredListTypes, UserPreferenceResourceActions, @@ -8,35 +7,35 @@ import { } from './types' export const getUserPreferenceResourcesMetadata = ( - recentlyVisited: BaseAppMetaData[], + recentlyVisited: BaseRecentlyVisitedEntitiesTypes[], resourceKind: PreferredResourceKindType, ): UserPreferenceResourceType => ({ [resourceKind]: { - [UserPreferenceResourceActions.RECENTLY_VISITED]: recentlyVisited.map(({ appId, appName }) => ({ - appId, - appName, + [UserPreferenceResourceActions.RECENTLY_VISITED]: recentlyVisited.map(({ id, name }) => ({ + id, + name, })), }, }) export const getFilteredUniqueAppList = ({ userPreferencesResponse, - appId, - appName, + id, + name, resourceKind, }: UserPreferenceFilteredListTypes) => { const _recentApps = userPreferencesResponse?.resources?.[resourceKind]?.[UserPreferenceResourceActions.RECENTLY_VISITED] || [] - const isInvalidApp = appId && !appName + const isInvalidApp = id && !name - const validApps = _recentApps.filter((app) => { - if (!app?.appId || !app?.appName) { + const validEntities = _recentApps.filter((app) => { + if (!app?.id || !app?.name) { return false } if (isInvalidApp) { - return app.appId !== appId + return app.id !== id } return true @@ -44,7 +43,7 @@ export const getFilteredUniqueAppList = ({ // Convert to a Map for uniqueness while maintaining stacking order const uniqueApps = ( - appId && appName ? [{ appId, appName }, ...validApps.filter((app) => app.appId !== appId)] : validApps + id && name ? [{ id, name }, ...validEntities.filter((entity) => entity.id !== id)] : validEntities ).slice(0, 6) // Limit to 6 items return uniqueApps From f6c28c1b5626bc836c98bf616cbd8716a95dabad Mon Sep 17 00:00:00 2001 From: shivani170 Date: Wed, 25 Jun 2025 11:26:20 +0530 Subject: [PATCH 09/90] chore: handling error & reload in context switcher --- src/Shared/Components/ContextSwitcher/ContextSwitcher.tsx | 4 ++++ src/Shared/Components/ContextSwitcher/types.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/src/Shared/Components/ContextSwitcher/ContextSwitcher.tsx b/src/Shared/Components/ContextSwitcher/ContextSwitcher.tsx index e61a91dcb..84be291c7 100644 --- a/src/Shared/Components/ContextSwitcher/ContextSwitcher.tsx +++ b/src/Shared/Components/ContextSwitcher/ContextSwitcher.tsx @@ -15,6 +15,8 @@ export const ContextSwitcher = ({ placeholder, filterOption, formatOptionLabel, + optionListError, + reloadOptionList, }: ContextSwitcherTypes) => { const selectedOptions = options?.map((section) => ({ ...section, @@ -36,6 +38,8 @@ export const ContextSwitcher = ({ size={ComponentSizeType.xl} filterOption={filterOption || customSelect} formatOptionLabel={formatOptionLabel} + optionListError={optionListError} + reloadOptionList={reloadOptionList} /> ) } diff --git a/src/Shared/Components/ContextSwitcher/types.ts b/src/Shared/Components/ContextSwitcher/types.ts index 10bf49f6f..aa3063e6f 100644 --- a/src/Shared/Components/ContextSwitcher/types.ts +++ b/src/Shared/Components/ContextSwitcher/types.ts @@ -14,6 +14,8 @@ export interface ContextSwitcherTypes | 'inputId' | 'formatOptionLabel' | 'filterOption' + | 'optionListError' + | 'reloadOptionList' > { options: GroupBase>[] isAppDataAvailable?: boolean From 73acec4af231d437b7380d3592b802edda0bf35e Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Wed, 25 Jun 2025 15:42:54 +0530 Subject: [PATCH 10/90] fix: css alignment for dividers --- .../TreeView/TreeView.component.tsx | 153 +++++++++++------- src/Shared/Components/TreeView/TreeView.scss | 75 ++------- src/Shared/Components/TreeView/index.ts | 1 + 3 files changed, 108 insertions(+), 121 deletions(-) diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index 9c87ea710..69127ca1c 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -4,19 +4,52 @@ import { Tooltip } from '@Common/Tooltip' import { Icon } from '../Icon' import { TrailingItem } from '../TrailingItem' -import { TreeViewProps } from './types' +import { TreeHeading, TreeViewProps } from './types' import './TreeView.scss' // Only selected element should have tab-index 0 and for tab navigation use keyboard events const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = 0 }: TreeViewProps) => { const { pathname } = useLocation() + const isFirstLevel = depth === 0 + + const getToggleNode = (node: TreeHeading) => () => { + onToggle(node) + } + return (
{nodes.map((node) => { + const content = ( + + {node.startIconConfig && ( + + + + )} + + {/* TODO: Tooltip */} + + + {node.title} + + {node.subtitle && ( + + {node.subtitle} + + )} + + + ) + if (node.type === 'heading') { const isExpanded = expandedMap[node.id] ?? false @@ -26,48 +59,53 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = role="treeitem" aria-selected={false} aria-expanded={isExpanded} - className={`flexbox-col w-100 tree-view__heading-group-wrapper ${isExpanded ? 'tree-view__heading-group-wrapper--expanded' : ''}`} + className="flexbox-col w-100" aria-level={depth + 1} >
+ {depth > 1 && + Array.from({ length: depth - 1 }).map((_, index) => ( + + + + ))} + {node.trailingItem && } @@ -75,7 +113,7 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth =
{isExpanded && ( -
+
{!node.items?.length ? ( {node.noItemsText || 'No items found.'} ) : ( @@ -100,39 +138,40 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = } const isSelected = selectedId === node.id + const baseClass = + 'dc__transparent p-0-imp flexbox dc__align-start flex-grow-1 bg__hover--opaque br-4 tree-view__container--item' - const content = ( - - {node.startIconConfig && ( - - - - )} - - {/* TODO: Tooltip */} - - {/* TODO: Strike through */} - {node.title} - {node.subtitle && {node.subtitle}} + const itemDivider = + depth > 0 ? ( + + - - ) + ) : null return (
+ {/* TODO: Duplicate element */} + {depth > 1 && + Array.from({ length: depth - 1 }).map((_, index) => ( + + + + ))} + {node.as === 'link' ? ( { // Prevent navigation to the same page if (node.href === pathname) { @@ -142,18 +181,20 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = onSelect(node) }} > + {itemDivider} {content} ) : ( )} diff --git a/src/Shared/Components/TreeView/TreeView.scss b/src/Shared/Components/TreeView/TreeView.scss index e8cb71be5..70ee266c4 100644 --- a/src/Shared/Components/TreeView/TreeView.scss +++ b/src/Shared/Components/TreeView/TreeView.scss @@ -1,72 +1,17 @@ .tree-view { &__container { - &[aria-selected="true"] &--title { - color: var(--B500); - font-weight: 600; - background: var(--B100); - } - } - - &__heading-group-wrapper { - position: relative; - - &--expanded::before { - content: ""; - position: absolute; - left: 17px; - top: 24px; - bottom: 0; - width: 1px; - background-color: var(--N200); - z-index: 0; - } - } - - &__group { - margin-left: 24px; - border-left: 1px solid transparent; - padding-top: 8px; - padding-bottom: 8px; - position: relative; - } - - &__item { - position: relative; - padding-left: 16px; - margin: 4px 0; - - &::before { - content: ""; - position: absolute; - left: -8px; - top: 0; - bottom: 0; - width: 1px; - background-color: var(--N200); - z-index: 1; - } - - &::after { - content: ""; - position: absolute; - left: -8px; - top: 0; - width: 1px; - height: 100%; - z-index: 2; - opacity: 0; - background-image: linear-gradient(to bottom, - var(--bg-primary) 0px, - var(--bg-primary) 4px, - var(--B500) 4px, - var(--B500) calc(100% - 4px), - var(--bg-primary) calc(100% - 4px), - var(--bg-primary) 100%); - transition: opacity 0.2s ease; + .icon-with-divider { + grid-template-rows: 24px auto; } - &:hover::after { - opacity: 1; + &--item { + &:hover { + .tree-view__divider { + background-color: var(--B500); + height: 16px; + border-radius: 3px; + } + } } } } \ No newline at end of file diff --git a/src/Shared/Components/TreeView/index.ts b/src/Shared/Components/TreeView/index.ts index f64ae9a11..89897deba 100644 --- a/src/Shared/Components/TreeView/index.ts +++ b/src/Shared/Components/TreeView/index.ts @@ -1 +1,2 @@ export { default as TreeView } from './TreeView.component' +export * from './types' From 739b759273ca8751f94c8e6df1f652b4a6472747 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Wed, 25 Jun 2025 16:42:28 +0530 Subject: [PATCH 11/90] feat: make variant prop optional in TrailingItemProps and improve TreeView component structure with Divider --- src/Shared/Components/TrailingItem/types.ts | 2 +- .../TreeView/TreeView.component.tsx | 176 +++++++++--------- 2 files changed, 91 insertions(+), 87 deletions(-) diff --git a/src/Shared/Components/TrailingItem/types.ts b/src/Shared/Components/TrailingItem/types.ts index f462af40e..bae6a904b 100644 --- a/src/Shared/Components/TrailingItem/types.ts +++ b/src/Shared/Components/TrailingItem/types.ts @@ -53,5 +53,5 @@ export type TrailingItemProps = TrailingItemType & { /** * @default 'neutral' */ - variant: 'neutral' | 'negative' + variant?: 'neutral' | 'negative' } diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index 69127ca1c..c883bdf42 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -8,6 +8,12 @@ import { TreeHeading, TreeViewProps } from './types' import './TreeView.scss' +const Divider = () => ( + + + +) + // Only selected element should have tab-index 0 and for tab navigation use keyboard events const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = 0 }: TreeViewProps) => { const { pathname } = useLocation() @@ -50,6 +56,11 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = ) + const dividerPrefix = + depth > 1 && + // eslint-disable-next-line react/no-array-index-key + Array.from({ length: depth - 1 }).map((_, index) => ) + if (node.type === 'heading') { const isExpanded = expandedMap[node.id] ?? false @@ -63,59 +74,56 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = aria-level={depth + 1} >
-
- {depth > 1 && - Array.from({ length: depth - 1 }).map((_, index) => ( - - - - ))} - - + + + + + + {isExpanded && ( + + + + )} + + + {content} + - {node.trailingItem && } + {node.trailingItem && } +
{isExpanded && (
{!node.items?.length ? ( - {node.noItemsText || 'No items found.'} + <> + {dividerPrefix} + + {node.noItemsText || 'No items found.'} + ) : (
{node.items.map((nodeItem) => ( @@ -139,11 +147,11 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = const isSelected = selectedId === node.id const baseClass = - 'dc__transparent p-0-imp flexbox dc__align-start flex-grow-1 bg__hover--opaque br-4 tree-view__container--item' + 'dc__transparent p-0-imp flexbox dc__align-start flex-grow-1 tree-view__container--item' const itemDivider = depth > 0 ? ( - + ) : null @@ -156,50 +164,46 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = className="flexbox flex-grow-1 w-100" aria-level={depth + 1} > - {/* TODO: Duplicate element */} - {depth > 1 && - Array.from({ length: depth - 1 }).map((_, index) => ( - - - - ))} - - {node.as === 'link' ? ( - { - // Prevent navigation to the same page - if (node.href === pathname) { - e.preventDefault() + {dividerPrefix} + +
+ {node.as === 'link' ? ( + - {itemDivider} - {content} - - ) : ( - - )} + className={baseClass} + onClick={(e) => { + // Prevent navigation to the same page + if (node.href === pathname) { + e.preventDefault() + } + node.onClick?.(e) + onSelect(node) + }} + > + {itemDivider} + {content} + + ) : ( + + )} - {node.trailingItem && } + {node.trailingItem && } +
) })} From 6d0a5061841252d2322791e16b2f1aa1d5603bce Mon Sep 17 00:00:00 2001 From: shivani170 Date: Wed, 25 Jun 2025 16:53:41 +0530 Subject: [PATCH 12/90] chore: getParsedResourcesMap code refactoring --- src/Shared/Hooks/useUserPreferences/constants.ts | 11 +++++++++++ src/Shared/Hooks/useUserPreferences/service.ts | 11 ++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/Shared/Hooks/useUserPreferences/constants.ts b/src/Shared/Hooks/useUserPreferences/constants.ts index 5c55615cc..e868f5002 100644 --- a/src/Shared/Hooks/useUserPreferences/constants.ts +++ b/src/Shared/Hooks/useUserPreferences/constants.ts @@ -14,4 +14,15 @@ * limitations under the License. */ +import { ResourceKindType } from '@Shared/types' + +import { PreferredResourceKindType } from './types' + export const USER_PREFERENCES_ATTRIBUTE_KEY = 'userPreferences' + +export const PreferredResourceKinds: PreferredResourceKindType[] = [ + ResourceKindType.devtronApplication, + ResourceKindType.job, + 'app-group', + ResourceKindType.cluster, +] diff --git a/src/Shared/Hooks/useUserPreferences/service.ts b/src/Shared/Hooks/useUserPreferences/service.ts index 13d5b45ae..2aad9f776 100644 --- a/src/Shared/Hooks/useUserPreferences/service.ts +++ b/src/Shared/Hooks/useUserPreferences/service.ts @@ -18,7 +18,7 @@ import { ROUTES } from '@Common/Constants' import { get, getUrlWithSearchParams, patch, showError } from '@Common/index' import { THEME_PREFERENCE_MAP } from '@Shared/Providers/ThemeProvider/types' -import { USER_PREFERENCES_ATTRIBUTE_KEY } from './constants' +import { PreferredResourceKinds, USER_PREFERENCES_ATTRIBUTE_KEY } from './constants' import { BaseRecentlyVisitedEntitiesTypes, GetUserPreferencesParsedDTO, @@ -33,12 +33,13 @@ import { } from './types' import { getUserPreferenceResourcesMetadata } from './utils' -export const getParsedResourcesMap = (resources: GetUserPreferencesParsedDTO['resources']) => { - const resourcesMap = resources || {} +export const getParsedResourcesMap = ( + resources: GetUserPreferencesParsedDTO['resources'], +): UserPreferencesType['resources'] => { const parsedResourcesMap: UserPreferencesType['resources'] = {} - Object.entries(resourcesMap).forEach(([resourceKind, resourceActions]) => { - parsedResourcesMap[resourceKind] = resourceActions + PreferredResourceKinds.forEach((resourceKind) => { + parsedResourcesMap[resourceKind] = resources?.[resourceKind] }) return parsedResourcesMap From ad82427cad58425aadc54a4e22263dc48e217697 Mon Sep 17 00:00:00 2001 From: shivani170 Date: Wed, 25 Jun 2025 17:02:51 +0530 Subject: [PATCH 13/90] chore: classNamePrefix support added --- src/Shared/Components/ContextSwitcher/ContextSwitcher.tsx | 6 ++++-- src/Shared/Components/ContextSwitcher/types.ts | 1 + src/Shared/Components/ContextSwitcher/utils.ts | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Shared/Components/ContextSwitcher/ContextSwitcher.tsx b/src/Shared/Components/ContextSwitcher/ContextSwitcher.tsx index 84be291c7..b6f89a159 100644 --- a/src/Shared/Components/ContextSwitcher/ContextSwitcher.tsx +++ b/src/Shared/Components/ContextSwitcher/ContextSwitcher.tsx @@ -2,7 +2,7 @@ import { getNoMatchingResultText, SelectPicker, SelectPickerVariantType } from ' import { ComponentSizeType } from '@Shared/constants' import { ContextSwitcherTypes } from './types' -import { customSelect, getDisabledOptions } from './utils' +import { customSelectFilterOption, getDisabledOptions } from './utils' export const ContextSwitcher = ({ inputId, @@ -17,6 +17,7 @@ export const ContextSwitcher = ({ formatOptionLabel, optionListError, reloadOptionList, + classNamePrefix, }: ContextSwitcherTypes) => { const selectedOptions = options?.map((section) => ({ ...section, @@ -36,10 +37,11 @@ export const ContextSwitcher = ({ placeholder={placeholder} isOptionDisabled={getDisabledOptions} size={ComponentSizeType.xl} - filterOption={filterOption || customSelect} + filterOption={filterOption || customSelectFilterOption} formatOptionLabel={formatOptionLabel} optionListError={optionListError} reloadOptionList={reloadOptionList} + classNamePrefix={classNamePrefix} /> ) } diff --git a/src/Shared/Components/ContextSwitcher/types.ts b/src/Shared/Components/ContextSwitcher/types.ts index aa3063e6f..8ba63b827 100644 --- a/src/Shared/Components/ContextSwitcher/types.ts +++ b/src/Shared/Components/ContextSwitcher/types.ts @@ -16,6 +16,7 @@ export interface ContextSwitcherTypes | 'filterOption' | 'optionListError' | 'reloadOptionList' + | 'classNamePrefix' > { options: GroupBase>[] isAppDataAvailable?: boolean diff --git a/src/Shared/Components/ContextSwitcher/utils.ts b/src/Shared/Components/ContextSwitcher/utils.ts index fa23bbda7..ca04e7c25 100644 --- a/src/Shared/Components/ContextSwitcher/utils.ts +++ b/src/Shared/Components/ContextSwitcher/utils.ts @@ -3,7 +3,7 @@ import { RecentlyVisitedGroupedOptionsType, RecentlyVisitedOptions } from './typ export const getDisabledOptions = (option: RecentlyVisitedOptions): SelectPickerProps['isDisabled'] => option.isDisabled -export const customSelect: SelectPickerProps['filterOption'] = (option, searchText: string) => { +export const customSelectFilterOption: SelectPickerProps['filterOption'] = (option, searchText: string) => { const label = option.data.label as string return option.data.value === 0 || label.toLowerCase().includes(searchText.toLowerCase()) } From 8fc790b2f1fd7e46b8a517ac7a2728f5c595e232 Mon Sep 17 00:00:00 2001 From: shivani170 Date: Thu, 26 Jun 2025 12:52:26 +0530 Subject: [PATCH 14/90] chore: removed resource kind spreading from update resource --- src/Shared/Hooks/useUserPreferences/service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Shared/Hooks/useUserPreferences/service.ts b/src/Shared/Hooks/useUserPreferences/service.ts index 2aad9f776..d796aa4b8 100644 --- a/src/Shared/Hooks/useUserPreferences/service.ts +++ b/src/Shared/Hooks/useUserPreferences/service.ts @@ -109,7 +109,6 @@ const getUserPreferencePayload = async ({ const existingResources = userPreferencesResponse?.resources || {} const updatedResources = { - ...existingResources, [resourceKind]: { ...existingResources[resourceKind], [UserPreferenceResourceActions.RECENTLY_VISITED]: From 439a4c6895583dc59e221c59dcce559f3190cce3 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Thu, 26 Jun 2025 15:34:04 +0530 Subject: [PATCH 15/90] feat: add mode prop to TreeViewProps for navigation and form modes, and update TreeView component for keyboard navigation support --- .../Components/TreeView/TreeView.component.tsx | 12 +++++++++--- src/Shared/Components/TreeView/types.ts | 6 ++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index c883bdf42..4de6d87bb 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -15,10 +15,12 @@ const Divider = () => ( ) // Only selected element should have tab-index 0 and for tab navigation use keyboard events -const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = 0 }: TreeViewProps) => { +const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = 0, mode }: TreeViewProps) => { const { pathname } = useLocation() const isFirstLevel = depth === 0 + const fallbackTabIndex = mode === 'navigation' ? -1 : 0 + const getToggleNode = (node: TreeHeading) => () => { onToggle(node) } @@ -82,8 +84,9 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth =
@@ -147,7 +151,7 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = const isSelected = selectedId === node.id const baseClass = - 'dc__transparent p-0-imp flexbox dc__align-start flex-grow-1 tree-view__container--item' + 'dc__transparent p-0-imp flexbox dc__align-start flex-grow-1 tree-view__container--item dc__select-text' const itemDivider = depth > 0 ? ( @@ -183,6 +187,7 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = node.onClick?.(e) onSelect(node) }} + tabIndex={isSelected ? 0 : fallbackTabIndex} > {itemDivider} {content} @@ -196,6 +201,7 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = node.onClick?.(e) onSelect(node) }} + tabIndex={isSelected ? 0 : fallbackTabIndex} > {itemDivider} {content} diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts index 87c28fd32..4f52262ae 100644 --- a/src/Shared/Components/TreeView/types.ts +++ b/src/Shared/Components/TreeView/types.ts @@ -75,4 +75,10 @@ export interface TreeViewProps { * WARNING: For internal use only. */ depth?: number + /** + * If navigation mode, the tree view will provide navigation through keyboard actions and make the only selected item focusable. + * If form mode, will leave the navigation to browser. + * @default 'navigation' + */ + mode: 'navigation' | 'form' } From 5319e27f865a283f51106ae05c05404f28075999 Mon Sep 17 00:00:00 2001 From: shivani170 Date: Thu, 26 Jun 2025 16:23:42 +0530 Subject: [PATCH 16/90] chore: payload fixes --- src/Shared/Hooks/useUserPreferences/service.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/Shared/Hooks/useUserPreferences/service.ts b/src/Shared/Hooks/useUserPreferences/service.ts index d796aa4b8..767ac8509 100644 --- a/src/Shared/Hooks/useUserPreferences/service.ts +++ b/src/Shared/Hooks/useUserPreferences/service.ts @@ -25,7 +25,6 @@ import { GetUserPreferencesQueryParamsType, UpdateUserPreferencesPayloadType, UserPathValueMapType, - UserPreferenceResourceActions, UserPreferenceResourceProps, UserPreferencesPayloadValueType, UserPreferencesType, @@ -42,7 +41,7 @@ export const getParsedResourcesMap = ( parsedResourcesMap[resourceKind] = resources?.[resourceKind] }) - return parsedResourcesMap + return parsedResourcesMap || {} } /** @@ -91,7 +90,6 @@ const getUserPreferencePayload = async ({ path, value, resourceKind, - userPreferencesResponse, }: UserPathValueMapType): Promise> => { switch (path) { case 'themePreference': @@ -106,15 +104,11 @@ const getUserPreferencePayload = async ({ } case 'resources': { - const existingResources = userPreferencesResponse?.resources || {} - const updatedResources = { [resourceKind]: { - ...existingResources[resourceKind], - [UserPreferenceResourceActions.RECENTLY_VISITED]: - getUserPreferenceResourcesMetadata(value as BaseRecentlyVisitedEntitiesTypes[], resourceKind)[ - resourceKind - ]?.[UserPreferenceResourceActions.RECENTLY_VISITED] || [], + ...getUserPreferenceResourcesMetadata(value as BaseRecentlyVisitedEntitiesTypes[], resourceKind)[ + resourceKind + ], }, } From d41abb6f489d534124d13bb38dd0ae3b5b170f49 Mon Sep 17 00:00:00 2001 From: shivani170 Date: Thu, 26 Jun 2025 17:44:26 +0530 Subject: [PATCH 17/90] chore: getParsedResourcesMap fixes --- src/Shared/Hooks/useUserPreferences/constants.ts | 12 ++++++------ src/Shared/Hooks/useUserPreferences/service.ts | 16 ++-------------- src/Shared/Hooks/useUserPreferences/utils.tsx | 15 +++++++++++++++ 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/src/Shared/Hooks/useUserPreferences/constants.ts b/src/Shared/Hooks/useUserPreferences/constants.ts index e868f5002..f77f48e07 100644 --- a/src/Shared/Hooks/useUserPreferences/constants.ts +++ b/src/Shared/Hooks/useUserPreferences/constants.ts @@ -20,9 +20,9 @@ import { PreferredResourceKindType } from './types' export const USER_PREFERENCES_ATTRIBUTE_KEY = 'userPreferences' -export const PreferredResourceKinds: PreferredResourceKindType[] = [ - ResourceKindType.devtronApplication, - ResourceKindType.job, - 'app-group', - ResourceKindType.cluster, -] +export const DEFAULT_RESOURCES_MAP: Record = { + [ResourceKindType.devtronApplication]: null, + [ResourceKindType.job]: null, + 'app-group': null, + [ResourceKindType.cluster]: null, +} diff --git a/src/Shared/Hooks/useUserPreferences/service.ts b/src/Shared/Hooks/useUserPreferences/service.ts index 767ac8509..84dccb813 100644 --- a/src/Shared/Hooks/useUserPreferences/service.ts +++ b/src/Shared/Hooks/useUserPreferences/service.ts @@ -18,7 +18,7 @@ import { ROUTES } from '@Common/Constants' import { get, getUrlWithSearchParams, patch, showError } from '@Common/index' import { THEME_PREFERENCE_MAP } from '@Shared/Providers/ThemeProvider/types' -import { PreferredResourceKinds, USER_PREFERENCES_ATTRIBUTE_KEY } from './constants' +import { USER_PREFERENCES_ATTRIBUTE_KEY } from './constants' import { BaseRecentlyVisitedEntitiesTypes, GetUserPreferencesParsedDTO, @@ -30,19 +30,7 @@ import { UserPreferencesType, ViewIsPipelineRBACConfiguredRadioTabs, } from './types' -import { getUserPreferenceResourcesMetadata } from './utils' - -export const getParsedResourcesMap = ( - resources: GetUserPreferencesParsedDTO['resources'], -): UserPreferencesType['resources'] => { - const parsedResourcesMap: UserPreferencesType['resources'] = {} - - PreferredResourceKinds.forEach((resourceKind) => { - parsedResourcesMap[resourceKind] = resources?.[resourceKind] - }) - - return parsedResourcesMap || {} -} +import { getParsedResourcesMap, getUserPreferenceResourcesMetadata } from './utils' /** * @returns UserPreferencesType diff --git a/src/Shared/Hooks/useUserPreferences/utils.tsx b/src/Shared/Hooks/useUserPreferences/utils.tsx index 834bf9279..e4f6a5aef 100644 --- a/src/Shared/Hooks/useUserPreferences/utils.tsx +++ b/src/Shared/Hooks/useUserPreferences/utils.tsx @@ -1,9 +1,12 @@ +import { DEFAULT_RESOURCES_MAP } from './constants' import { BaseRecentlyVisitedEntitiesTypes, + GetUserPreferencesParsedDTO, PreferredResourceKindType, UserPreferenceFilteredListTypes, UserPreferenceResourceActions, UserPreferenceResourceType, + UserPreferencesType, } from './types' export const getUserPreferenceResourcesMetadata = ( @@ -48,3 +51,15 @@ export const getFilteredUniqueAppList = ({ return uniqueApps } + +export const getParsedResourcesMap = ( + resources: GetUserPreferencesParsedDTO['resources'], +): UserPreferencesType['resources'] => { + const parsedResourcesMap: UserPreferencesType['resources'] = {} + + Object.keys(DEFAULT_RESOURCES_MAP).forEach((resourceKind) => { + parsedResourcesMap[resourceKind] = resources?.[resourceKind] + }) + + return parsedResourcesMap || {} +} From 7d0451fa44bdab279d1e457fb2991555ca3bee4a Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Fri, 27 Jun 2025 12:16:08 +0530 Subject: [PATCH 18/90] feat(icons): add new SVG icons for activity, folder, and namespace --- src/Assets/IconV2/ic-activity.svg | 3 +++ src/Assets/IconV2/ic-folder.svg | 3 +++ src/Assets/IconV2/ic-namespace.svg | 3 +++ src/Shared/Components/Icon/Icon.tsx | 6 ++++++ 4 files changed, 15 insertions(+) create mode 100644 src/Assets/IconV2/ic-activity.svg create mode 100644 src/Assets/IconV2/ic-folder.svg create mode 100644 src/Assets/IconV2/ic-namespace.svg diff --git a/src/Assets/IconV2/ic-activity.svg b/src/Assets/IconV2/ic-activity.svg new file mode 100644 index 000000000..de027535e --- /dev/null +++ b/src/Assets/IconV2/ic-activity.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/Assets/IconV2/ic-folder.svg b/src/Assets/IconV2/ic-folder.svg new file mode 100644 index 000000000..586cba681 --- /dev/null +++ b/src/Assets/IconV2/ic-folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/Assets/IconV2/ic-namespace.svg b/src/Assets/IconV2/ic-namespace.svg new file mode 100644 index 000000000..cbaf35fcc --- /dev/null +++ b/src/Assets/IconV2/ic-namespace.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/Shared/Components/Icon/Icon.tsx b/src/Shared/Components/Icon/Icon.tsx index eaa2a5589..244aa0cea 100644 --- a/src/Shared/Components/Icon/Icon.tsx +++ b/src/Shared/Components/Icon/Icon.tsx @@ -2,6 +2,7 @@ import { ReactComponent as IC73strings } from '@IconsV2/ic-73strings.svg' import { ReactComponent as ICAborted } from '@IconsV2/ic-aborted.svg' +import { ReactComponent as ICActivity } from '@IconsV2/ic-activity.svg' import { ReactComponent as ICAdd } from '@IconsV2/ic-add.svg' import { ReactComponent as ICAmazonEks } from '@IconsV2/ic-amazon-eks.svg' import { ReactComponent as ICApica } from '@IconsV2/ic-apica.svg' @@ -83,6 +84,7 @@ import { ReactComponent as ICFiles } from '@IconsV2/ic-files.svg' import { ReactComponent as ICFilter } from '@IconsV2/ic-filter.svg' import { ReactComponent as ICFilterApplied } from '@IconsV2/ic-filter-applied.svg' import { ReactComponent as ICFlask } from '@IconsV2/ic-flask.svg' +import { ReactComponent as ICFolder } from '@IconsV2/ic-folder.svg' import { ReactComponent as ICFolderColor } from '@IconsV2/ic-folder-color.svg' import { ReactComponent as ICFolderUser } from '@IconsV2/ic-folder-user.svg' import { ReactComponent as ICGear } from '@IconsV2/ic-gear.svg' @@ -137,6 +139,7 @@ import { ReactComponent as ICMissing } from '@IconsV2/ic-missing.svg' import { ReactComponent as ICMobile } from '@IconsV2/ic-mobile.svg' import { ReactComponent as ICMonitoring } from '@IconsV2/ic-monitoring.svg' import { ReactComponent as ICMoreVertical } from '@IconsV2/ic-more-vertical.svg' +import { ReactComponent as ICNamespace } from '@IconsV2/ic-namespace.svg' import { ReactComponent as ICNew } from '@IconsV2/ic-new.svg' import { ReactComponent as ICNodeScript } from '@IconsV2/ic-node-script.svg' import { ReactComponent as ICOidc } from '@IconsV2/ic-oidc.svg' @@ -207,6 +210,7 @@ import { IconBaseProps } from './types' export const iconMap = { 'ic-73strings': IC73strings, 'ic-aborted': ICAborted, + 'ic-activity': ICActivity, 'ic-add': ICAdd, 'ic-amazon-eks': ICAmazonEks, 'ic-apica': ICApica, @@ -290,6 +294,7 @@ export const iconMap = { 'ic-flask': ICFlask, 'ic-folder-color': ICFolderColor, 'ic-folder-user': ICFolderUser, + 'ic-folder': ICFolder, 'ic-gear': ICGear, 'ic-gift-gradient': ICGiftGradient, 'ic-gift': ICGift, @@ -342,6 +347,7 @@ export const iconMap = { 'ic-mobile': ICMobile, 'ic-monitoring': ICMonitoring, 'ic-more-vertical': ICMoreVertical, + 'ic-namespace': ICNamespace, 'ic-new': ICNew, 'ic-node-script': ICNodeScript, 'ic-oidc': ICOidc, From 3650e059f3c7c9486830c57c1402dd6b0ff561b9 Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Fri, 27 Jun 2025 12:25:05 +0530 Subject: [PATCH 19/90] feat: GroupedFilterSelectPicker - create component --- .../SelectPicker/FilterSelectPicker.tsx | 20 ++++- .../GroupedFilterSelectPicker.tsx | 86 +++++++++++++++++++ src/Shared/Components/SelectPicker/index.ts | 1 + .../Components/SelectPicker/selectPicker.scss | 9 +- src/Shared/Components/SelectPicker/type.ts | 17 +++- src/Shared/Components/Widgets/Widgets.tsx | 5 ++ src/Shared/Components/Widgets/index.ts | 1 + src/Shared/Components/Widgets/types.ts | 3 + 8 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 src/Shared/Components/SelectPicker/GroupedFilterSelectPicker.tsx create mode 100644 src/Shared/Components/Widgets/Widgets.tsx create mode 100644 src/Shared/Components/Widgets/index.ts create mode 100644 src/Shared/Components/Widgets/types.ts diff --git a/src/Shared/Components/SelectPicker/FilterSelectPicker.tsx b/src/Shared/Components/SelectPicker/FilterSelectPicker.tsx index 7210ef5d0..58a494f0a 100644 --- a/src/Shared/Components/SelectPicker/FilterSelectPicker.tsx +++ b/src/Shared/Components/SelectPicker/FilterSelectPicker.tsx @@ -32,11 +32,14 @@ const FilterSelectPicker = ({ appliedFilterOptions, handleApplyFilter, options, + menuIsOpen = false, + onMenuClose, + focusOnMount = false, ...props }: FilterSelectPickerProps) => { const selectRef = useRef['selectRef']['current']>() - const [isMenuOpen, setIsMenuOpen] = useState(false) + const [isMenuOpen, setIsMenuOpen] = useState(menuIsOpen) const { triggerAutoClickTimestamp, setTriggerAutoClickTimestampToNow, resetTriggerAutoClickTimestamp } = useTriggerAutoClickTimestamp() @@ -88,6 +91,11 @@ const FilterSelectPicker = ({ } } + const handleMenuClose = () => { + onMenuClose?.() + ;(handleApplyClick as () => void)() + } + useEffect(() => { if (isMenuOpen) { registerShortcut({ keys: APPLY_FILTER_SHORTCUT_KEYS, callback: handleApplyClick as () => void }) @@ -98,6 +106,14 @@ const FilterSelectPicker = ({ } }, [handleApplyClick, isMenuOpen]) + useEffect(() => { + setTimeout(() => { + if (menuIsOpen && focusOnMount && selectRef.current) { + selectRef.current.focus() + } + }, 100) + }, []) + return (
@@ -108,7 +124,7 @@ const FilterSelectPicker = ({ isMulti menuIsOpen={isMenuOpen} onMenuOpen={openMenu} - onMenuClose={handleApplyClick as () => void} + onMenuClose={handleMenuClose} onChange={handleSelectOnChange} menuListFooterConfig={{ type: 'button', diff --git a/src/Shared/Components/SelectPicker/GroupedFilterSelectPicker.tsx b/src/Shared/Components/SelectPicker/GroupedFilterSelectPicker.tsx new file mode 100644 index 000000000..3d5990362 --- /dev/null +++ b/src/Shared/Components/SelectPicker/GroupedFilterSelectPicker.tsx @@ -0,0 +1,86 @@ +import { KeyboardEvent, useEffect, useRef, useState } from 'react' + +import { useRegisterShortcut } from '@Common/Hooks' + +import { ActionMenu, ActionMenuItemType, ActionMenuProps } from '../ActionMenu' +import { Icon } from '../Icon' +import { ShortcutKeyBadge } from '../Widgets' +import FilterSelectPicker from './FilterSelectPicker' +import { GroupedFilterSelectPickerProps } from './type' + +import './selectPicker.scss' + +export const GroupedFilterSelectPicker = ({ + id, + filterSelectPickerPropsMap, + isFilterApplied, + ...restProps +}: GroupedFilterSelectPickerProps) => { + // STATES + const [selectedActionMenuItem, setSelectedActionMenuItem] = useState['id']>(null) + + // REFS + const shouldFocusActionMenuRef = useRef(false) + const triggerButtonRef = useRef() + + // HOOKS + const { registerShortcut, unregisterShortcut } = useRegisterShortcut() + + useEffect(() => { + const shortcutCallback = () => { + triggerButtonRef.current?.click() + } + + registerShortcut({ keys: ['F'], callback: shortcutCallback }) + + return () => { + unregisterShortcut(['F']) + } + }, []) + + useEffect(() => { + if (!selectedActionMenuItem && shouldFocusActionMenuRef.current) { + triggerButtonRef.current?.click() + shouldFocusActionMenuRef.current = false + } + }, [selectedActionMenuItem]) + + // HANDLERS + const handleMenuItemClick: ActionMenuProps['onClick'] = (item) => { + setSelectedActionMenuItem(item.id) + } + + const handleMenuClose = () => { + setSelectedActionMenuItem(null) + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Backspace' && !(e.target as HTMLInputElement).value) { + setSelectedActionMenuItem(null) + shouldFocusActionMenuRef.current = true + } + } + + return selectedActionMenuItem ? ( + + ) : ( + + + + ) +} diff --git a/src/Shared/Components/SelectPicker/index.ts b/src/Shared/Components/SelectPicker/index.ts index 35c4d8336..0d2932a96 100644 --- a/src/Shared/Components/SelectPicker/index.ts +++ b/src/Shared/Components/SelectPicker/index.ts @@ -16,6 +16,7 @@ export { ValueContainerWithLoadingShimmer } from './common' export { default as FilterSelectPicker } from './FilterSelectPicker' +export * from './GroupedFilterSelectPicker' export { default as SelectPicker } from './SelectPicker.component' export * from './SelectPickerTextArea.component' export * from './type' diff --git a/src/Shared/Components/SelectPicker/selectPicker.scss b/src/Shared/Components/SelectPicker/selectPicker.scss index 352ec6ab5..d626d2db8 100644 --- a/src/Shared/Components/SelectPicker/selectPicker.scss +++ b/src/Shared/Components/SelectPicker/selectPicker.scss @@ -1,10 +1,17 @@ .select-picker { &__menu-list-footer-button { &--border-less { - button, a { + button, + a { justify-content: flex-start; border-radius: 0; } } } } + +.grouped-filter-select-picker { + &__button:hover { + border-color: var(--N300); + } +} diff --git a/src/Shared/Components/SelectPicker/type.ts b/src/Shared/Components/SelectPicker/type.ts index b572b412a..4333919d4 100644 --- a/src/Shared/Components/SelectPicker/type.ts +++ b/src/Shared/Components/SelectPicker/type.ts @@ -26,6 +26,7 @@ import { TooltipProps } from '@Common/Tooltip' import { OptionType } from '@Common/Types' import { ComponentSizeType } from '@Shared/constants' +import { ActionMenuProps } from '../ActionMenu' import { ButtonComponentType, ButtonProps, ButtonVariantType } from '../Button' import { FormFieldWrapperProps } from '../FormFieldWrapper/types' @@ -333,7 +334,9 @@ export interface FilterSelectPickerProps | 'reloadOptionList' | 'getOptionValue' | 'isOptionDisabled' - > { + >, + Partial, 'onMenuClose' | 'menuIsOpen' | 'onKeyDown'>> { + focusOnMount?: boolean appliedFilterOptions: SelectPickerOptionType[] handleApplyFilter: (filtersToApply: SelectPickerOptionType[]) => void } @@ -349,3 +352,15 @@ export type SelectPickerTextAreaProps = Omit< | 'shouldRenderTextArea' > & Pick + +export interface GroupedFilterSelectPickerProps + extends Omit< + ActionMenuProps, + 'onClick' | 'disableDescriptionEllipsis' | 'children' | 'buttonProps' | 'isSearchable' + > { + isFilterApplied?: boolean + filterSelectPickerPropsMap: Record< + T, + Omit + > +} diff --git a/src/Shared/Components/Widgets/Widgets.tsx b/src/Shared/Components/Widgets/Widgets.tsx new file mode 100644 index 000000000..ecd930090 --- /dev/null +++ b/src/Shared/Components/Widgets/Widgets.tsx @@ -0,0 +1,5 @@ +import { ShortcutKeyBadgeProps } from './types' + +export const ShortcutKeyBadge = ({ shortcutKey }: ShortcutKeyBadgeProps) => ( +
{shortcutKey}
+) diff --git a/src/Shared/Components/Widgets/index.ts b/src/Shared/Components/Widgets/index.ts new file mode 100644 index 000000000..8e03f1309 --- /dev/null +++ b/src/Shared/Components/Widgets/index.ts @@ -0,0 +1 @@ +export * from './Widgets' diff --git a/src/Shared/Components/Widgets/types.ts b/src/Shared/Components/Widgets/types.ts new file mode 100644 index 000000000..7f05eaf2e --- /dev/null +++ b/src/Shared/Components/Widgets/types.ts @@ -0,0 +1,3 @@ +export interface ShortcutKeyBadgeProps { + shortcutKey: string +} From 5eba693dae3c74ad6ea3d4ba2a4bc9694f3259e7 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Fri, 27 Jun 2025 12:27:22 +0530 Subject: [PATCH 20/90] feat: update TreeViewProps to support optional depth, flatNodeList, and getUpdateItemsRefMap, and deprecate CollapsibleList component --- .../CollapsibleList.component.tsx | 3 + .../TreeView/TreeView.component.tsx | 108 +++++++++++++++++- src/Shared/Components/TreeView/types.ts | 31 ++++- 3 files changed, 134 insertions(+), 8 deletions(-) diff --git a/src/Shared/Components/CollapsibleList/CollapsibleList.component.tsx b/src/Shared/Components/CollapsibleList/CollapsibleList.component.tsx index e4cde93cf..6a0ce3c87 100644 --- a/src/Shared/Components/CollapsibleList/CollapsibleList.component.tsx +++ b/src/Shared/Components/CollapsibleList/CollapsibleList.component.tsx @@ -33,6 +33,9 @@ const renderWithTippy = (tippyProps: TippyProps) => (children: React.ReactElemen ) +/** + * @deprecated - Please use `TreeView` component instead. + */ export const CollapsibleList = ({ config, tabType, diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index 4de6d87bb..d765890ba 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -1,3 +1,4 @@ +import { useMemo, useRef } from 'react' import { NavLink, useLocation } from 'react-router-dom' import { Tooltip } from '@Common/Tooltip' @@ -15,8 +16,21 @@ const Divider = () => ( ) // Only selected element should have tab-index 0 and for tab navigation use keyboard events -const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = 0, mode }: TreeViewProps) => { +const TreeView = ({ + nodes, + expandedMap, + selectedId, + onToggle, + onSelect, + depth = 0, + mode, + flatNodeList: flatNodeListProp, + getUpdateItemsRefMap: getUpdateItemsRefMapProp, +}: TreeViewProps) => { const { pathname } = useLocation() + // Using this at root level + const rootItemRefs = useRef>({}) + const isFirstLevel = depth === 0 const fallbackTabIndex = mode === 'navigation' ? -1 : 0 @@ -25,10 +39,78 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = onToggle(node) } + const getUpdateItemsRefMap = (id: string) => (el: HTMLButtonElement | HTMLAnchorElement) => { + if (!isFirstLevel) { + throw new Error('getUpdateItemsRefMap should only be used at the first level of the tree view.') + } + rootItemRefs.current[id] = el + } + + // will traverse all the nodes that are expanded and visible in the tree view + // and return a flat list of node ids for keyboard navigation + const traverseNodes = (nodeList: typeof nodes): string[] => + nodeList.reduce((acc: string[], node) => { + acc.push(node.id) + if (node.type === 'heading' && expandedMap[node.id] && node.items?.length) { + // If the node is a heading and expanded, traverse its items + acc.push(...traverseNodes(node.items)) + } + return acc + }, []) + + const flatNodeList = useMemo(() => { + if (flatNodeListProp) { + return flatNodeListProp + } + + if (flatNodeListProp) { + // If flatNodeList is provided, return it directly + return flatNodeListProp + } + + return traverseNodes(nodes) + }, [nodes, expandedMap]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (mode !== 'navigation' || !isFirstLevel) { + return + } + + const { key } = e + + if (!['ArrowUp', 'ArrowDown'].includes(key)) { + return + } + + e.preventDefault() + + const target = e.target as HTMLButtonElement | HTMLAnchorElement + const nodeId = target.getAttribute('data-node-id') + if (!nodeId) { + return + } + + // Find the index of the current node in the flatNodeList + const currentIndex = flatNodeList.indexOf(nodeId) + if (currentIndex === -1) { + return + } + + if (key === 'ArrowDown' && currentIndex < flatNodeList.length - 1) { + rootItemRefs.current[flatNodeList[currentIndex + 1]]?.focus() + return + } + + if (key === 'ArrowUp' && currentIndex > 0) { + rootItemRefs.current[flatNodeList[currentIndex - 1]]?.focus() + } + } + return (
{nodes.map((node) => { const content = ( @@ -87,6 +169,12 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = className="tree-view__container--item dc__transparent p-0-imp flexbox dc__align-start flex-grow-1 dc__select-text" onClick={getToggleNode(node)} tabIndex={fallbackTabIndex} + data-node-id={node.id} + ref={ + getUpdateItemsRefMapProp + ? getUpdateItemsRefMapProp(node.id) + : getUpdateItemsRefMap(node.id) + } > {depth > 0 && ( @@ -139,6 +227,10 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = nodes={[nodeItem]} depth={depth + 1} mode={mode} + getUpdateItemsRefMap={ + getUpdateItemsRefMapProp || getUpdateItemsRefMap + } + flatNodeList={flatNodeList} /> ))}
@@ -188,6 +280,12 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = onSelect(node) }} tabIndex={isSelected ? 0 : fallbackTabIndex} + data-node-id={node.id} + ref={ + getUpdateItemsRefMapProp + ? getUpdateItemsRefMapProp(node.id) + : getUpdateItemsRefMap(node.id) + } > {itemDivider} {content} @@ -202,6 +300,12 @@ const TreeView = ({ nodes, expandedMap, selectedId, onToggle, onSelect, depth = onSelect(node) }} tabIndex={isSelected ? 0 : fallbackTabIndex} + data-node-id={node.id} + ref={ + getUpdateItemsRefMapProp + ? getUpdateItemsRefMapProp(node.id) + : getUpdateItemsRefMap(node.id) + } > {itemDivider} {content} diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts index 4f52262ae..309ac0794 100644 --- a/src/Shared/Components/TreeView/types.ts +++ b/src/Shared/Components/TreeView/types.ts @@ -65,20 +65,39 @@ export type TreeItem = BaseNode & { } ) -export interface TreeViewProps { +export type TreeViewProps = { nodes: TreeNode[] expandedMap: Record selectedId?: string onToggle: (item: TreeHeading) => void onSelect: (item: TreeItem) => void - /** - * WARNING: For internal use only. - */ - depth?: number /** * If navigation mode, the tree view will provide navigation through keyboard actions and make the only selected item focusable. * If form mode, will leave the navigation to browser. * @default 'navigation' */ mode: 'navigation' | 'form' -} +} & ( + | { + /** + * WARNING: For internal use only. + */ + depth: number + /** + * WARNING: For internal use only. + * Would pass this to item button/ref and store it in out ref map through this function. + */ + getUpdateItemsRefMap: (id: string) => (element: HTMLButtonElement | HTMLAnchorElement) => void + + /** + * WARNING: For internal use only. + * List of all nodes visible in tree view for keyboard navigation. + */ + flatNodeList: string[] + } + | { + depth?: never + getUpdateItemsRefMap?: never + flatNodeList?: never + } +) From 15b51d57f50d54389d164511424f2c64712e34f2 Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Fri, 27 Jun 2025 13:06:27 +0530 Subject: [PATCH 21/90] feat(SelectPicker): add autoFocus prop and remove focusOnMount; update GroupedFilterSelectPicker --- .../SelectPicker/FilterSelectPicker.tsx | 10 +--------- .../SelectPicker/GroupedFilterSelectPicker.tsx | 16 +++++++++------- .../SelectPicker/SelectPicker.component.tsx | 12 +++++++++++- src/Shared/Components/SelectPicker/type.ts | 9 +++++---- 4 files changed, 26 insertions(+), 21 deletions(-) diff --git a/src/Shared/Components/SelectPicker/FilterSelectPicker.tsx b/src/Shared/Components/SelectPicker/FilterSelectPicker.tsx index 58a494f0a..1bfa388f3 100644 --- a/src/Shared/Components/SelectPicker/FilterSelectPicker.tsx +++ b/src/Shared/Components/SelectPicker/FilterSelectPicker.tsx @@ -34,7 +34,6 @@ const FilterSelectPicker = ({ options, menuIsOpen = false, onMenuClose, - focusOnMount = false, ...props }: FilterSelectPickerProps) => { const selectRef = useRef['selectRef']['current']>() @@ -67,6 +66,7 @@ const FilterSelectPicker = ({ const closeMenu = () => { resetTriggerAutoClickTimestamp() setIsMenuOpen(false) + onMenuClose?.() } const handleSelectOnChange: SelectPickerProps['onChange'] = (selectedOptionsToUpdate) => { @@ -106,14 +106,6 @@ const FilterSelectPicker = ({ } }, [handleApplyClick, isMenuOpen]) - useEffect(() => { - setTimeout(() => { - if (menuIsOpen && focusOnMount && selectRef.current) { - selectRef.current.focus() - } - }, 100) - }, []) - return (
diff --git a/src/Shared/Components/SelectPicker/GroupedFilterSelectPicker.tsx b/src/Shared/Components/SelectPicker/GroupedFilterSelectPicker.tsx index 3d5990362..beead6509 100644 --- a/src/Shared/Components/SelectPicker/GroupedFilterSelectPicker.tsx +++ b/src/Shared/Components/SelectPicker/GroupedFilterSelectPicker.tsx @@ -62,13 +62,15 @@ export const GroupedFilterSelectPicker = ({ } return selectedActionMenuItem ? ( - +
+ +
) : ( - {node.trailingItem && } + {node.trailingItem && ( +
+ +
+ )}
@@ -278,7 +266,7 @@ const TreeView = ({ e.preventDefault() } node.onClick?.(e) - onSelect(node) + onSelect?.(node) }} tabIndex={isSelected ? 0 : fallbackTabIndex} data-node-id={node.id} @@ -298,7 +286,7 @@ const TreeView = ({ className={baseClass} onClick={(e) => { node.onClick?.(e) - onSelect(node) + onSelect?.(node) }} tabIndex={isSelected ? 0 : fallbackTabIndex} data-node-id={node.id} @@ -313,7 +301,11 @@ const TreeView = ({ )} - {node.trailingItem && } + {node.trailingItem && ( +
+ +
+ )}
) diff --git a/src/Shared/Components/TreeView/TreeView.scss b/src/Shared/Components/TreeView/TreeView.scss index 061aa0441..15f9e045d 100644 --- a/src/Shared/Components/TreeView/TreeView.scss +++ b/src/Shared/Components/TreeView/TreeView.scss @@ -25,5 +25,16 @@ color: var(--B500); } } + + &--title-wrapper:hover { + .title-with-tooltip { + text-decoration-line: underline; + text-decoration-style: dotted; + text-decoration-color: var(--N300); + text-decoration-thickness: 12%; + text-underline-offset: 20%; + text-decoration-skip-ink: auto; + } + } } } \ No newline at end of file diff --git a/src/Shared/Components/TreeView/TreeViewNodeContent.tsx b/src/Shared/Components/TreeView/TreeViewNodeContent.tsx new file mode 100644 index 000000000..48aa9a453 --- /dev/null +++ b/src/Shared/Components/TreeView/TreeViewNodeContent.tsx @@ -0,0 +1,94 @@ +import { ConditionalWrap } from '@Common/Helper' +import { Tooltip, TooltipProps, useIsTextTruncated } from '@Common/Tooltip' + +import { Icon } from '../Icon' +import { TreeViewNodeContentProps } from './types' + +const wrapWithTooltip = + (customTooltipConfig: TooltipProps, isTextTruncated: boolean, title: string, subTitle: string) => + (children: TooltipProps['children']) => { + if (customTooltipConfig) { + return {children} + } + + if (isTextTruncated) { + return ( + +
+ {title} +
+ {subTitle && ( +

{subTitle}

+ )} +
+ } + interactive + > + {children} + + ) + } + + return children + } + +const TreeViewNodeContent = ({ + startIconConfig, + title, + subtitle, + type, + customTooltipConfig, + strikeThrough, +}: TreeViewNodeContentProps) => { + const { isTextTruncated: isTitleTruncate, handleMouseEnterEvent: handleTitleMouseEnter } = useIsTextTruncated() + const { isTextTruncated: isSubtitleTruncate, handleMouseEnterEvent: handleSubtitleMouseEnter } = + useIsTextTruncated() + + const isTextTruncated = isTitleTruncate || isSubtitleTruncate + + return ( + + {startIconConfig && ( + + {startIconConfig.customIcon || ( + + )} + + )} + + + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + + + ) +} + +export default TreeViewNodeContent diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts index 309ac0794..871821be1 100644 --- a/src/Shared/Components/TreeView/types.ts +++ b/src/Shared/Components/TreeView/types.ts @@ -1,3 +1,6 @@ +import { TooltipProps } from '@Common/Tooltip' +import { Never } from '@Shared/types' + import { IconsProps } from '../Icon' import { TrailingItemProps } from '../TrailingItem' @@ -14,13 +17,17 @@ interface BaseNode { * The subtitle of the list item. */ subtitle?: string + customTooltipConfig?: TooltipProps /** * If true, the title will be rendered with line-through. */ strikeThrough?: boolean - startIconConfig?: Pick & { + startIconConfig?: { tooltipContent?: string - } + } & ( + | (Pick & { customIcon?: never }) + | (Never> & { customIcon?: JSX.Element }) + ) trailingItem?: TrailingItemProps } @@ -68,9 +75,9 @@ export type TreeItem = BaseNode & { export type TreeViewProps = { nodes: TreeNode[] expandedMap: Record - selectedId?: string onToggle: (item: TreeHeading) => void - onSelect: (item: TreeItem) => void + selectedId?: string + onSelect?: (item: TreeItem) => void /** * If navigation mode, the tree view will provide navigation through keyboard actions and make the only selected item focusable. * If form mode, will leave the navigation to browser. @@ -101,3 +108,8 @@ export type TreeViewProps = { flatNodeList?: never } ) + +export interface TreeViewNodeContentProps + extends Pick { + type: 'heading' | 'item' +} From 4af95bed8a0322b4d7ae72d43726abb3ddda2c05 Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Mon, 30 Jun 2025 16:04:33 +0530 Subject: [PATCH 25/90] refactor: remove ShortcutKeyBadge component and update usage in GroupedFilterSelectPicker --- .../Components/SelectPicker/GroupedFilterSelectPicker.tsx | 3 +-- src/Shared/Components/Widgets/Widgets.tsx | 5 ----- src/Shared/Components/Widgets/index.ts | 1 - src/Shared/Components/Widgets/types.ts | 3 --- 4 files changed, 1 insertion(+), 11 deletions(-) delete mode 100644 src/Shared/Components/Widgets/Widgets.tsx delete mode 100644 src/Shared/Components/Widgets/index.ts delete mode 100644 src/Shared/Components/Widgets/types.ts diff --git a/src/Shared/Components/SelectPicker/GroupedFilterSelectPicker.tsx b/src/Shared/Components/SelectPicker/GroupedFilterSelectPicker.tsx index beead6509..76cd881fa 100644 --- a/src/Shared/Components/SelectPicker/GroupedFilterSelectPicker.tsx +++ b/src/Shared/Components/SelectPicker/GroupedFilterSelectPicker.tsx @@ -4,7 +4,6 @@ import { useRegisterShortcut } from '@Common/Hooks' import { ActionMenu, ActionMenuItemType, ActionMenuProps } from '../ActionMenu' import { Icon } from '../Icon' -import { ShortcutKeyBadge } from '../Widgets' import FilterSelectPicker from './FilterSelectPicker' import { GroupedFilterSelectPickerProps } from './type' @@ -81,7 +80,7 @@ export const GroupedFilterSelectPicker = ({ > Filter - + F ) diff --git a/src/Shared/Components/Widgets/Widgets.tsx b/src/Shared/Components/Widgets/Widgets.tsx deleted file mode 100644 index ecd930090..000000000 --- a/src/Shared/Components/Widgets/Widgets.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { ShortcutKeyBadgeProps } from './types' - -export const ShortcutKeyBadge = ({ shortcutKey }: ShortcutKeyBadgeProps) => ( -
{shortcutKey}
-) diff --git a/src/Shared/Components/Widgets/index.ts b/src/Shared/Components/Widgets/index.ts deleted file mode 100644 index 8e03f1309..000000000 --- a/src/Shared/Components/Widgets/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Widgets' diff --git a/src/Shared/Components/Widgets/types.ts b/src/Shared/Components/Widgets/types.ts deleted file mode 100644 index 7f05eaf2e..000000000 --- a/src/Shared/Components/Widgets/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface ShortcutKeyBadgeProps { - shortcutKey: string -} From d0fc33fb84c488cb400bab00619c15f7092f9ba9 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Mon, 30 Jun 2025 16:19:16 +0530 Subject: [PATCH 26/90] feat: integrate framer-motion for animated transitions in TreeView component --- .../TreeView/TreeView.component.tsx | 82 ++++++++++++------- 1 file changed, 51 insertions(+), 31 deletions(-) diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index 746b5ec0a..4ee185687 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -1,5 +1,6 @@ -import { useMemo, useRef } from 'react' +import { useMemo, useRef, useState } from 'react' import { NavLink, useLocation } from 'react-router-dom' +import { AnimatePresence, motion } from 'framer-motion' import { Icon } from '../Icon' import { TrailingItem } from '../TrailingItem' @@ -28,15 +29,21 @@ const TreeView = ({ const { pathname } = useLocation() // Using this at root level const rootItemRefs = useRef>({}) + const [transitionNodeId, setTransitionNodeId] = useState(null) const isFirstLevel = depth === 0 const fallbackTabIndex = mode === 'navigation' ? -1 : 0 const getToggleNode = (node: TreeHeading) => () => { + setTransitionNodeId(node.id) onToggle(node) } + const onTransitionEnd = () => { + setTransitionNodeId(null) + } + const getUpdateItemsRefMap = (id: string) => (el: HTMLButtonElement | HTMLAnchorElement) => { if (!isFirstLevel) { throw new Error('getUpdateItemsRefMap should only be used at the first level of the tree view.') @@ -197,36 +204,49 @@ const TreeView = ({
- {isExpanded && ( -
- {!node.items?.length ? ( - <> - {dividerPrefix} - - {node.noItemsText || 'No items found.'} - - ) : ( -
- {node.items.map((nodeItem) => ( - - ))} -
- )} -
- )} + + {isExpanded && ( + + {!node.items?.length ? ( + <> + {dividerPrefix} + + + {node.noItemsText || 'No items found.'} + + + ) : ( +
+ {node.items.map((nodeItem) => ( + + ))} +
+ )} +
+ )} +
) } From 7db4a2d25575ce039167c208cf99bb0803c26577 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Mon, 30 Jun 2025 16:40:19 +0530 Subject: [PATCH 27/90] feat: copilot review --- .../TreeView/TreeView.component.tsx | 23 +++++-------------- .../TreeView/TreeViewNodeContent.tsx | 8 +++---- src/Shared/Components/TreeView/constants.ts | 1 + src/Shared/Components/TreeView/types.ts | 2 +- 4 files changed, 12 insertions(+), 22 deletions(-) create mode 100644 src/Shared/Components/TreeView/constants.ts diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index 4ee185687..0fe323874 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -1,9 +1,10 @@ -import { useMemo, useRef, useState } from 'react' +import { useMemo, useRef } from 'react' import { NavLink, useLocation } from 'react-router-dom' import { AnimatePresence, motion } from 'framer-motion' import { Icon } from '../Icon' import { TrailingItem } from '../TrailingItem' +import { DEFAULT_NO_ITEMS_TEXT } from './constants' import TreeViewNodeContent from './TreeViewNodeContent' import { TreeHeading, TreeViewProps } from './types' @@ -29,21 +30,15 @@ const TreeView = ({ const { pathname } = useLocation() // Using this at root level const rootItemRefs = useRef>({}) - const [transitionNodeId, setTransitionNodeId] = useState(null) const isFirstLevel = depth === 0 const fallbackTabIndex = mode === 'navigation' ? -1 : 0 const getToggleNode = (node: TreeHeading) => () => { - setTransitionNodeId(node.id) onToggle(node) } - const onTransitionEnd = () => { - setTransitionNodeId(null) - } - const getUpdateItemsRefMap = (id: string) => (el: HTMLButtonElement | HTMLAnchorElement) => { if (!isFirstLevel) { throw new Error('getUpdateItemsRefMap should only be used at the first level of the tree view.') @@ -68,13 +63,8 @@ const TreeView = ({ return flatNodeListProp } - if (flatNodeListProp) { - // If flatNodeList is provided, return it directly - return flatNodeListProp - } - return traverseNodes(nodes) - }, [nodes, expandedMap]) + }, [nodes, expandedMap, flatNodeListProp]) const handleKeyDown = (e: React.KeyboardEvent) => { if (mode !== 'navigation' || !isFirstLevel) { @@ -204,24 +194,23 @@ const TreeView = ({ - + {isExpanded && ( {!node.items?.length ? ( <> {dividerPrefix} - {node.noItemsText || 'No items found.'} + {node.noItemsText || DEFAULT_NO_ITEMS_TEXT} ) : ( diff --git a/src/Shared/Components/TreeView/TreeViewNodeContent.tsx b/src/Shared/Components/TreeView/TreeViewNodeContent.tsx index 48aa9a453..f739d1b6d 100644 --- a/src/Shared/Components/TreeView/TreeViewNodeContent.tsx +++ b/src/Shared/Components/TreeView/TreeViewNodeContent.tsx @@ -5,7 +5,7 @@ import { Icon } from '../Icon' import { TreeViewNodeContentProps } from './types' const wrapWithTooltip = - (customTooltipConfig: TooltipProps, isTextTruncated: boolean, title: string, subTitle: string) => + (customTooltipConfig: TooltipProps, isTextTruncated: boolean, title: string, subtitle: string) => (children: TooltipProps['children']) => { if (customTooltipConfig) { return {children} @@ -20,12 +20,12 @@ const wrapWithTooltip = content={
{title}
- {subTitle && ( -

{subTitle}

+ {subtitle && ( +

{subtitle}

)}
} diff --git a/src/Shared/Components/TreeView/constants.ts b/src/Shared/Components/TreeView/constants.ts new file mode 100644 index 000000000..d39c65d46 --- /dev/null +++ b/src/Shared/Components/TreeView/constants.ts @@ -0,0 +1 @@ +export const DEFAULT_NO_ITEMS_TEXT = 'No items found' diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts index 871821be1..8c81b4399 100644 --- a/src/Shared/Components/TreeView/types.ts +++ b/src/Shared/Components/TreeView/types.ts @@ -36,7 +36,7 @@ export interface TreeHeading extends BaseNode { items?: TreeNode[] /** * Text to display when there are no items in the list. - * @default 'No items found.' + * @default DEFAULT_NO_ITEMS_TEXT */ noItemsText?: string } From 6aa852bf1cbd040fd4474f6bef58075b1e2fc9ab Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Mon, 30 Jun 2025 16:48:16 +0530 Subject: [PATCH 28/90] feat: add click handlers for TreeView node items to manage button and link interactions --- .../TreeView/TreeView.component.tsx | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index 0fe323874..e6d72e577 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef } from 'react' +import { MouseEvent, useMemo, useRef } from 'react' import { NavLink, useLocation } from 'react-router-dom' import { AnimatePresence, motion } from 'framer-motion' @@ -6,7 +6,7 @@ import { Icon } from '../Icon' import { TrailingItem } from '../TrailingItem' import { DEFAULT_NO_ITEMS_TEXT } from './constants' import TreeViewNodeContent from './TreeViewNodeContent' -import { TreeHeading, TreeViewProps } from './types' +import { TreeHeading, TreeItem, TreeViewProps } from './types' import './TreeView.scss' @@ -101,6 +101,28 @@ const TreeView = ({ } } + const getNodeItemButtonClick = (node: TreeItem) => (e: MouseEvent) => { + if (node.as !== 'button') { + return + } + + node.onClick?.(e) + onSelect?.(node) + } + + const getNodeItemNavLinkClick = (node: TreeItem) => (e: MouseEvent) => { + if (node.as !== 'link') { + return + } + + // Prevent navigation to the same page + if (node.href === pathname) { + e.preventDefault() + } + node.onClick?.(e) + onSelect?.(node) + } + return (
{ - // Prevent navigation to the same page - if (node.href === pathname) { - e.preventDefault() - } - node.onClick?.(e) - onSelect?.(node) - }} + onClick={getNodeItemNavLinkClick(node)} tabIndex={isSelected ? 0 : fallbackTabIndex} data-node-id={node.id} ref={ @@ -293,10 +308,7 @@ const TreeView = ({ type="button" disabled={node.isDisabled} className={baseClass} - onClick={(e) => { - node.onClick?.(e) - onSelect?.(node) - }} + onClick={getNodeItemButtonClick(node)} tabIndex={isSelected ? 0 : fallbackTabIndex} data-node-id={node.id} ref={ From 029324c7ea9f2d279b52d8ea4f1af930bbd49eea Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Tue, 1 Jul 2025 11:04:44 +0530 Subject: [PATCH 29/90] feat: update TreeView and related components to support selection state and improve click handling --- .../Security/SecurityModal/config/index.ts | 2 +- .../Security/SecurityModal/index.ts | 8 +++++-- .../Security/SecurityModal/types.ts | 7 ------ src/Shared/Components/Security/types.tsx | 7 +++++- .../TrailingItem/TrailingItem.component.tsx | 2 +- src/Shared/Components/TrailingItem/types.ts | 2 +- .../TreeView/TreeView.component.tsx | 22 ++++++++----------- .../TreeView/TreeViewNodeContent.tsx | 3 ++- src/Shared/Components/TreeView/types.ts | 11 ++++++---- 9 files changed, 33 insertions(+), 31 deletions(-) diff --git a/src/Shared/Components/Security/SecurityModal/config/index.ts b/src/Shared/Components/Security/SecurityModal/config/index.ts index 974736e69..0efecf962 100644 --- a/src/Shared/Components/Security/SecurityModal/config/index.ts +++ b/src/Shared/Components/Security/SecurityModal/config/index.ts @@ -16,5 +16,5 @@ export { getProgressingStateForStatus } from './ImageScan' export { getInfoCardData } from './InfoCard' -export { getSidebarData } from './Sidebar' +export { getSecurityModalSidebarChildFromId, getSecurityModalSidebarId, getSidebarData } from './Sidebar' export { getTableData } from './Table' diff --git a/src/Shared/Components/Security/SecurityModal/index.ts b/src/Shared/Components/Security/SecurityModal/index.ts index 466b11348..e16f6439f 100644 --- a/src/Shared/Components/Security/SecurityModal/index.ts +++ b/src/Shared/Components/Security/SecurityModal/index.ts @@ -14,7 +14,12 @@ * limitations under the License. */ -export { getProgressingStateForStatus, getSidebarData } from './config' +export { + getProgressingStateForStatus, + getSecurityModalSidebarChildFromId, + getSecurityModalSidebarId, + getSidebarData, +} from './config' export { CATEGORY_LABELS } from './constants' export { default as SecurityModal } from './SecurityModal' export { getSecurityScan } from './service' @@ -23,7 +28,6 @@ export type { GetResourceScanDetailsResponseType, ScanResultDTO, SidebarDataChildType, - SidebarDataType, SidebarPropsType, } from './types' export { SeveritiesDTO } from './types' diff --git a/src/Shared/Components/Security/SecurityModal/types.ts b/src/Shared/Components/Security/SecurityModal/types.ts index 79e1dac6e..38ec60fc8 100644 --- a/src/Shared/Components/Security/SecurityModal/types.ts +++ b/src/Shared/Components/Security/SecurityModal/types.ts @@ -284,13 +284,6 @@ export type SidebarDataChildType = { } } -export type SidebarDataType = { - label: string - isExpanded: boolean - children: NonNullable - hideInHelmApp?: boolean -} - export type EmptyStateType = Pick export const VulnerabilityState = { diff --git a/src/Shared/Components/Security/types.tsx b/src/Shared/Components/Security/types.tsx index 90a699cab..c7372ee97 100644 --- a/src/Shared/Components/Security/types.tsx +++ b/src/Shared/Components/Security/types.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { CATEGORIES, SUB_CATEGORIES } from './SecurityModal/types' +import { CATEGORIES, ScanResultDTO, SUB_CATEGORIES } from './SecurityModal/types' export type ScanCategories = (typeof CATEGORIES)[keyof typeof CATEGORIES] export type ScanSubCategories = (typeof SUB_CATEGORIES)[keyof typeof SUB_CATEGORIES] @@ -37,3 +37,8 @@ export interface SecurityConfigType { codeScan?: SecurityConfigCategoryType kubernetesManifest?: SecurityConfigCategoryType } + +export interface GetSidebarDataParamsType extends Record { + selectedId: string + scanResult: ScanResultDTO +} diff --git a/src/Shared/Components/TrailingItem/TrailingItem.component.tsx b/src/Shared/Components/TrailingItem/TrailingItem.component.tsx index 4e2baac51..649911780 100644 --- a/src/Shared/Components/TrailingItem/TrailingItem.component.tsx +++ b/src/Shared/Components/TrailingItem/TrailingItem.component.tsx @@ -41,7 +41,7 @@ const TrailingItem = ({ type, config, variant = 'neutral' }: TrailingItemProps) ) } case 'counter': - return + return case 'switch': return case 'button': diff --git a/src/Shared/Components/TrailingItem/types.ts b/src/Shared/Components/TrailingItem/types.ts index bae6a904b..d72fa5803 100644 --- a/src/Shared/Components/TrailingItem/types.ts +++ b/src/Shared/Components/TrailingItem/types.ts @@ -28,7 +28,7 @@ export type TrailingItemType = type: 'counter' config: { value: NumbersCountProps['count'] - } + } & Pick } | { type: 'switch' diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index e6d72e577..5cf831211 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -1,4 +1,4 @@ -import { MouseEvent, useMemo, useRef } from 'react' +import { SyntheticEvent, useMemo, useRef } from 'react' import { NavLink, useLocation } from 'react-router-dom' import { AnimatePresence, motion } from 'framer-motion' @@ -23,7 +23,7 @@ const TreeView = ({ onToggle, onSelect, depth = 0, - mode, + mode = 'navigation', flatNodeList: flatNodeListProp, getUpdateItemsRefMap: getUpdateItemsRefMapProp, }: TreeViewProps) => { @@ -101,26 +101,21 @@ const TreeView = ({ } } - const getNodeItemButtonClick = (node: TreeItem) => (e: MouseEvent) => { - if (node.as !== 'button') { - return - } - + const commonClickHandler = (e: SyntheticEvent, node: TreeItem) => { node.onClick?.(e) onSelect?.(node) } - const getNodeItemNavLinkClick = (node: TreeItem) => (e: MouseEvent) => { - if (node.as !== 'link') { - return - } + const getNodeItemButtonClick = (node: TreeItem) => (e: SyntheticEvent) => { + commonClickHandler(e, node) + } + const getNodeItemNavLinkClick = (node: TreeItem) => (e: SyntheticEvent) => { // Prevent navigation to the same page if (node.href === pathname) { e.preventDefault() } - node.onClick?.(e) - onSelect?.(node) + commonClickHandler(e, node) } return ( @@ -145,6 +140,7 @@ const TreeView = ({ type={node.type} customTooltipConfig={node.customTooltipConfig} strikeThrough={node.strikeThrough} + isSelected={isSelected} /> ) diff --git a/src/Shared/Components/TreeView/TreeViewNodeContent.tsx b/src/Shared/Components/TreeView/TreeViewNodeContent.tsx index f739d1b6d..3c474c970 100644 --- a/src/Shared/Components/TreeView/TreeViewNodeContent.tsx +++ b/src/Shared/Components/TreeView/TreeViewNodeContent.tsx @@ -46,6 +46,7 @@ const TreeViewNodeContent = ({ type, customTooltipConfig, strikeThrough, + isSelected, }: TreeViewNodeContentProps) => { const { isTextTruncated: isTitleTruncate, handleMouseEnterEvent: handleTitleMouseEnter } = useIsTextTruncated() const { isTextTruncated: isSubtitleTruncate, handleMouseEnterEvent: handleSubtitleMouseEnter } = @@ -72,7 +73,7 @@ const TreeViewNodeContent = ({ > {title} diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts index 8c81b4399..c8b596929 100644 --- a/src/Shared/Components/TreeView/types.ts +++ b/src/Shared/Components/TreeView/types.ts @@ -1,3 +1,5 @@ +import { SyntheticEvent } from 'react' + import { TooltipProps } from '@Common/Tooltip' import { Never } from '@Shared/types' @@ -47,13 +49,13 @@ export type TreeItem = BaseNode & { * @default false */ isDisabled?: boolean -} & ( +} & ( // Should we add as `div` as well? | { as?: 'button' /** * The callback function to handle click events on the button. */ - onClick?: (e: React.MouseEvent) => void + onClick?: (e: SyntheticEvent) => void href?: never clearQueryParamsOnNavigation?: never } @@ -63,7 +65,7 @@ export type TreeItem = BaseNode & { /** * The callback function to handle click events on the nav link. */ - onClick?: (e: React.MouseEvent) => void + onClick?: (e: SyntheticEvent) => void /** * If `true`, clears query parameters during navigation. * @default false @@ -83,7 +85,7 @@ export type TreeViewProps = { * If form mode, will leave the navigation to browser. * @default 'navigation' */ - mode: 'navigation' | 'form' + mode?: 'navigation' | 'form' } & ( | { /** @@ -112,4 +114,5 @@ export type TreeViewProps = { export interface TreeViewNodeContentProps extends Pick { type: 'heading' | 'item' + isSelected: boolean } From fa6b56e888366b3525a43e70df5b1887bbd9b6ee Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Tue, 1 Jul 2025 11:06:15 +0530 Subject: [PATCH 30/90] feat: enhance Sidebar component to support TreeView structure and add threat count handling --- .../Security/SecurityModal/config/Sidebar.ts | 162 +++++++++++++----- 1 file changed, 115 insertions(+), 47 deletions(-) diff --git a/src/Shared/Components/Security/SecurityModal/config/Sidebar.ts b/src/Shared/Components/Security/SecurityModal/config/Sidebar.ts index 569f55e3d..9f37f6f7a 100644 --- a/src/Shared/Components/Security/SecurityModal/config/Sidebar.ts +++ b/src/Shared/Components/Security/SecurityModal/config/Sidebar.ts @@ -14,103 +14,171 @@ * limitations under the License. */ -import { ScanCategoriesWithLicense } from '../../types' +import { TreeItem, TreeViewProps } from '@Shared/Components/TreeView' + +import { GetSidebarDataParamsType } from '../../types' import { CATEGORY_LABELS, SUB_CATEGORY_LABELS } from '../constants' -import { CATEGORIES, SidebarDataType, SUB_CATEGORIES } from '../types' +import { CATEGORIES, SeveritiesDTO, SidebarDataChildType, SUB_CATEGORIES } from '../types' + +export const getSecurityModalSidebarId = ({ category, subCategory }: SidebarDataChildType['value']): string => + JSON.stringify({ category, subCategory }) -export const getSidebarData = (categoriesConfig: Record): SidebarDataType[] => { - const { imageScan, codeScan, kubernetesManifest, imageScanLicenseRisks } = categoriesConfig +export const getSecurityModalSidebarChildFromId = (id: string): SidebarDataChildType['value'] => { + const parsedId = JSON.parse(id) + return { + category: parsedId.category, + subCategory: parsedId.subCategory, + } +} - return [ +export const getSidebarData = ({ + imageScan, + codeScan, + kubernetesManifest, + imageScanLicenseRisks, + selectedId, + scanResult, +}: GetSidebarDataParamsType): TreeViewProps['nodes'] => { + const nodes: TreeViewProps['nodes'] = [ ...(imageScan - ? [ + ? ([ { - label: CATEGORY_LABELS.IMAGE_SCAN, - isExpanded: true, - children: [ + type: 'heading', + title: CATEGORY_LABELS.IMAGE_SCAN, + id: CATEGORY_LABELS.IMAGE_SCAN, + items: [ { - label: SUB_CATEGORY_LABELS.VULNERABILITIES, - value: { + type: 'item', + title: SUB_CATEGORY_LABELS.VULNERABILITIES, + id: getSecurityModalSidebarId({ category: CATEGORIES.IMAGE_SCAN, subCategory: SUB_CATEGORIES.VULNERABILITIES, - }, + }), }, ...(imageScanLicenseRisks - ? [ + ? ([ { - label: SUB_CATEGORY_LABELS.LICENSE, - value: { + type: 'item', + title: SUB_CATEGORY_LABELS.LICENSE, + id: getSecurityModalSidebarId({ category: CATEGORIES.IMAGE_SCAN, subCategory: SUB_CATEGORIES.LICENSE, - }, + }), }, - ] + ] satisfies TreeItem[]) : []), ], }, - ] + ] satisfies TreeViewProps['nodes']) : []), ...(codeScan - ? [ + ? ([ { - label: CATEGORY_LABELS.CODE_SCAN, - isExpanded: true, - children: [ + type: 'heading', + title: CATEGORY_LABELS.CODE_SCAN, + id: CATEGORY_LABELS.CODE_SCAN, + items: [ { - label: SUB_CATEGORY_LABELS.VULNERABILITIES, - value: { + type: 'item', + title: SUB_CATEGORY_LABELS.VULNERABILITIES, + id: getSecurityModalSidebarId({ category: CATEGORIES.CODE_SCAN, subCategory: SUB_CATEGORIES.VULNERABILITIES, - }, + }), }, { - label: SUB_CATEGORY_LABELS.LICENSE, - value: { + type: 'item', + title: SUB_CATEGORY_LABELS.LICENSE, + id: getSecurityModalSidebarId({ category: CATEGORIES.CODE_SCAN, subCategory: SUB_CATEGORIES.LICENSE, - }, + }), }, { - label: SUB_CATEGORY_LABELS.MISCONFIGURATIONS, - value: { + type: 'item', + title: SUB_CATEGORY_LABELS.MISCONFIGURATIONS, + id: getSecurityModalSidebarId({ category: CATEGORIES.CODE_SCAN, subCategory: SUB_CATEGORIES.MISCONFIGURATIONS, - }, + }), }, { - label: SUB_CATEGORY_LABELS.EXPOSED_SECRETS, - value: { + type: 'item', + title: SUB_CATEGORY_LABELS.EXPOSED_SECRETS, + id: getSecurityModalSidebarId({ category: CATEGORIES.CODE_SCAN, subCategory: SUB_CATEGORIES.EXPOSED_SECRETS, - }, + }), }, ], }, - ] + ] satisfies TreeViewProps['nodes']) : []), ...(kubernetesManifest - ? [ + ? ([ { - label: CATEGORY_LABELS.KUBERNETES_MANIFEST, - isExpanded: true, - children: [ + type: 'heading', + title: CATEGORY_LABELS.KUBERNETES_MANIFEST, + id: CATEGORY_LABELS.KUBERNETES_MANIFEST, + items: [ { - label: SUB_CATEGORY_LABELS.MISCONFIGURATIONS, - value: { + type: 'item', + title: SUB_CATEGORY_LABELS.MISCONFIGURATIONS, + id: getSecurityModalSidebarId({ category: CATEGORIES.KUBERNETES_MANIFEST, subCategory: SUB_CATEGORIES.MISCONFIGURATIONS, - }, + }), }, { - label: SUB_CATEGORY_LABELS.EXPOSED_SECRETS, - value: { + type: 'item', + title: SUB_CATEGORY_LABELS.EXPOSED_SECRETS, + id: getSecurityModalSidebarId({ category: CATEGORIES.KUBERNETES_MANIFEST, subCategory: SUB_CATEGORIES.EXPOSED_SECRETS, - }, + }), }, ], }, - ] + ] satisfies TreeViewProps['nodes']) : []), - ] + ] satisfies TreeViewProps['nodes'] + + // Not implementing complete dfs since its not nested, traversing + nodes.forEach((node) => { + if (node.type === 'heading') { + node.items.forEach((item) => { + if (item.type === 'heading') { + throw new Error( + 'Broken assumption: Heading should not have nested headings in security sidebar, Please implement dfs based handling for nested headings in security sidebar', + ) + } + + const { category, subCategory } = getSecurityModalSidebarChildFromId(item.id) + const subCategoryResult = scanResult[category]?.[subCategory] + + const severities: Partial> = + subCategoryResult?.summary?.severities || subCategoryResult?.misConfSummary?.status + + const threatCount: number = Object.keys(severities || {}).reduce((acc, key) => { + if (key === SeveritiesDTO.SUCCESSES) { + return acc + } + return acc + severities[key] + }, 0) + + // eslint-disable-next-line no-param-reassign + item.trailingItem = threatCount + ? { + type: 'counter', + config: { + value: threatCount, + isSelected: selectedId === item.id, + }, + } + : null + }) + } + }) + + return nodes } From 6445985323b7612fc01b874f6dba8c8e5cd307ec Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Tue, 1 Jul 2025 11:43:50 +0530 Subject: [PATCH 31/90] feat: refactor TreeView component to support uncontrolled mode and update props for better state management --- .../TreeView/TreeView.component.tsx | 18 +++++-- src/Shared/Components/TreeView/types.ts | 52 +++++++++++-------- 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index 5cf831211..e78eddd67 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -1,4 +1,4 @@ -import { SyntheticEvent, useMemo, useRef } from 'react' +import { SyntheticEvent, useMemo, useRef, useState } from 'react' import { NavLink, useLocation } from 'react-router-dom' import { AnimatePresence, motion } from 'framer-motion' @@ -18,7 +18,8 @@ const Divider = () => ( const TreeView = ({ nodes, - expandedMap, + isUncontrolled, + expandedMap: expandedMapProp, selectedId, onToggle, onSelect, @@ -30,13 +31,24 @@ const TreeView = ({ const { pathname } = useLocation() // Using this at root level const rootItemRefs = useRef>({}) + // This will in actuality be used in first level of tree view since we are sending isUncontrolled prop as false to all the nested tree views + const [currentLevelExpandedMap, setCurrentLevelExpandedMap] = useState>({}) + + const expandedMap = isUncontrolled ? currentLevelExpandedMap : expandedMapProp const isFirstLevel = depth === 0 const fallbackTabIndex = mode === 'navigation' ? -1 : 0 const getToggleNode = (node: TreeHeading) => () => { - onToggle(node) + if (isUncontrolled) { + setCurrentLevelExpandedMap((prev) => ({ + ...prev, + [node.id]: !prev[node.id], + })) + } else { + onToggle(node) + } } const getUpdateItemsRefMap = (id: string) => (el: HTMLButtonElement | HTMLAnchorElement) => { diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts index c8b596929..f86823612 100644 --- a/src/Shared/Components/TreeView/types.ts +++ b/src/Shared/Components/TreeView/types.ts @@ -76,8 +76,6 @@ export type TreeItem = BaseNode & { export type TreeViewProps = { nodes: TreeNode[] - expandedMap: Record - onToggle: (item: TreeHeading) => void selectedId?: string onSelect?: (item: TreeItem) => void /** @@ -88,28 +86,40 @@ export type TreeViewProps = { mode?: 'navigation' | 'form' } & ( | { - /** - * WARNING: For internal use only. - */ - depth: number - /** - * WARNING: For internal use only. - * Would pass this to item button/ref and store it in out ref map through this function. - */ - getUpdateItemsRefMap: (id: string) => (element: HTMLButtonElement | HTMLAnchorElement) => void - - /** - * WARNING: For internal use only. - * List of all nodes visible in tree view for keyboard navigation. - */ - flatNodeList: string[] + isUncontrolled: true + expandedMap?: never + onToggle?: never } | { - depth?: never - getUpdateItemsRefMap?: never - flatNodeList?: never + isUncontrolled?: false + expandedMap: Record + onToggle: (item: TreeHeading) => void } -) +) & + ( + | { + /** + * WARNING: For internal use only. + */ + depth: number + /** + * WARNING: For internal use only. + * Would pass this to item button/ref and store it in out ref map through this function. + */ + getUpdateItemsRefMap: (id: string) => (element: HTMLButtonElement | HTMLAnchorElement) => void + + /** + * WARNING: For internal use only. + * List of all nodes visible in tree view for keyboard navigation. + */ + flatNodeList: string[] + } + | { + depth?: never + getUpdateItemsRefMap?: never + flatNodeList?: never + } + ) export interface TreeViewNodeContentProps extends Pick { From 8c09454451956df0d61787508416d50ddb6a830e Mon Sep 17 00:00:00 2001 From: shivani170 Date: Tue, 1 Jul 2025 13:13:12 +0530 Subject: [PATCH 32/90] chore: locally saved user preferences payload --- src/Shared/Hooks/useUserPreferences/service.ts | 15 ++++++++++----- .../useUserPreferences/useUserPrefrences.tsx | 5 ++++- src/Shared/Hooks/useUserPreferences/utils.tsx | 15 --------------- 3 files changed, 14 insertions(+), 21 deletions(-) diff --git a/src/Shared/Hooks/useUserPreferences/service.ts b/src/Shared/Hooks/useUserPreferences/service.ts index 84dccb813..5d22ec6c2 100644 --- a/src/Shared/Hooks/useUserPreferences/service.ts +++ b/src/Shared/Hooks/useUserPreferences/service.ts @@ -20,17 +20,17 @@ import { THEME_PREFERENCE_MAP } from '@Shared/Providers/ThemeProvider/types' import { USER_PREFERENCES_ATTRIBUTE_KEY } from './constants' import { - BaseRecentlyVisitedEntitiesTypes, GetUserPreferencesParsedDTO, GetUserPreferencesQueryParamsType, UpdateUserPreferencesPayloadType, UserPathValueMapType, + UserPreferenceResourceActions, UserPreferenceResourceProps, UserPreferencesPayloadValueType, UserPreferencesType, ViewIsPipelineRBACConfiguredRadioTabs, } from './types' -import { getParsedResourcesMap, getUserPreferenceResourcesMetadata } from './utils' +import { getParsedResourcesMap } from './utils' /** * @returns UserPreferencesType @@ -78,6 +78,7 @@ const getUserPreferencePayload = async ({ path, value, resourceKind, + userPreferencesResponse, }: UserPathValueMapType): Promise> => { switch (path) { case 'themePreference': @@ -92,11 +93,15 @@ const getUserPreferencePayload = async ({ } case 'resources': { + const existingResources = userPreferencesResponse?.resources || {} const updatedResources = { + ...existingResources, [resourceKind]: { - ...getUserPreferenceResourcesMetadata(value as BaseRecentlyVisitedEntitiesTypes[], resourceKind)[ - resourceKind - ], + ...existingResources[resourceKind], + [UserPreferenceResourceActions.RECENTLY_VISITED]: value.map(({ id, name }) => ({ + id, + name, + })), }, } diff --git a/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx b/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx index 98fa77cc3..85a545acb 100644 --- a/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx +++ b/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx @@ -39,7 +39,8 @@ export const useUserPreferences = ({ migrateUserPreferences, recentlyVisitedFetc const { handleThemeSwitcherDialogVisibilityChange, handleThemePreferenceChange } = useTheme() const fetchRecentlyVisitedParsedEntities = async (): Promise => { - const userPreferencesResponse = await getUserPreferences() + // Retrieve and parse the user's saved preferences from local storage + const userPreferencesResponse = JSON.parse(localStorage.getItem('userPreferences')) const uniqueFilteredApps = getFilteredUniqueAppList({ userPreferencesResponse, @@ -66,6 +67,8 @@ export const useUserPreferences = ({ migrateUserPreferences, recentlyVisitedFetc }, } + localStorage.setItem('userPreferences', JSON.stringify(updatedUserPreferences)) + return updatedUserPreferences } diff --git a/src/Shared/Hooks/useUserPreferences/utils.tsx b/src/Shared/Hooks/useUserPreferences/utils.tsx index e4f6a5aef..489a7bd34 100644 --- a/src/Shared/Hooks/useUserPreferences/utils.tsx +++ b/src/Shared/Hooks/useUserPreferences/utils.tsx @@ -1,26 +1,11 @@ import { DEFAULT_RESOURCES_MAP } from './constants' import { - BaseRecentlyVisitedEntitiesTypes, GetUserPreferencesParsedDTO, - PreferredResourceKindType, UserPreferenceFilteredListTypes, UserPreferenceResourceActions, - UserPreferenceResourceType, UserPreferencesType, } from './types' -export const getUserPreferenceResourcesMetadata = ( - recentlyVisited: BaseRecentlyVisitedEntitiesTypes[], - resourceKind: PreferredResourceKindType, -): UserPreferenceResourceType => ({ - [resourceKind]: { - [UserPreferenceResourceActions.RECENTLY_VISITED]: recentlyVisited.map(({ id, name }) => ({ - id, - name, - })), - }, -}) - export const getFilteredUniqueAppList = ({ userPreferencesResponse, id, From 6ebd364fe9a17cae28a5a01425cfc4dfdd4a3340 Mon Sep 17 00:00:00 2001 From: shivani170 Date: Tue, 1 Jul 2025 14:07:56 +0530 Subject: [PATCH 33/90] chore: undefined user preferences handling --- .../useUserPreferences/useUserPrefrences.tsx | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx b/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx index 85a545acb..19054e394 100644 --- a/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx +++ b/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx @@ -42,6 +42,10 @@ export const useUserPreferences = ({ migrateUserPreferences, recentlyVisitedFetc // Retrieve and parse the user's saved preferences from local storage const userPreferencesResponse = JSON.parse(localStorage.getItem('userPreferences')) + if (!resourceKind) { + return userPreferencesResponse + } + const uniqueFilteredApps = getFilteredUniqueAppList({ userPreferencesResponse, id, @@ -49,13 +53,6 @@ export const useUserPreferences = ({ migrateUserPreferences, recentlyVisitedFetc resourceKind, }) - await updateUserPreferences({ - path: 'resources', - value: uniqueFilteredApps, - resourceKind, - userPreferencesResponse, - }) - const updatedUserPreferences = { ...userPreferencesResponse, resources: { @@ -66,13 +63,33 @@ export const useUserPreferences = ({ migrateUserPreferences, recentlyVisitedFetc }, }, } - localStorage.setItem('userPreferences', JSON.stringify(updatedUserPreferences)) + setUserPreferences((prev) => ({ + ...prev, + resources: { + ...prev?.resources, + [resourceKind]: { + ...prev?.resources?.[resourceKind], + [UserPreferenceResourceActions.RECENTLY_VISITED]: uniqueFilteredApps, + }, + }, + })) + await updateUserPreferences({ + path: 'resources', + value: uniqueFilteredApps, + resourceKind, + userPreferencesResponse, + }) + return updatedUserPreferences } - const [, recentResourcesResult] = useAsync(() => fetchRecentlyVisitedParsedEntities(), [id, name], isDataAvailable) + const [recentResourcesLoading, recentResourcesResult] = useAsync( + () => fetchRecentlyVisitedParsedEntities(), + [id, name], + isDataAvailable && !!resourceKind, + ) const recentlyVisitedResources = recentResourcesResult?.resources?.[resourceKind]?.[UserPreferenceResourceActions.RECENTLY_VISITED] || @@ -125,5 +142,6 @@ export const useUserPreferences = ({ migrateUserPreferences, recentlyVisitedFetc handleUpdateUserThemePreference, fetchRecentlyVisitedParsedEntities, recentlyVisitedResources, + recentResourcesLoading, } } From 4e9118d3c8a03bc26c372f672486da8ef80cf19b Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Tue, 1 Jul 2025 17:22:46 +0530 Subject: [PATCH 34/90] feat: add variant support to TreeView for customizable background and hover styles --- .../Components/TreeView/TreeView.component.tsx | 17 ++++++++++++----- src/Shared/Components/TreeView/constants.ts | 12 ++++++++++++ src/Shared/Components/TreeView/types.ts | 5 +++++ 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index e78eddd67..17368d696 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -4,7 +4,7 @@ import { AnimatePresence, motion } from 'framer-motion' import { Icon } from '../Icon' import { TrailingItem } from '../TrailingItem' -import { DEFAULT_NO_ITEMS_TEXT } from './constants' +import { DEFAULT_NO_ITEMS_TEXT, VARIANT_TO_BG_CLASS_MAP, VARIANT_TO_HOVER_CLASS_MAP } from './constants' import TreeViewNodeContent from './TreeViewNodeContent' import { TreeHeading, TreeItem, TreeViewProps } from './types' @@ -27,6 +27,7 @@ const TreeView = ({ mode = 'navigation', flatNodeList: flatNodeListProp, getUpdateItemsRefMap: getUpdateItemsRefMapProp, + variant = 'primary', }: TreeViewProps) => { const { pathname } = useLocation() // Using this at root level @@ -132,7 +133,7 @@ const TreeView = ({ return (
@@ -169,13 +170,13 @@ const TreeView = ({ aria-level={depth + 1} >
{dividerPrefix}
@@ -290,7 +293,9 @@ const TreeView = ({ > {dividerPrefix} -
+
{node.as === 'link' ? ( {itemDivider} {content} diff --git a/src/Shared/Components/TreeView/constants.ts b/src/Shared/Components/TreeView/constants.ts index d39c65d46..dfa71357c 100644 --- a/src/Shared/Components/TreeView/constants.ts +++ b/src/Shared/Components/TreeView/constants.ts @@ -1 +1,13 @@ +import { TreeViewProps } from './types' + export const DEFAULT_NO_ITEMS_TEXT = 'No items found' + +export const VARIANT_TO_BG_CLASS_MAP: Record = { + primary: 'bg__primary', + secondary: 'bg__secondary', +} + +export const VARIANT_TO_HOVER_CLASS_MAP: Record = { + primary: 'bg__hover--opaque', + secondary: 'bg__hover-secondary--opaque', +} diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts index f86823612..050eba12d 100644 --- a/src/Shared/Components/TreeView/types.ts +++ b/src/Shared/Components/TreeView/types.ts @@ -84,6 +84,11 @@ export type TreeViewProps = { * @default 'navigation' */ mode?: 'navigation' | 'form' + /** + * If primary the background color will be bg__primary and bg__hover--opaque, if secondary the background color will be bg__secondary bg__hover-secondary--opaque. + * @default 'primary' + */ + variant?: 'primary' | 'secondary' } & ( | { isUncontrolled: true From e9355d70977162ad86b09f2fa949cc78bd3339a5 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Wed, 2 Jul 2025 15:42:19 +0530 Subject: [PATCH 35/90] feat: add scroll to selected item functionality in TreeView component --- .../TreeView/TreeView.component.tsx | 61 +++++++++++++++++-- src/Shared/Components/TreeView/types.ts | 6 ++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index 17368d696..9fd96fc27 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -2,11 +2,13 @@ import { SyntheticEvent, useMemo, useRef, useState } from 'react' import { NavLink, useLocation } from 'react-router-dom' import { AnimatePresence, motion } from 'framer-motion' +import { useEffectAfterMount } from '@Common/Helper' + import { Icon } from '../Icon' import { TrailingItem } from '../TrailingItem' import { DEFAULT_NO_ITEMS_TEXT, VARIANT_TO_BG_CLASS_MAP, VARIANT_TO_HOVER_CLASS_MAP } from './constants' import TreeViewNodeContent from './TreeViewNodeContent' -import { TreeHeading, TreeItem, TreeViewProps } from './types' +import { TreeHeading, TreeItem, TreeNode, TreeViewProps } from './types' import './TreeView.scss' @@ -28,10 +30,11 @@ const TreeView = ({ flatNodeList: flatNodeListProp, getUpdateItemsRefMap: getUpdateItemsRefMapProp, variant = 'primary', + shouldScrollOnChange = true, }: TreeViewProps) => { const { pathname } = useLocation() // Using this at root level - const rootItemRefs = useRef>({}) + const itemsRef = useRef>({}) // This will in actuality be used in first level of tree view since we are sending isUncontrolled prop as false to all the nested tree views const [currentLevelExpandedMap, setCurrentLevelExpandedMap] = useState>({}) @@ -41,6 +44,54 @@ const TreeView = ({ const fallbackTabIndex = mode === 'navigation' ? -1 : 0 + const findSelectedIdParentNodes = (node: TreeNode, onFindParentNode: (id: string) => void): boolean => { + if (node.id === selectedId) { + return true + } + + if (node.type === 'heading' && node.items?.length) { + let found = false + node.items.forEach((childNode) => { + if (findSelectedIdParentNodes(childNode, onFindParentNode)) { + found = true + onFindParentNode(node.id) + } + }) + return found + } + + return false + } + + const getSelectedIdParentNodes = (): string[] => { + const selectedIdParentNodes: string[] = [] + + nodes.forEach((node) => { + findSelectedIdParentNodes(node, (id: string) => { + selectedIdParentNodes.push(id) + }) + }) + return selectedIdParentNodes + } + + useEffectAfterMount(() => { + // To use this functionality one must make sure the expandedMap is set correctly + if (isFirstLevel && itemsRef.current && itemsRef.current[selectedId]) { + // In case of uncontrolled tree view, we will expand all the parent nodes of the selected item + if (isUncontrolled) { + const selectedIdParentNodes = getSelectedIdParentNodes() + setCurrentLevelExpandedMap((prev) => { + const newExpandedMap = { ...prev } + selectedIdParentNodes.forEach((id) => { + newExpandedMap[id] = true + }) + return newExpandedMap + }) + } + itemsRef.current[selectedId].scrollIntoView() + } + }, [shouldScrollOnChange, selectedId]) + const getToggleNode = (node: TreeHeading) => () => { if (isUncontrolled) { setCurrentLevelExpandedMap((prev) => ({ @@ -56,7 +107,7 @@ const TreeView = ({ if (!isFirstLevel) { throw new Error('getUpdateItemsRefMap should only be used at the first level of the tree view.') } - rootItemRefs.current[id] = el + itemsRef.current[id] = el } // will traverse all the nodes that are expanded and visible in the tree view @@ -105,12 +156,12 @@ const TreeView = ({ } if (key === 'ArrowDown' && currentIndex < flatNodeList.length - 1) { - rootItemRefs.current[flatNodeList[currentIndex + 1]]?.focus() + itemsRef.current[flatNodeList[currentIndex + 1]]?.focus() return } if (key === 'ArrowUp' && currentIndex > 0) { - rootItemRefs.current[flatNodeList[currentIndex - 1]]?.focus() + itemsRef.current[flatNodeList[currentIndex - 1]]?.focus() } } diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts index 050eba12d..162d479c0 100644 --- a/src/Shared/Components/TreeView/types.ts +++ b/src/Shared/Components/TreeView/types.ts @@ -89,6 +89,12 @@ export type TreeViewProps = { * @default 'primary' */ variant?: 'primary' | 'secondary' + /** + * If true, means on change of selectedId, the tree view will scroll to the selected item. + * Assumption: parents of the selected item are expanded. + * @default true + */ + shouldScrollOnChange?: boolean } & ( | { isUncontrolled: true From 5e6c67db59de3bfa4f3721871fd77bdd603512c5 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Wed, 2 Jul 2025 16:49:46 +0530 Subject: [PATCH 36/90] feat: add defaultExpandedMap prop to TreeView and update state initialization --- src/Shared/Components/TreeView/TreeView.component.tsx | 6 +++++- src/Shared/Components/TreeView/types.ts | 8 +++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index 9fd96fc27..86f506082 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -31,12 +31,13 @@ const TreeView = ({ getUpdateItemsRefMap: getUpdateItemsRefMapProp, variant = 'primary', shouldScrollOnChange = true, + defaultExpandedMap = {}, }: TreeViewProps) => { const { pathname } = useLocation() // Using this at root level const itemsRef = useRef>({}) // This will in actuality be used in first level of tree view since we are sending isUncontrolled prop as false to all the nested tree views - const [currentLevelExpandedMap, setCurrentLevelExpandedMap] = useState>({}) + const [currentLevelExpandedMap, setCurrentLevelExpandedMap] = useState>(defaultExpandedMap) const expandedMap = isUncontrolled ? currentLevelExpandedMap : expandedMapProp @@ -241,6 +242,7 @@ const TreeView = ({ ? getUpdateItemsRefMapProp(node.id) : getUpdateItemsRefMap(node.id) } + {...node.dataAttributes} > {depth > 0 && ( @@ -364,6 +366,7 @@ const TreeView = ({ ? getUpdateItemsRefMapProp(node.id) : getUpdateItemsRefMap(node.id) } + {...node.dataAttributes} > {itemDivider} {content} @@ -382,6 +385,7 @@ const TreeView = ({ : getUpdateItemsRefMap(node.id) } data-testid={`tree-view-item-${node.title}`} + {...node.dataAttributes} > {itemDivider} {content} diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts index 162d479c0..f1e959505 100644 --- a/src/Shared/Components/TreeView/types.ts +++ b/src/Shared/Components/TreeView/types.ts @@ -1,7 +1,7 @@ import { SyntheticEvent } from 'react' import { TooltipProps } from '@Common/Tooltip' -import { Never } from '@Shared/types' +import { DataAttributes, Never } from '@Shared/types' import { IconsProps } from '../Icon' import { TrailingItemProps } from '../TrailingItem' @@ -31,6 +31,7 @@ interface BaseNode { | (Never> & { customIcon?: JSX.Element }) ) trailingItem?: TrailingItemProps + dataAttributes?: Exclude } export interface TreeHeading extends BaseNode { @@ -98,6 +99,10 @@ export type TreeViewProps = { } & ( | { isUncontrolled: true + /** + * @default {} + */ + defaultExpandedMap?: Record expandedMap?: never onToggle?: never } @@ -105,6 +110,7 @@ export type TreeViewProps = { isUncontrolled?: false expandedMap: Record onToggle: (item: TreeHeading) => void + defaultExpandedMap?: never } ) & ( From d31006ec444c978ffc6d510a0d6d3025d0fd2064 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Wed, 2 Jul 2025 21:05:12 +0530 Subject: [PATCH 37/90] feat: remove isExpanded property from K8SObjectBaseType interface --- src/Pages/ResourceBrowser/ResourceBrowser.Types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Pages/ResourceBrowser/ResourceBrowser.Types.ts b/src/Pages/ResourceBrowser/ResourceBrowser.Types.ts index 107b3af21..f5069541c 100644 --- a/src/Pages/ResourceBrowser/ResourceBrowser.Types.ts +++ b/src/Pages/ResourceBrowser/ResourceBrowser.Types.ts @@ -38,7 +38,6 @@ export interface ApiResourceType { export interface K8SObjectBaseType { name: string - isExpanded: boolean } interface K8sRequestResourceIdentifierType { From 443d6e47ee0d2e952e5144bc6ab245d491650637 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Thu, 3 Jul 2025 13:09:39 +0530 Subject: [PATCH 38/90] feat: add generic support for DataAttributeType in TreeView component and related types --- .../TreeView/TreeView.component.tsx | 19 +++++++------ src/Shared/Components/TreeView/types.ts | 27 +++++++++++-------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index 86f506082..ecbe6e324 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -18,7 +18,7 @@ const Divider = () => ( ) -const TreeView = ({ +const TreeView = ({ nodes, isUncontrolled, expandedMap: expandedMapProp, @@ -32,7 +32,7 @@ const TreeView = ({ variant = 'primary', shouldScrollOnChange = true, defaultExpandedMap = {}, -}: TreeViewProps) => { +}: TreeViewProps) => { const { pathname } = useLocation() // Using this at root level const itemsRef = useRef>({}) @@ -45,7 +45,10 @@ const TreeView = ({ const fallbackTabIndex = mode === 'navigation' ? -1 : 0 - const findSelectedIdParentNodes = (node: TreeNode, onFindParentNode: (id: string) => void): boolean => { + const findSelectedIdParentNodes = ( + node: TreeNode, + onFindParentNode: (id: string) => void, + ): boolean => { if (node.id === selectedId) { return true } @@ -93,7 +96,7 @@ const TreeView = ({ } }, [shouldScrollOnChange, selectedId]) - const getToggleNode = (node: TreeHeading) => () => { + const getToggleNode = (node: TreeHeading) => () => { if (isUncontrolled) { setCurrentLevelExpandedMap((prev) => ({ ...prev, @@ -166,16 +169,16 @@ const TreeView = ({ } } - const commonClickHandler = (e: SyntheticEvent, node: TreeItem) => { + const commonClickHandler = (e: SyntheticEvent, node: TreeItem) => { node.onClick?.(e) onSelect?.(node) } - const getNodeItemButtonClick = (node: TreeItem) => (e: SyntheticEvent) => { + const getNodeItemButtonClick = (node: TreeItem) => (e: SyntheticEvent) => { commonClickHandler(e, node) } - const getNodeItemNavLinkClick = (node: TreeItem) => (e: SyntheticEvent) => { + const getNodeItemNavLinkClick = (node: TreeItem) => (e: SyntheticEvent) => { // Prevent navigation to the same page if (node.href === pathname) { e.preventDefault() @@ -242,7 +245,7 @@ const TreeView = ({ ? getUpdateItemsRefMapProp(node.id) : getUpdateItemsRefMap(node.id) } - {...node.dataAttributes} + {...(node.dataAttributes ? node.dataAttributes : {})} > {depth > 0 && ( diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts index f1e959505..587878555 100644 --- a/src/Shared/Components/TreeView/types.ts +++ b/src/Shared/Components/TreeView/types.ts @@ -7,9 +7,9 @@ import { IconsProps } from '../Icon' import { TrailingItemProps } from '../TrailingItem' // eslint-disable-next-line no-use-before-define -export type TreeNode = TreeHeading | TreeItem +export type TreeNode = TreeHeading | TreeItem -interface BaseNode { +type BaseNode = { id: string /** * The title of the list item. @@ -31,12 +31,17 @@ interface BaseNode { | (Never> & { customIcon?: JSX.Element }) ) trailingItem?: TrailingItemProps - dataAttributes?: Exclude -} +} & (DataAttributeType extends DataAttributes + ? { + dataAttributes?: DataAttributeType + } + : { + dataAttributes?: never + }) -export interface TreeHeading extends BaseNode { +export type TreeHeading = BaseNode & { type: 'heading' - items?: TreeNode[] + items?: TreeNode[] /** * Text to display when there are no items in the list. * @default DEFAULT_NO_ITEMS_TEXT @@ -44,7 +49,7 @@ export interface TreeHeading extends BaseNode { noItemsText?: string } -export type TreeItem = BaseNode & { +export type TreeItem = BaseNode & { type: 'item' /** * @default false @@ -75,10 +80,10 @@ export type TreeItem = BaseNode & { } ) -export type TreeViewProps = { - nodes: TreeNode[] +export type TreeViewProps = { + nodes: TreeNode[] selectedId?: string - onSelect?: (item: TreeItem) => void + onSelect?: (item: TreeItem) => void /** * If navigation mode, the tree view will provide navigation through keyboard actions and make the only selected item focusable. * If form mode, will leave the navigation to browser. @@ -109,7 +114,7 @@ export type TreeViewProps = { | { isUncontrolled?: false expandedMap: Record - onToggle: (item: TreeHeading) => void + onToggle: (item: TreeHeading) => void defaultExpandedMap?: never } ) & From f5469f01ac6540b3bb9d1e408557f2c3bbc279bf Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Thu, 3 Jul 2025 15:36:47 +0530 Subject: [PATCH 39/90] feat: enhance TreeView component with controlled state management and improved scrolling behavior --- .../TreeView/TreeView.component.tsx | 99 ++++++++++++------- src/Shared/Components/TreeView/types.ts | 71 ++++++------- 2 files changed, 98 insertions(+), 72 deletions(-) diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index ecbe6e324..3c848d105 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -1,9 +1,7 @@ -import { SyntheticEvent, useMemo, useRef, useState } from 'react' +import { SyntheticEvent, useEffect, useMemo, useRef, useState } from 'react' import { NavLink, useLocation } from 'react-router-dom' import { AnimatePresence, motion } from 'framer-motion' -import { useEffectAfterMount } from '@Common/Helper' - import { Icon } from '../Icon' import { TrailingItem } from '../TrailingItem' import { DEFAULT_NO_ITEMS_TEXT, VARIANT_TO_BG_CLASS_MAP, VARIANT_TO_HOVER_CLASS_MAP } from './constants' @@ -20,31 +18,21 @@ const Divider = () => ( const TreeView = ({ nodes, - isUncontrolled, - expandedMap: expandedMapProp, + isControlled = false, + expandedMap: expandedMapProp = {}, selectedId, - onToggle, + onToggle: onToggleProp, onSelect, depth = 0, mode = 'navigation', flatNodeList: flatNodeListProp, getUpdateItemsRefMap: getUpdateItemsRefMapProp, variant = 'primary', - shouldScrollOnChange = true, defaultExpandedMap = {}, }: TreeViewProps) => { const { pathname } = useLocation() - // Using this at root level - const itemsRef = useRef>({}) - // This will in actuality be used in first level of tree view since we are sending isUncontrolled prop as false to all the nested tree views - const [currentLevelExpandedMap, setCurrentLevelExpandedMap] = useState>(defaultExpandedMap) - - const expandedMap = isUncontrolled ? currentLevelExpandedMap : expandedMapProp - const isFirstLevel = depth === 0 - const fallbackTabIndex = mode === 'navigation' ? -1 : 0 - const findSelectedIdParentNodes = ( node: TreeNode, onFindParentNode: (id: string) => void, @@ -70,6 +58,10 @@ const TreeView = ({ const getSelectedIdParentNodes = (): string[] => { const selectedIdParentNodes: string[] = [] + if (!selectedId) { + return selectedIdParentNodes + } + nodes.forEach((node) => { findSelectedIdParentNodes(node, (id: string) => { selectedIdParentNodes.push(id) @@ -78,32 +70,63 @@ const TreeView = ({ return selectedIdParentNodes } - useEffectAfterMount(() => { - // To use this functionality one must make sure the expandedMap is set correctly + const getDefaultExpandedMap = (): Record => { + const defaultMap: Record = defaultExpandedMap + if (!selectedId) { + return defaultMap + } + + const selectedIdParentNodes = getSelectedIdParentNodes() + selectedIdParentNodes.forEach((id) => { + defaultMap[id] = true + }) + return defaultMap + } + + const [itemIdToScroll, setItemIdToScroll] = useState(null) + + // Using this at root level + const itemsRef = useRef>({}) + // This will in actuality be used in first level of tree view since we are sending isControlled prop as true to all the nested tree views + const [currentLevelExpandedMap, setCurrentLevelExpandedMap] = + useState>(getDefaultExpandedMap) + + const expandedMap = isControlled ? expandedMapProp : currentLevelExpandedMap + + const fallbackTabIndex = mode === 'navigation' ? -1 : 0 + + useEffect(() => { + // isControlled is false for first level of the tree view so should set the expanded map only from first level if (isFirstLevel && itemsRef.current && itemsRef.current[selectedId]) { - // In case of uncontrolled tree view, we will expand all the parent nodes of the selected item - if (isUncontrolled) { - const selectedIdParentNodes = getSelectedIdParentNodes() - setCurrentLevelExpandedMap((prev) => { - const newExpandedMap = { ...prev } - selectedIdParentNodes.forEach((id) => { - newExpandedMap[id] = true - }) - return newExpandedMap + const selectedIdParentNodes = getSelectedIdParentNodes() + setCurrentLevelExpandedMap((prev) => { + const newExpandedMap = { ...prev } + selectedIdParentNodes.forEach((id) => { + newExpandedMap[id] = true }) - } - itemsRef.current[selectedId].scrollIntoView() + return newExpandedMap + }) + + setItemIdToScroll(selectedId) } - }, [shouldScrollOnChange, selectedId]) + }, [selectedId]) const getToggleNode = (node: TreeHeading) => () => { - if (isUncontrolled) { + if (isControlled) { + onToggleProp(node) + } else { setCurrentLevelExpandedMap((prev) => ({ ...prev, [node.id]: !prev[node.id], })) + } + } + + const childItemsOnToggle = (node: TreeHeading) => { + if (isControlled) { + onToggleProp(node) } else { - onToggle(node) + getToggleNode(node)() } } @@ -111,6 +134,15 @@ const TreeView = ({ if (!isFirstLevel) { throw new Error('getUpdateItemsRefMap should only be used at the first level of the tree view.') } + + if (id === itemIdToScroll) { + el?.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center', + }) + setItemIdToScroll(null) + } itemsRef.current[id] = el } @@ -308,7 +340,8 @@ const TreeView = ({ key={nodeItem.id} expandedMap={expandedMap} selectedId={selectedId} - onToggle={onToggle} + isControlled + onToggle={childItemsOnToggle} onSelect={onSelect} nodes={[nodeItem]} depth={depth + 1} diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts index 587878555..fcae396c9 100644 --- a/src/Shared/Components/TreeView/types.ts +++ b/src/Shared/Components/TreeView/types.ts @@ -96,52 +96,45 @@ export type TreeViewProps = { */ variant?: 'primary' | 'secondary' /** - * If true, means on change of selectedId, the tree view will scroll to the selected item. - * Assumption: parents of the selected item are expanded. - * @default true + * @default {} */ - shouldScrollOnChange?: boolean -} & ( + defaultExpandedMap?: Record +} /** + * WARNING: For internal use only. + */ & ( | { - isUncontrolled: true + depth: number /** - * @default {} + * WARNING: For internal use only. + * Would pass this to item button/ref and store it in out ref map through this function. */ - defaultExpandedMap?: Record - expandedMap?: never - onToggle?: never + getUpdateItemsRefMap: (id: string) => (element: HTMLButtonElement | HTMLAnchorElement) => void + + /** + * WARNING: For internal use only. + * List of all nodes visible in tree view for keyboard navigation. + */ + flatNodeList: string[] + + /** + * Would be called when the user toggles a heading. + */ + onToggle: (item: TreeHeading) => void + /** + * Map of id to whether the item is expanded or not. + */ + expandedMap: Record + isControlled: true } | { - isUncontrolled?: false - expandedMap: Record - onToggle: (item: TreeHeading) => void - defaultExpandedMap?: never + depth?: never + getUpdateItemsRefMap?: never + flatNodeList?: never + onToggle?: never + expandedMap?: never + isControlled?: false } -) & - ( - | { - /** - * WARNING: For internal use only. - */ - depth: number - /** - * WARNING: For internal use only. - * Would pass this to item button/ref and store it in out ref map through this function. - */ - getUpdateItemsRefMap: (id: string) => (element: HTMLButtonElement | HTMLAnchorElement) => void - - /** - * WARNING: For internal use only. - * List of all nodes visible in tree view for keyboard navigation. - */ - flatNodeList: string[] - } - | { - depth?: never - getUpdateItemsRefMap?: never - flatNodeList?: never - } - ) +) export interface TreeViewNodeContentProps extends Pick { From 8ee4444b2291928d58691c5076359fe2ef6f3d3e Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Wed, 2 Jul 2025 11:36:24 +0530 Subject: [PATCH 40/90] feat: add ic-list-bullets icon and update PageHeader to conditionally render headerName --- src/Assets/IconV2/ic-list-bullets.svg | 4 ++++ src/Shared/Components/Header/PageHeader.tsx | 8 +++++--- src/Shared/Components/Icon/Icon.tsx | 2 ++ 3 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 src/Assets/IconV2/ic-list-bullets.svg diff --git a/src/Assets/IconV2/ic-list-bullets.svg b/src/Assets/IconV2/ic-list-bullets.svg new file mode 100644 index 000000000..305aeb0ae --- /dev/null +++ b/src/Assets/IconV2/ic-list-bullets.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/Shared/Components/Header/PageHeader.tsx b/src/Shared/Components/Header/PageHeader.tsx index 08783c01b..e28da01a0 100644 --- a/src/Shared/Components/Header/PageHeader.tsx +++ b/src/Shared/Components/Header/PageHeader.tsx @@ -178,9 +178,11 @@ const PageHeader = ({ >

- - {headerName} - + {headerName && ( + + {headerName} + + )} {additionalHeaderInfo && additionalHeaderInfo()} {isBreadcrumbs && breadCrumbs()} {tippyProps && diff --git a/src/Shared/Components/Icon/Icon.tsx b/src/Shared/Components/Icon/Icon.tsx index 1d29db585..6d279c22b 100644 --- a/src/Shared/Components/Icon/Icon.tsx +++ b/src/Shared/Components/Icon/Icon.tsx @@ -131,6 +131,7 @@ import { ReactComponent as ICLego } from '@IconsV2/ic-lego.svg' import { ReactComponent as ICLightning } from '@IconsV2/ic-lightning.svg' import { ReactComponent as ICLightningFill } from '@IconsV2/ic-lightning-fill.svg' import { ReactComponent as ICLinkedBuildColor } from '@IconsV2/ic-linked-build-color.svg' +import { ReactComponent as ICListBullets } from '@IconsV2/ic-list-bullets.svg' import { ReactComponent as ICLivspace } from '@IconsV2/ic-livspace.svg' import { ReactComponent as ICLogout } from '@IconsV2/ic-logout.svg' import { ReactComponent as ICLogs } from '@IconsV2/ic-logs.svg' @@ -349,6 +350,7 @@ export const iconMap = { 'ic-lightning-fill': ICLightningFill, 'ic-lightning': ICLightning, 'ic-linked-build-color': ICLinkedBuildColor, + 'ic-list-bullets': ICListBullets, 'ic-livspace': ICLivspace, 'ic-logout': ICLogout, 'ic-logs': ICLogs, From 8e755ddd8a88bf15658bec4a0bed332e0436caab Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Thu, 3 Jul 2025 11:31:54 +0530 Subject: [PATCH 41/90] feat: add PortalContainer component for rendering portals in the DOM --- .../PortalContainer/PortalContainer.tsx | 47 +++++++++++++++++++ .../Components/PortalContainer/index.ts | 1 + src/Shared/Components/index.ts | 1 + 3 files changed, 49 insertions(+) create mode 100644 src/Shared/Components/PortalContainer/PortalContainer.tsx create mode 100644 src/Shared/Components/PortalContainer/index.ts diff --git a/src/Shared/Components/PortalContainer/PortalContainer.tsx b/src/Shared/Components/PortalContainer/PortalContainer.tsx new file mode 100644 index 000000000..554d0f6c9 --- /dev/null +++ b/src/Shared/Components/PortalContainer/PortalContainer.tsx @@ -0,0 +1,47 @@ +import { PropsWithChildren, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' + +import { getUniqueId } from '@Shared/Helpers' + +export const PortalContainer = ({ + condition = true, + portalParentId, + children, +}: PropsWithChildren<{ condition?: boolean; portalParentId: string }>) => { + // STATES + const [targetElement, setTargetElement] = useState(null) + + // REFS + const portalContainerIdRef = useRef(`portal-container-${getUniqueId()}`) + + useLayoutEffect(() => { + const portalParent = document.getElementById(portalParentId) + let element = document.getElementById(portalContainerIdRef.current) + let systemCreated = false + + if (condition && portalParent) { + // If the portal container doesn't exist, create and append it to the DOM + if (!element) { + systemCreated = true + + const portalContainer = document.createElement('div') + portalContainer.setAttribute('id', portalContainerIdRef.current) + portalParent.appendChild(portalContainer) + + element = portalContainer + } + + // Set the container element as the portal's render target + setTargetElement(element) + } + + return () => { + // Clean up only if we created the element + if (systemCreated && portalParent) { + portalParent.removeChild(element) + } + } + }, [condition]) + + return targetElement ? createPortal(children, targetElement) : null +} diff --git a/src/Shared/Components/PortalContainer/index.ts b/src/Shared/Components/PortalContainer/index.ts new file mode 100644 index 000000000..546fa304f --- /dev/null +++ b/src/Shared/Components/PortalContainer/index.ts @@ -0,0 +1 @@ +export * from './PortalContainer' diff --git a/src/Shared/Components/index.ts b/src/Shared/Components/index.ts index 268c670b8..e41622b97 100644 --- a/src/Shared/Components/index.ts +++ b/src/Shared/Components/index.ts @@ -82,6 +82,7 @@ export * from './NumbersCount' export * from './PhoneInput' export * from './Plugin' export * from './Popover' +export * from './PortalContainer' export * from './ProgressBar' export { default as QRCode } from './QRCode' export * from './ReactSelect' From 6828992c38d63950291e3773d26d43e162c704f9 Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Thu, 3 Jul 2025 12:42:38 +0530 Subject: [PATCH 42/90] feat: add generate-illustration script --- .husky/pre-commit | 21 +++- package.json | 3 +- scripts/generate-illustration.cjs | 101 ++++++++++++++++++ .../Illustration/illustration-code.webp | Bin 0 -> 31632 bytes .../Components/Illustration/Illustration.tsx | 25 +++++ .../Illustration/IllustrationBase.tsx | 37 +++++++ src/Shared/Components/Illustration/index.ts | 17 +++ src/Shared/Components/Illustration/types.ts | 44 ++++++++ src/Shared/Components/index.ts | 1 + tsconfig.json | 1 + 10 files changed, 247 insertions(+), 3 deletions(-) create mode 100644 scripts/generate-illustration.cjs create mode 100644 src/Assets/Illustration/illustration-code.webp create mode 100644 src/Shared/Components/Illustration/Illustration.tsx create mode 100644 src/Shared/Components/Illustration/IllustrationBase.tsx create mode 100644 src/Shared/Components/Illustration/index.ts create mode 100644 src/Shared/Components/Illustration/types.ts diff --git a/.husky/pre-commit b/.husky/pre-commit index 929d7364e..b40501eae 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -20,9 +20,9 @@ echo "Running pre-commit hook..." # Check for changes in the Icon folder -CHANGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR | grep -E 'IconV2/|generate-icon.cjs' || true) +ICON_CHANGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR | grep -E 'IconV2/|generate-icon.cjs' || true) -if [ -n "$CHANGED_FILES" ]; then +if [ -n "$ICON_CHANGED_FILES" ]; then echo "Changes detected in the Icon folder or icon generation script. Running icon generation script..." if ! npm run generate-icon; then @@ -36,6 +36,23 @@ else echo "No changes in the IconsV2 folder. Skipping icon generation." fi +# Check for changes in the Illustration folder +ILLUSTRATION_CHANGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR | grep -E 'Illustration/|generate-illustration.cjs' || true) + +if [ -n "$ILLUSTRATION_CHANGED_FILES" ]; then + echo "Changes detected in the Illustration folder or illustration generation script. Running illustration generation script..." + + if ! npm run generate-illustration; then + echo "Error: Illustration generation script failed." + exit 1 + fi + + echo "Illustration.tsx updated. Adding to commit." + git add src/Shared/Components/Illustration/Illustration.tsx +else + echo "No changes in the Illustration folder. Skipping illustration generation." +fi + # TypeScript check if ! npx tsc --noEmit; then echo "Error: TypeScript check failed." diff --git a/package.json b/package.json index ab1e3f544..d4d97cb25 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "preview": "vite preview", "lint-staged": "lint-staged", "postinstall": "patch-package", - "generate-icon": "node ./scripts/generate-icon.cjs" + "generate-icon": "node ./scripts/generate-icon.cjs", + "generate-illustration": "node ./scripts/generate-illustration.cjs" }, "devDependencies": { "@esbuild-plugins/node-globals-polyfill": "0.2.3", diff --git a/scripts/generate-illustration.cjs b/scripts/generate-illustration.cjs new file mode 100644 index 000000000..4c4793234 --- /dev/null +++ b/scripts/generate-illustration.cjs @@ -0,0 +1,101 @@ +const fs = require('fs') +const path = require('path') +const { execFile } = require('child_process') + +// Base path relative to the current script +const basePath = path.resolve(__dirname, '../src') + +// Directory containing SVG, Webp illustrations and the output file +const illustrationsDir = path.join(basePath, 'Assets', 'Illustration') +const outputFile = path.join(basePath, 'Shared', 'Components', 'Illustration', 'Illustration.tsx') + +const runESLint = (filePath) => { + execFile('npx', ['eslint', filePath, '--fix'], (error, stdout, stderr) => { + if (error) { + console.error(`Error running ESLint: ${error.message}`) + return + } + if (stderr) { + console.error(`ESLint stderr: ${stderr}`) + } + if (stdout) { + console.log(`ESLint output:\n${stdout}`) + } + console.log('ESLint completed successfully.') + }) +} + +const generateIllustrationComponent = () => { + // Read all files in the illustrations directory + const files = fs.readdirSync(illustrationsDir) + + // Filter for SVG files + const svgFiles = files.filter((file) => file.endsWith('.svg')) + // Filter for WEBP files + const webpFiles = files.filter((file) => file.endsWith('.webp')) + + // Generate import statements and the illustration map + const imports = [] + const illustrationMapEntries = [] + + svgFiles.forEach((file) => { + // Remove the .svg extension + const illustrationName = path.basename(file, '.svg') + // Convert illustration-name to IllustrationName for importName + const importName = illustrationName.replace(/(^\w|-\w)/g, (match) => match.replace('-', '').toUpperCase()) + // Push imports statement + imports.push(`import { ReactComponent as ${importName} } from '@Illustrations/${file}'`) + // Push illustrations to illustrationMap + illustrationMapEntries.push(`["${illustrationName}"]: ${importName},`) + }) + + webpFiles.forEach((file) => { + // Remove the .webp extension + const illustrationName = path.basename(file, '.webp') + // Convert illustration-name to IllustrationName for importName + const importName = illustrationName.replace(/(^\w|-\w)/g, (match) => match.replace('-', '').toUpperCase()) + // Push imports statement + imports.push(`import ${importName} from '@Illustrations/${file}'`) + // Push illustrations to illustrationMap + illustrationMapEntries.push(`["${illustrationName}"]: ${importName},`) + }) + + // Generate the Illustration.tsx content + const content = ` + // NOTE: This file is auto-generated. Do not edit directly. Run the script \`npm run generate-illustration\` to update. + + ${imports.join('\n')} + + // eslint-disable-next-line no-restricted-imports + import { IllustrationBase } from './IllustrationBase'; + import { IllustrationBaseProps } from './types'; + + export const illustrationMap = { + ${illustrationMapEntries.join('\n')} + }; + + export type IllustrationName = keyof typeof illustrationMap; + + export interface IllustrationProps extends Omit { + /** + * The name of the illustration to render. + * @note The component will return either an img component or an SVG component based on the type of illustration (.svg, .webp) + */ + name: keyof typeof illustrationMap; + } + + export const Illustration = (props: IllustrationProps) => { + return ; + }; +` + + // Write the content to the Illustration.tsx file + fs.writeFileSync(outputFile, content.trim(), 'utf-8') + console.log(`Illustration component file generated at: ${outputFile}`) + + // Run ESLint on the generated file + runESLint(outputFile) +} + +// Run the script +generateIllustrationComponent() diff --git a/src/Assets/Illustration/illustration-code.webp b/src/Assets/Illustration/illustration-code.webp new file mode 100644 index 0000000000000000000000000000000000000000..9fbbc7af14632db522b63380a6742653a02e4a21 GIT binary patch literal 31632 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 Fe*twE!i4|; literal 0 HcmV?d00001 diff --git a/src/Shared/Components/Illustration/Illustration.tsx b/src/Shared/Components/Illustration/Illustration.tsx new file mode 100644 index 000000000..7a86b7d8c --- /dev/null +++ b/src/Shared/Components/Illustration/Illustration.tsx @@ -0,0 +1,25 @@ +// NOTE: This file is auto-generated. Do not edit directly. Run the script `npm run generate-illustration` to update. + +import IllustrationCode from '@Illustrations/illustration-code.webp' + +// eslint-disable-next-line no-restricted-imports +import { IllustrationBase } from './IllustrationBase' +import { IllustrationBaseProps } from './types' + +export const illustrationMap = { + 'illustration-code': IllustrationCode, +} + +export type IllustrationName = keyof typeof illustrationMap + +export interface IllustrationProps extends Omit { + /** + * The name of the illustration to render. + * @note The component will return either an img component or an SVG component based on the type of illustration (.svg, .webp) + */ + name: keyof typeof illustrationMap +} + +export const Illustration = (props: IllustrationProps) => ( + +) diff --git a/src/Shared/Components/Illustration/IllustrationBase.tsx b/src/Shared/Components/Illustration/IllustrationBase.tsx new file mode 100644 index 000000000..7b1bc834c --- /dev/null +++ b/src/Shared/Components/Illustration/IllustrationBase.tsx @@ -0,0 +1,37 @@ +/* + * 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 { IllustrationBaseProps } from './types' + +export const IllustrationBase = ({ + name, + illustrationMap, + dataTestId, + imageProps, + svgProps, +}: IllustrationBaseProps) => { + const IllustrationComponent = illustrationMap[name] + + if (!IllustrationComponent) { + throw new Error(`Illustration with name "${name}" does not exist.`) + } + + if (typeof IllustrationComponent === 'string') { + return {name} + } + + return +} diff --git a/src/Shared/Components/Illustration/index.ts b/src/Shared/Components/Illustration/index.ts new file mode 100644 index 000000000..4a0b689b1 --- /dev/null +++ b/src/Shared/Components/Illustration/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export * from './Illustration' diff --git a/src/Shared/Components/Illustration/types.ts b/src/Shared/Components/Illustration/types.ts new file mode 100644 index 000000000..5ceb388b0 --- /dev/null +++ b/src/Shared/Components/Illustration/types.ts @@ -0,0 +1,44 @@ +/* + * 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 { FC, SVGProps } from 'react' + +type IllustrationMap = Record> | string> + +export interface IllustrationBaseProps { + /** + * The name of the illustration to render. + * @note The component will return either an img component or an SVG component based on the type of illustration (.svg, .webp) + */ + name: keyof IllustrationMap + /** + * A map containing all available illustrations. + */ + illustrationMap: IllustrationMap + /** + * A unique identifier for testing purposes, typically used in test automation. + */ + dataTestId?: string + /** + * Additional props to pass to the image element. + * @note This prop is only used when the illustration is a .webp image. + */ + imageProps?: Omit, 'src'> + /** + * Additional props to pass to the SVG element. + */ + svgProps?: React.SVGProps +} diff --git a/src/Shared/Components/index.ts b/src/Shared/Components/index.ts index e41622b97..f0aadb4d5 100644 --- a/src/Shared/Components/index.ts +++ b/src/Shared/Components/index.ts @@ -65,6 +65,7 @@ export * from './GraphVisualizer' export * from './Header' export * from './Icon' export * from './IframeContainer' +export * from './Illustration' export * from './ImageCard' export * from './ImageCardAccordion' export * from './ImageChipCell' diff --git a/tsconfig.json b/tsconfig.json index 8ab4b2801..df774296d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "paths": { "@Icons/*": ["./src/Assets/Icon/*"], "@IconsV2/*": ["./src/Assets/IconV2/*"], + "@Illustrations/*": ["./src/Assets/Illustration/*"], "@Sounds/*": ["./src/Assets/Sounds/*"], "@Images/*": ["./src/Assets/Img/*"], "@Common/*": ["./src/Common/*"], From b0c0f3a8621b7e7d68551f596576c247b4876cef Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Thu, 3 Jul 2025 12:57:58 +0530 Subject: [PATCH 43/90] feat: add new illustrations --- .../illustration-man-on-rocket.webp | Bin 0 -> 44072 bytes .../Illustration/illustration-no-result.webp | Bin 0 -> 40256 bytes .../Components/Illustration/Illustration.tsx | 4 ++++ 3 files changed, 4 insertions(+) create mode 100644 src/Assets/Illustration/illustration-man-on-rocket.webp create mode 100644 src/Assets/Illustration/illustration-no-result.webp diff --git a/src/Assets/Illustration/illustration-man-on-rocket.webp b/src/Assets/Illustration/illustration-man-on-rocket.webp new file mode 100644 index 0000000000000000000000000000000000000000..8edd9f0b2baa5b3445fd2a3b50253f1b17fa1fa4 GIT binary patch literal 44072 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 literal 0 HcmV?d00001 diff --git a/src/Assets/Illustration/illustration-no-result.webp b/src/Assets/Illustration/illustration-no-result.webp new file mode 100644 index 0000000000000000000000000000000000000000..445a663bfdb3799b52fa9144e45ec41b3c6f9213 GIT binary patch literal 40256 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 j-?a?AvCnL9002nO)D{2$00000000000000000000+|kHY literal 0 HcmV?d00001 diff --git a/src/Shared/Components/Illustration/Illustration.tsx b/src/Shared/Components/Illustration/Illustration.tsx index 7a86b7d8c..b2b78f6bc 100644 --- a/src/Shared/Components/Illustration/Illustration.tsx +++ b/src/Shared/Components/Illustration/Illustration.tsx @@ -1,6 +1,8 @@ // NOTE: This file is auto-generated. Do not edit directly. Run the script `npm run generate-illustration` to update. import IllustrationCode from '@Illustrations/illustration-code.webp' +import IllustrationManOnRocket from '@Illustrations/illustration-man-on-rocket.webp' +import IllustrationNoResult from '@Illustrations/illustration-no-result.webp' // eslint-disable-next-line no-restricted-imports import { IllustrationBase } from './IllustrationBase' @@ -8,6 +10,8 @@ import { IllustrationBaseProps } from './types' export const illustrationMap = { 'illustration-code': IllustrationCode, + 'illustration-man-on-rocket': IllustrationManOnRocket, + 'illustration-no-result': IllustrationNoResult, } export type IllustrationName = keyof typeof illustrationMap From feb81cbf101ba098465624b88ea683f23e00e0f1 Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Thu, 3 Jul 2025 12:58:43 +0530 Subject: [PATCH 44/90] feat: add illustration support to GenericEmptyState component --- src/Common/EmptyState/GenericEmptyState.tsx | 20 ++++++++++++++++++-- src/Common/Types.ts | 2 ++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/Common/EmptyState/GenericEmptyState.tsx b/src/Common/EmptyState/GenericEmptyState.tsx index 3b1ea4a12..8e958689e 100644 --- a/src/Common/EmptyState/GenericEmptyState.tsx +++ b/src/Common/EmptyState/GenericEmptyState.tsx @@ -14,6 +14,8 @@ * limitations under the License. */ +import { Illustration } from '@Shared/Components' + import AppNotDeployed from '../../Assets/Img/app-not-deployed.svg' import { GenericEmptyStateType, ImageType } from '../Types' @@ -35,6 +37,7 @@ const GenericEmptyState = ({ layout = 'column', contentClassName = '', imageStyles = {}, + illustrationName, }: GenericEmptyStateType): JSX.Element => { const isRowLayout = layout === 'row' @@ -54,7 +57,20 @@ const GenericEmptyState = ({ data-testid="generic-empty-state" > {!SvgImage ? ( - !noImage && ( + !noImage && + (illustrationName ? ( + + ) : ( empty-state - ) + )) ) : ( export interface GenericEmptyStateType { title: ReactNode + illustrationName?: IllustrationName image? classname?: string subTitle?: ReactNode From 675159ffbe7828d8c779c58c62f62b1f0a5ffd66 Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Thu, 3 Jul 2025 15:10:07 +0530 Subject: [PATCH 45/90] chore: rename illustrations --- .../{illustration-code.webp => img-code.webp} | Bin ...on-man-on-rocket.webp => img-man-on-rocket.webp} | Bin ...lustration-no-result.webp => img-no-result.webp} | Bin src/Shared/Components/Illustration/Illustration.tsx | 12 ++++++------ 4 files changed, 6 insertions(+), 6 deletions(-) rename src/Assets/Illustration/{illustration-code.webp => img-code.webp} (100%) rename src/Assets/Illustration/{illustration-man-on-rocket.webp => img-man-on-rocket.webp} (100%) rename src/Assets/Illustration/{illustration-no-result.webp => img-no-result.webp} (100%) diff --git a/src/Assets/Illustration/illustration-code.webp b/src/Assets/Illustration/img-code.webp similarity index 100% rename from src/Assets/Illustration/illustration-code.webp rename to src/Assets/Illustration/img-code.webp diff --git a/src/Assets/Illustration/illustration-man-on-rocket.webp b/src/Assets/Illustration/img-man-on-rocket.webp similarity index 100% rename from src/Assets/Illustration/illustration-man-on-rocket.webp rename to src/Assets/Illustration/img-man-on-rocket.webp diff --git a/src/Assets/Illustration/illustration-no-result.webp b/src/Assets/Illustration/img-no-result.webp similarity index 100% rename from src/Assets/Illustration/illustration-no-result.webp rename to src/Assets/Illustration/img-no-result.webp diff --git a/src/Shared/Components/Illustration/Illustration.tsx b/src/Shared/Components/Illustration/Illustration.tsx index b2b78f6bc..c37385b11 100644 --- a/src/Shared/Components/Illustration/Illustration.tsx +++ b/src/Shared/Components/Illustration/Illustration.tsx @@ -1,17 +1,17 @@ // NOTE: This file is auto-generated. Do not edit directly. Run the script `npm run generate-illustration` to update. -import IllustrationCode from '@Illustrations/illustration-code.webp' -import IllustrationManOnRocket from '@Illustrations/illustration-man-on-rocket.webp' -import IllustrationNoResult from '@Illustrations/illustration-no-result.webp' +import ImgCode from '@Illustrations/img-code.webp' +import ImgManOnRocket from '@Illustrations/img-man-on-rocket.webp' +import ImgNoResult from '@Illustrations/img-no-result.webp' // eslint-disable-next-line no-restricted-imports import { IllustrationBase } from './IllustrationBase' import { IllustrationBaseProps } from './types' export const illustrationMap = { - 'illustration-code': IllustrationCode, - 'illustration-man-on-rocket': IllustrationManOnRocket, - 'illustration-no-result': IllustrationNoResult, + 'img-code': ImgCode, + 'img-man-on-rocket': ImgManOnRocket, + 'img-no-result': ImgNoResult, } export type IllustrationName = keyof typeof illustrationMap From 841056a9e3b582d4e8f0d5425d0cc0e0af50d81f Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Fri, 4 Jul 2025 00:54:01 +0530 Subject: [PATCH 46/90] fix: handle undefined dataAttributes in TreeView component to prevent potential errors --- .../Security/SecurityModal/config/Sidebar.ts | 35 ++++++++++++------- .../TreeView/TreeView.component.tsx | 4 +-- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/Shared/Components/Security/SecurityModal/config/Sidebar.ts b/src/Shared/Components/Security/SecurityModal/config/Sidebar.ts index 9f37f6f7a..b5b225688 100644 --- a/src/Shared/Components/Security/SecurityModal/config/Sidebar.ts +++ b/src/Shared/Components/Security/SecurityModal/config/Sidebar.ts @@ -144,9 +144,9 @@ export const getSidebarData = ({ ] satisfies TreeViewProps['nodes'] // Not implementing complete dfs since its not nested, traversing - nodes.forEach((node) => { + const parsedNodes = nodes.map<(typeof nodes)[number]>((node) => { if (node.type === 'heading') { - node.items.forEach((item) => { + const items = node.items.map<(typeof node.items)[number]>((item) => { if (item.type === 'heading') { throw new Error( 'Broken assumption: Heading should not have nested headings in security sidebar, Please implement dfs based handling for nested headings in security sidebar', @@ -166,19 +166,28 @@ export const getSidebarData = ({ return acc + severities[key] }, 0) - // eslint-disable-next-line no-param-reassign - item.trailingItem = threatCount - ? { - type: 'counter', - config: { - value: threatCount, - isSelected: selectedId === item.id, - }, - } - : null + return { + ...item, + trailingItem: threatCount + ? { + type: 'counter', + config: { + value: threatCount, + isSelected: selectedId === item.id, + }, + } + : null, + } }) + + return { + ...node, + items, + } } + + return node }) - return nodes + return parsedNodes } diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index 3c848d105..3aaddd479 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -402,7 +402,7 @@ const TreeView = ({ ? getUpdateItemsRefMapProp(node.id) : getUpdateItemsRefMap(node.id) } - {...node.dataAttributes} + {...(node.dataAttributes ? node.dataAttributes : {})} > {itemDivider} {content} @@ -421,7 +421,7 @@ const TreeView = ({ : getUpdateItemsRefMap(node.id) } data-testid={`tree-view-item-${node.title}`} - {...node.dataAttributes} + {...(node.dataAttributes ? node.dataAttributes : {})} > {itemDivider} {content} From 4b439defef11415146d4edaf6309d2c56ddcd0a7 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Fri, 4 Jul 2025 13:39:55 +0530 Subject: [PATCH 47/90] feat: enhance TreeView component with improved type definitions and render logic for node items --- .../TreeView/TreeView.component.tsx | 112 ++++++++++-------- src/Shared/Components/TreeView/types.ts | 72 ++++++++++- 2 files changed, 131 insertions(+), 53 deletions(-) diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index 3aaddd479..912801e37 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -6,7 +6,7 @@ import { Icon } from '../Icon' import { TrailingItem } from '../TrailingItem' import { DEFAULT_NO_ITEMS_TEXT, VARIANT_TO_BG_CLASS_MAP, VARIANT_TO_HOVER_CLASS_MAP } from './constants' import TreeViewNodeContent from './TreeViewNodeContent' -import { TreeHeading, TreeItem, TreeNode, TreeViewProps } from './types' +import { NodeElementType, TreeHeading, TreeItem, TreeNode, TreeViewProps } from './types' import './TreeView.scss' @@ -86,7 +86,7 @@ const TreeView = ({ const [itemIdToScroll, setItemIdToScroll] = useState(null) // Using this at root level - const itemsRef = useRef>({}) + const itemsRef = useRef>({}) // This will in actuality be used in first level of tree view since we are sending isControlled prop as true to all the nested tree views const [currentLevelExpandedMap, setCurrentLevelExpandedMap] = useState>(getDefaultExpandedMap) @@ -130,7 +130,7 @@ const TreeView = ({ } } - const getUpdateItemsRefMap = (id: string) => (el: HTMLButtonElement | HTMLAnchorElement) => { + const getUpdateItemsRefMap = (id: string) => (el: NodeElementType) => { if (!isFirstLevel) { throw new Error('getUpdateItemsRefMap should only be used at the first level of the tree view.') } @@ -218,6 +218,66 @@ const TreeView = ({ commonClickHandler(e, node) } + const renderNodeItemAction = ( + node: TreeItem, + itemDivider: JSX.Element, + content: JSX.Element, + ) => { + const isSelected = selectedId === node.id + const baseClass = + 'dc__transparent p-0-imp flexbox dc__align-start flex-grow-1 tree-view__container--item dc__select-text' + + if (node.as === 'div') { + return ( +
+ {itemDivider} + {content} +
+ ) + } + + if (node.as === 'link') { + return ( + + {itemDivider} + {content} + + ) + } + + return ( + + ) + } + return (
({ ) } - const baseClass = - 'dc__transparent p-0-imp flexbox dc__align-start flex-grow-1 tree-view__container--item dc__select-text' - const itemDivider = depth > 0 ? ( @@ -385,48 +442,7 @@ const TreeView = ({
- {node.as === 'link' ? ( - - {itemDivider} - {content} - - ) : ( - - )} + {renderNodeItemAction(node, itemDivider, content)} {node.trailingItem && (
diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts index fcae396c9..37ae6c414 100644 --- a/src/Shared/Components/TreeView/types.ts +++ b/src/Shared/Components/TreeView/types.ts @@ -9,6 +9,20 @@ import { TrailingItemProps } from '../TrailingItem' // eslint-disable-next-line no-use-before-define export type TreeNode = TreeHeading | TreeItem +/** + * Represents a base node structure for a tree view component. + * + * @typeParam DataAttributeType - The type for data attributes, defaults to null. If it extends `DataAttributes`, the node can have `dataAttributes` of this type. + * + * @property id - Unique identifier for the node. + * @property title - The main title text displayed for the node. + * @property subtitle - Optional subtitle text for the node. + * @property customTooltipConfig - Optional configuration for a custom tooltip. + * @property strikeThrough - If true, the title will be rendered with a line-through style. + * @property startIconConfig - Optional configuration for a start icon, which can be either a standard icon (with `name` and `color`) or a custom JSX element. + * @property trailingItem - Optional configuration for a trailing item (e.g., button, icon) displayed at the end of the node. + * @property dataAttributes - Optional data attributes, present only if `DataAttributeType` extends `DataAttributes`. + */ type BaseNode = { id: string /** @@ -49,13 +63,32 @@ export type TreeHeading = BaseNode noItemsText?: string } +export type NodeElementType = HTMLDivElement | HTMLButtonElement | HTMLAnchorElement + +/** + * Represents an item node in a tree structure, supporting different rendering modes. + * + * @template DataAttributeType - The type for custom data attributes, defaults to null. + * + * A `TreeItem` can be rendered as a button, link, or div, each with its own set of properties: + * - When `as` is `'button'` (default), it can have an `onClick` handler. + * - When `as` is `'link'`, it requires an `href`, can have an `onClick` handler, and supports clearing query parameters on navigation. + * - When `as` is `'div'`, it is a non-interactive container. + * + * @property {'item'} type - Identifies the node as an item. + * @property {boolean} [isDisabled=false] - If true, disables the item. + * @property {'button' | 'link' | 'div'} [as] - Determines the rendered element type. + * @property {(e: SyntheticEvent) => void} [onClick] - Callback for click events (button or link only). + * @property {string} [href] - The navigation URL (link only). + * @property {boolean} [clearQueryParamsOnNavigation=false] - If true, clears query parameters during navigation (link only). + */ export type TreeItem = BaseNode & { type: 'item' /** * @default false */ isDisabled?: boolean -} & ( // Should we add as `div` as well? +} & ( | { as?: 'button' /** @@ -78,8 +111,40 @@ export type TreeItem = BaseNode & { */ clearQueryParamsOnNavigation?: boolean } + | { + as: 'div' + href?: never + onClick?: never + clearQueryParamsOnNavigation?: never + } ) +/** + * Props for the TreeView component. + * + * @template DataAttributeType - The type for data attributes associated with tree nodes. + * + * @property nodes - An array of tree nodes to be rendered in the tree view. + * @property selectedId - (Optional) The ID of the currently selected tree item. + * @property onSelect - (Optional) Callback invoked when a tree item is selected. + * @property mode - (Optional) Determines the navigation mode of the tree view. + * - `'navigation'`: Enables keyboard navigation and focuses only the selected item. + * - `'form'`: Leaves navigation to the browser. + * - @default 'navigation' + * @property variant - (Optional) Visual variant of the tree view. + * - `'primary'`: Uses primary background and hover colors. + * - `'secondary'`: Uses secondary background and hover colors. + * - @default 'primary' + * @property defaultExpandedMap - (Optional) Initial map of node IDs to their expanded state. + * - @default {} + * + * @property depth - (Internal use only) The current depth level in the tree. + * @property getUpdateItemsRefMap - (Internal use only) Function to update the ref map for item buttons/anchors. + * @property flatNodeList - (Internal use only) List of all visible node IDs for keyboard navigation. + * @property onToggle - (Internal use only) Callback invoked when a tree heading is toggled. + * @property expandedMap - (Internal use only) Map of node IDs to their expanded state. + * @property isControlled - (Internal use only) Indicates if the tree view is controlled. + */ export type TreeViewProps = { nodes: TreeNode[] selectedId?: string @@ -105,17 +170,14 @@ export type TreeViewProps = { | { depth: number /** - * WARNING: For internal use only. * Would pass this to item button/ref and store it in out ref map through this function. */ - getUpdateItemsRefMap: (id: string) => (element: HTMLButtonElement | HTMLAnchorElement) => void + getUpdateItemsRefMap: (id: string) => (element: NodeElementType) => void /** - * WARNING: For internal use only. * List of all nodes visible in tree view for keyboard navigation. */ flatNodeList: string[] - /** * Would be called when the user toggles a heading. */ From 761fc26583a15f929f5641fd1c7a2df46e146701 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Fri, 4 Jul 2025 13:49:34 +0530 Subject: [PATCH 48/90] fix: simplify useEffect condition for setting expanded map in TreeView component --- src/Shared/Components/TreeView/TreeView.component.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index 912801e37..e7da1ae4b 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -97,7 +97,7 @@ const TreeView = ({ useEffect(() => { // isControlled is false for first level of the tree view so should set the expanded map only from first level - if (isFirstLevel && itemsRef.current && itemsRef.current[selectedId]) { + if (isFirstLevel) { const selectedIdParentNodes = getSelectedIdParentNodes() setCurrentLevelExpandedMap((prev) => { const newExpandedMap = { ...prev } From 4c80864a68d2f1532c4f4c7eeb2e0ef907cf25bb Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Mon, 7 Jul 2025 13:59:07 +0530 Subject: [PATCH 49/90] chore: update version to 1.17.0-pre-0 in package.json and package-lock.json --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index db90e2aaa..2ee805ef0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.17.0", + "version": "1.17.0-pre-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.17.0", + "version": "1.17.0-pre-0", "hasInstallScript": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 1473fed9a..3584ea54a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.17.0", + "version": "1.17.0-pre-0", "description": "Supporting common component library", "type": "module", "main": "dist/index.js", From 0064ac72fdfdd5587cf1fb331709ff66d1836297 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Tue, 8 Jul 2025 13:17:39 +0530 Subject: [PATCH 50/90] fix: review comments --- .../UseIsTextTruncated}/UseIsTextTruncated.ts | 0 .../Hooks/UseIsTextTruncated/constants.ts | 1 + src/Common/Hooks/UseIsTextTruncated/index.ts | 1 + src/Common/Hooks/index.ts | 1 + src/Common/Tooltip/Tooltip.tsx | 3 +- src/Common/Tooltip/constants.tsx | 2 - src/Common/Tooltip/index.ts | 1 - .../TreeView/TreeView.component.tsx | 63 ++++++---------- .../TreeView/TreeViewNodeContent.tsx | 3 +- src/Shared/Components/TreeView/types.ts | 18 +++++ src/Shared/Components/TreeView/utils.ts | 72 +++++++++++++++++++ 11 files changed, 118 insertions(+), 47 deletions(-) rename src/Common/{Tooltip => Hooks/UseIsTextTruncated}/UseIsTextTruncated.ts (100%) create mode 100644 src/Common/Hooks/UseIsTextTruncated/constants.ts create mode 100644 src/Common/Hooks/UseIsTextTruncated/index.ts create mode 100644 src/Shared/Components/TreeView/utils.ts diff --git a/src/Common/Tooltip/UseIsTextTruncated.ts b/src/Common/Hooks/UseIsTextTruncated/UseIsTextTruncated.ts similarity index 100% rename from src/Common/Tooltip/UseIsTextTruncated.ts rename to src/Common/Hooks/UseIsTextTruncated/UseIsTextTruncated.ts diff --git a/src/Common/Hooks/UseIsTextTruncated/constants.ts b/src/Common/Hooks/UseIsTextTruncated/constants.ts new file mode 100644 index 000000000..377520560 --- /dev/null +++ b/src/Common/Hooks/UseIsTextTruncated/constants.ts @@ -0,0 +1 @@ +export const SUB_PIXEL_ERROR = 1 diff --git a/src/Common/Hooks/UseIsTextTruncated/index.ts b/src/Common/Hooks/UseIsTextTruncated/index.ts new file mode 100644 index 000000000..9bc03650e --- /dev/null +++ b/src/Common/Hooks/UseIsTextTruncated/index.ts @@ -0,0 +1 @@ +export { default as useIsTextTruncated } from './UseIsTextTruncated' diff --git a/src/Common/Hooks/index.ts b/src/Common/Hooks/index.ts index fc57ae76b..b6bfc1049 100644 --- a/src/Common/Hooks/index.ts +++ b/src/Common/Hooks/index.ts @@ -16,6 +16,7 @@ export { useClickOutside } from './UseClickOutside/UseClickOutside' export { useGetUserRoles } from './UseGetUserRoles' +export { useIsTextTruncated } from './UseIsTextTruncated' export * from './UseRegisterShortcut' export * from './useStateFilters' export * from './useUrlFilters' diff --git a/src/Common/Tooltip/Tooltip.tsx b/src/Common/Tooltip/Tooltip.tsx index 5518a6339..59ad4f6c6 100644 --- a/src/Common/Tooltip/Tooltip.tsx +++ b/src/Common/Tooltip/Tooltip.tsx @@ -17,9 +17,10 @@ import { cloneElement } from 'react' import TippyJS from '@tippyjs/react' +import { useIsTextTruncated } from '@Common/Hooks' + import ShortcutKeyComboTooltipContent from './ShortcutKeyComboTooltipContent' import { TooltipProps } from './types' -import useIsTextTruncated from './UseIsTextTruncated' import './styles.scss' diff --git a/src/Common/Tooltip/constants.tsx b/src/Common/Tooltip/constants.tsx index 7565be91d..22550f81a 100644 --- a/src/Common/Tooltip/constants.tsx +++ b/src/Common/Tooltip/constants.tsx @@ -17,5 +17,3 @@ export const TOOLTIP_CONTENTS = { INVALID_INPUT: 'Valid input is required for all mandatory fields.', } - -export const SUB_PIXEL_ERROR = 1 diff --git a/src/Common/Tooltip/index.ts b/src/Common/Tooltip/index.ts index e1432cf20..465f794fa 100644 --- a/src/Common/Tooltip/index.ts +++ b/src/Common/Tooltip/index.ts @@ -17,4 +17,3 @@ export { TOOLTIP_CONTENTS } from './constants' export { default as Tooltip } from './Tooltip' export type { TooltipProps } from './types' -export { default as useIsTextTruncated } from './UseIsTextTruncated' diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index e7da1ae4b..ee8b8a097 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -6,7 +6,8 @@ import { Icon } from '../Icon' import { TrailingItem } from '../TrailingItem' import { DEFAULT_NO_ITEMS_TEXT, VARIANT_TO_BG_CLASS_MAP, VARIANT_TO_HOVER_CLASS_MAP } from './constants' import TreeViewNodeContent from './TreeViewNodeContent' -import { NodeElementType, TreeHeading, TreeItem, TreeNode, TreeViewProps } from './types' +import { NodeElementType, TreeHeading, TreeItem, TreeViewProps } from './types' +import { getSelectedIdParentNodes } from './utils' import './TreeView.scss' @@ -33,50 +34,16 @@ const TreeView = ({ const { pathname } = useLocation() const isFirstLevel = depth === 0 - const findSelectedIdParentNodes = ( - node: TreeNode, - onFindParentNode: (id: string) => void, - ): boolean => { - if (node.id === selectedId) { - return true - } - - if (node.type === 'heading' && node.items?.length) { - let found = false - node.items.forEach((childNode) => { - if (findSelectedIdParentNodes(childNode, onFindParentNode)) { - found = true - onFindParentNode(node.id) - } - }) - return found - } - - return false - } - - const getSelectedIdParentNodes = (): string[] => { - const selectedIdParentNodes: string[] = [] - - if (!selectedId) { - return selectedIdParentNodes - } - - nodes.forEach((node) => { - findSelectedIdParentNodes(node, (id: string) => { - selectedIdParentNodes.push(id) - }) - }) - return selectedIdParentNodes - } - const getDefaultExpandedMap = (): Record => { const defaultMap: Record = defaultExpandedMap if (!selectedId) { return defaultMap } - const selectedIdParentNodes = getSelectedIdParentNodes() + const selectedIdParentNodes = getSelectedIdParentNodes({ + selectedId, + nodes, + }) selectedIdParentNodes.forEach((id) => { defaultMap[id] = true }) @@ -98,7 +65,10 @@ const TreeView = ({ useEffect(() => { // isControlled is false for first level of the tree view so should set the expanded map only from first level if (isFirstLevel) { - const selectedIdParentNodes = getSelectedIdParentNodes() + const selectedIdParentNodes = getSelectedIdParentNodes({ + selectedId, + nodes, + }) setCurrentLevelExpandedMap((prev) => { const newExpandedMap = { ...prev } selectedIdParentNodes.forEach((id) => { @@ -146,8 +116,17 @@ const TreeView = ({ itemsRef.current[id] = el } - // will traverse all the nodes that are expanded and visible in the tree view - // and return a flat list of node ids for keyboard navigation + /** + * Recursively traverses a list of tree nodes and returns an array of all node IDs that are present in DOM. + * + * For each node in the provided list: + * - Adds the node's `id` to the result array. + * - If the node is of type `'heading'`, is expanded (as per `expandedMap`), and has child items, + * recursively traverses its child items and includes their IDs as well. + * + * @param nodeList - The list of nodes to traverse. + * @returns An array of strings representing the IDs of all traversed nodes. + */ const traverseNodes = (nodeList: typeof nodes): string[] => nodeList.reduce((acc: string[], node) => { acc.push(node.id) diff --git a/src/Shared/Components/TreeView/TreeViewNodeContent.tsx b/src/Shared/Components/TreeView/TreeViewNodeContent.tsx index 3c474c970..544d3ec10 100644 --- a/src/Shared/Components/TreeView/TreeViewNodeContent.tsx +++ b/src/Shared/Components/TreeView/TreeViewNodeContent.tsx @@ -1,5 +1,6 @@ import { ConditionalWrap } from '@Common/Helper' -import { Tooltip, TooltipProps, useIsTextTruncated } from '@Common/Tooltip' +import { useIsTextTruncated } from '@Common/Hooks' +import { Tooltip, TooltipProps } from '@Common/Tooltip' import { Icon } from '../Icon' import { TreeViewNodeContentProps } from './types' diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts index 37ae6c414..e8ea022e9 100644 --- a/src/Shared/Components/TreeView/types.ts +++ b/src/Shared/Components/TreeView/types.ts @@ -24,6 +24,9 @@ export type TreeNode = TreeHeading * @property dataAttributes - Optional data attributes, present only if `DataAttributeType` extends `DataAttributes`. */ type BaseNode = { + /** + * id - Unique identifier for the node. + */ id: string /** * The title of the list item. @@ -33,11 +36,17 @@ type BaseNode = { * The subtitle of the list item. */ subtitle?: string + /** + * Optional configuration for a custom tooltip. + */ customTooltipConfig?: TooltipProps /** * If true, the title will be rendered with line-through. */ strikeThrough?: boolean + /** + * Optional configuration for a start icon, which can be either a standard icon (with `name` and `color`) or a custom JSX element. + */ startIconConfig?: { tooltipContent?: string } & ( @@ -203,3 +212,12 @@ export interface TreeViewNodeContentProps type: 'heading' | 'item' isSelected: boolean } + +export interface GetSelectedIdParentNodesProps + extends Pick, 'nodes' | 'selectedId'> {} + +export interface FindSelectedIdParentNodesProps + extends Pick, 'selectedId'> { + node: TreeNode + onFindParentNode: (id: string) => void +} diff --git a/src/Shared/Components/TreeView/utils.ts b/src/Shared/Components/TreeView/utils.ts new file mode 100644 index 000000000..d4dae499a --- /dev/null +++ b/src/Shared/Components/TreeView/utils.ts @@ -0,0 +1,72 @@ +import { FindSelectedIdParentNodesProps, GetSelectedIdParentNodesProps } from './types' + +/** + * Recursively traverses a tree structure to find the parent nodes of the node with the specified `selectedId`. + * + * @param node - The current tree node to search within. + * @param onFindParentNode - Callback invoked with the ID of each parent node found on the path to the selected node. + * @returns `true` if the node with `selectedId` is found in the subtree rooted at `node`, otherwise `false`. + * + * @remarks + * - This function is used to collect all parent node IDs leading to a specific node in a tree. + * - The callback `onFindParentNode` is called for each parent node in the path from the root to the selected node. + * - Only nodes of type `'heading'` are considered to have children. + */ +const findSelectedIdParentNodes = ({ + node, + selectedId, + onFindParentNode, +}: FindSelectedIdParentNodesProps): boolean => { + if (node.id === selectedId) { + return true + } + + if (node.type === 'heading' && node.items?.length) { + let found = false + node.items.forEach((childNode) => { + if ( + findSelectedIdParentNodes({ + node: childNode, + onFindParentNode, + selectedId, + }) + ) { + found = true + onFindParentNode(node.id) + } + }) + return found + } + + return false +} + +/** + * Retrieves an array of parent node IDs for the currently selected node. + * + * Iterates through the provided tree nodes and collects the IDs of all parent nodes + * leading to the node identified by `selectedId`. If no node is selected, returns an empty array. + * + * @returns {string[]} An array of parent node IDs for the selected node, or an empty array if no node is selected. + */ +export const getSelectedIdParentNodes = ({ + nodes, + selectedId, +}: GetSelectedIdParentNodesProps): string[] => { + const selectedIdParentNodes: string[] = [] + + if (!selectedId) { + return selectedIdParentNodes + } + + nodes.forEach((node) => { + findSelectedIdParentNodes({ + node, + selectedId, + onFindParentNode: (id: string) => { + selectedIdParentNodes.push(id) + }, + }) + }) + return selectedIdParentNodes +} From 91c282245b6d344d55a16a68b31fd5153f936b25 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Tue, 8 Jul 2025 13:22:20 +0530 Subject: [PATCH 51/90] feat: add getVisibleNodes utility function and update TreeView to use it --- .../TreeView/TreeView.component.tsx | 28 +++-------------- src/Shared/Components/TreeView/types.ts | 5 +++ src/Shared/Components/TreeView/utils.ts | 31 ++++++++++++++++++- 3 files changed, 40 insertions(+), 24 deletions(-) diff --git a/src/Shared/Components/TreeView/TreeView.component.tsx b/src/Shared/Components/TreeView/TreeView.component.tsx index ee8b8a097..ced9c8518 100644 --- a/src/Shared/Components/TreeView/TreeView.component.tsx +++ b/src/Shared/Components/TreeView/TreeView.component.tsx @@ -7,7 +7,7 @@ import { TrailingItem } from '../TrailingItem' import { DEFAULT_NO_ITEMS_TEXT, VARIANT_TO_BG_CLASS_MAP, VARIANT_TO_HOVER_CLASS_MAP } from './constants' import TreeViewNodeContent from './TreeViewNodeContent' import { NodeElementType, TreeHeading, TreeItem, TreeViewProps } from './types' -import { getSelectedIdParentNodes } from './utils' +import { getSelectedIdParentNodes, getVisibleNodes } from './utils' import './TreeView.scss' @@ -116,33 +116,15 @@ const TreeView = ({ itemsRef.current[id] = el } - /** - * Recursively traverses a list of tree nodes and returns an array of all node IDs that are present in DOM. - * - * For each node in the provided list: - * - Adds the node's `id` to the result array. - * - If the node is of type `'heading'`, is expanded (as per `expandedMap`), and has child items, - * recursively traverses its child items and includes their IDs as well. - * - * @param nodeList - The list of nodes to traverse. - * @returns An array of strings representing the IDs of all traversed nodes. - */ - const traverseNodes = (nodeList: typeof nodes): string[] => - nodeList.reduce((acc: string[], node) => { - acc.push(node.id) - if (node.type === 'heading' && expandedMap[node.id] && node.items?.length) { - // If the node is a heading and expanded, traverse its items - acc.push(...traverseNodes(node.items)) - } - return acc - }, []) - const flatNodeList = useMemo(() => { if (flatNodeListProp) { return flatNodeListProp } - return traverseNodes(nodes) + return getVisibleNodes({ + nodeList: nodes, + expandedMap, + }) }, [nodes, expandedMap, flatNodeListProp]) const handleKeyDown = (e: React.KeyboardEvent) => { diff --git a/src/Shared/Components/TreeView/types.ts b/src/Shared/Components/TreeView/types.ts index e8ea022e9..8266cf458 100644 --- a/src/Shared/Components/TreeView/types.ts +++ b/src/Shared/Components/TreeView/types.ts @@ -221,3 +221,8 @@ export interface FindSelectedIdParentNodesProps node: TreeNode onFindParentNode: (id: string) => void } + +export interface GetVisibleNodesProps + extends Pick, 'expandedMap'> { + nodeList: TreeNode[] +} diff --git a/src/Shared/Components/TreeView/utils.ts b/src/Shared/Components/TreeView/utils.ts index d4dae499a..7b64a4b2c 100644 --- a/src/Shared/Components/TreeView/utils.ts +++ b/src/Shared/Components/TreeView/utils.ts @@ -1,4 +1,4 @@ -import { FindSelectedIdParentNodesProps, GetSelectedIdParentNodesProps } from './types' +import { FindSelectedIdParentNodesProps, GetSelectedIdParentNodesProps, GetVisibleNodesProps } from './types' /** * Recursively traverses a tree structure to find the parent nodes of the node with the specified `selectedId`. @@ -70,3 +70,32 @@ export const getSelectedIdParentNodes = ({ }) return selectedIdParentNodes } + +/** + * Recursively traverses a list of tree nodes and returns an array of all node IDs that are present in DOM. + * + * For each node in the provided list: + * - Adds the node's `id` to the result array. + * - If the node is of type `'heading'`, is expanded (as per `expandedMap`), and has child items, + * recursively traverses its child items and includes their IDs as well. + * + * @param nodeList - The list of nodes to traverse. + * @returns An array of strings representing the IDs of all traversed nodes. + */ +export const getVisibleNodes = ({ + nodeList, + expandedMap, +}: GetVisibleNodesProps): string[] => + nodeList.reduce((acc: string[], node) => { + acc.push(node.id) + if (node.type === 'heading' && expandedMap[node.id] && node.items?.length) { + // If the node is a heading and expanded, traverse its items + acc.push( + ...getVisibleNodes({ + nodeList: node.items, + expandedMap, + }), + ) + } + return acc + }, []) From 2ffe4a95997e63f0d50ced78ff2450fd07d09b23 Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Tue, 8 Jul 2025 16:19:36 +0530 Subject: [PATCH 52/90] refactor: rename illustrationName to imgName in GenericEmptyState and GenericFilterEmptyState --- src/Common/EmptyState/GenericEmptyState.tsx | 6 +++--- src/Common/EmptyState/GenericFilterEmptyState.tsx | 3 +-- src/Common/Types.ts | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Common/EmptyState/GenericEmptyState.tsx b/src/Common/EmptyState/GenericEmptyState.tsx index 8e958689e..1cc6626d0 100644 --- a/src/Common/EmptyState/GenericEmptyState.tsx +++ b/src/Common/EmptyState/GenericEmptyState.tsx @@ -37,7 +37,7 @@ const GenericEmptyState = ({ layout = 'column', contentClassName = '', imageStyles = {}, - illustrationName, + imgName, }: GenericEmptyStateType): JSX.Element => { const isRowLayout = layout === 'row' @@ -58,9 +58,9 @@ const GenericEmptyState = ({ > {!SvgImage ? ( !noImage && - (illustrationName ? ( + (imgName ? ( export interface GenericEmptyStateType { title: ReactNode - illustrationName?: IllustrationName + imgName?: IllustrationName image? classname?: string subTitle?: ReactNode From d8f12cf1bd5622aa74dd0ebe7047ce5a03bf8e9f Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Wed, 9 Jul 2025 13:41:57 +0530 Subject: [PATCH 53/90] feat: Table component - add generic typings & missing props --- src/Shared/Components/Table/InternalTable.tsx | 44 ++-- .../Components/Table/Table.component.tsx | 78 ++++-- src/Shared/Components/Table/TableContent.tsx | 16 +- src/Shared/Components/Table/index.ts | 1 + src/Shared/Components/Table/types.ts | 237 +++++++++++------- .../Table/useTableWithKeyboardShortcuts.ts | 15 +- src/Shared/Components/Table/utils.ts | 53 ++-- 7 files changed, 295 insertions(+), 149 deletions(-) diff --git a/src/Shared/Components/Table/InternalTable.tsx b/src/Shared/Components/Table/InternalTable.tsx index 480b892e2..44a2b9caa 100644 --- a/src/Shared/Components/Table/InternalTable.tsx +++ b/src/Shared/Components/Table/InternalTable.tsx @@ -8,7 +8,11 @@ import TableContent from './TableContent' import { FiltersTypeEnum, InternalTableProps, PaginationEnum } from './types' import { getFilteringPromise, searchAndSortRows } from './utils' -const InternalTable = ({ +const InternalTable = < + RowData extends unknown, + FilterVariant extends FiltersTypeEnum, + AdditionalProps extends Record, +>({ filtersVariant, filterData, rows, @@ -32,7 +36,7 @@ const InternalTable = ({ RowActionsOnHoverComponent, pageSizeOptions, clearFilters: userGivenUrlClearFilters, -}: InternalTableProps) => { +}: InternalTableProps) => { const { sortBy, sortOrder, @@ -132,7 +136,10 @@ const InternalTable = ({ if (!areFilteredRowsLoading && !filteredRows?.length && !loading) { return filtersVariant !== FiltersTypeEnum.NONE && areFiltersApplied ? ( - + ) : ( ) @@ -165,19 +172,24 @@ const InternalTable = ({ return (
{}} + offset={offset} + searchKey={searchKey} + sortBy={sortBy} + sortOrder={sortOrder} + {...filterData} + {...(areColumnsConfigurable + ? { + allColumns: columns, + setVisibleColumns, + visibleColumns, + } + : {})} + {...additionalProps} > {renderContent()} diff --git a/src/Shared/Components/Table/Table.component.tsx b/src/Shared/Components/Table/Table.component.tsx index 501209513..1a3d190fa 100644 --- a/src/Shared/Components/Table/Table.component.tsx +++ b/src/Shared/Components/Table/Table.component.tsx @@ -19,7 +19,13 @@ import { getVisibleColumns, setVisibleColumnsToLocalStorage } from './utils' import './styles.scss' -const UseResizableTableConfigWrapper = (props: InternalTableProps) => { +const UseResizableTableConfigWrapper = < + RowData extends unknown, + FilterVariant extends FiltersTypeEnum, + AdditionalProps extends Record, +>( + props: InternalTableProps, +) => { const { visibleColumns } = props const resizableConfig = useResizableTableConfig({ @@ -49,7 +55,13 @@ const UseResizableTableConfigWrapper = (props: InternalTableProps) => { return } -const TableWithResizableConfigWrapper = (tableProps: UseResizableTableConfigWrapperProps) => { +const TableWithResizableConfigWrapper = < + RowData extends unknown, + FilterVariant extends FiltersTypeEnum, + AdditionalProps extends Record, +>( + tableProps: UseResizableTableConfigWrapperProps, +) => { const { visibleColumns: columnsWithoutBulkActionGutter, bulkSelectionConfig: bulkActionsConfig } = tableProps const visibleColumns = useMemo( @@ -60,18 +72,28 @@ const TableWithResizableConfigWrapper = (tableProps: UseResizableTableConfigWrap [!!bulkActionsConfig, columnsWithoutBulkActionGutter], ) - const isResizable = visibleColumns.some(({ size }) => !!size?.range) + const isResizable = visibleColumns.some(({ size }) => size && 'range' in size && size.range) if (isResizable && visibleColumns.some(({ size }) => size === null)) { throw new Error('If any column is resizable, all columns must have a fixed size') } - const commonProps = { ...tableProps, visibleColumns, resizableConfig: null } as InternalTableProps + const commonProps = { ...tableProps, visibleColumns, resizableConfig: null } as InternalTableProps< + RowData, + FilterVariant, + AdditionalProps + > return isResizable ? : } -const TableWithUseBulkSelectionReturnValue = (tableProps: TableWithBulkSelectionProps) => { +const TableWithUseBulkSelectionReturnValue = < + RowData extends unknown = unknown, + FilterVariant extends FiltersTypeEnum = FiltersTypeEnum.NONE, + AdditionalProps extends Record = {}, +>( + tableProps: TableWithBulkSelectionProps, +) => { const bulkSelectionReturnValue = useBulkSelection() const { selectedIdentifiers, handleBulkSelection, isBulkSelectionApplied } = bulkSelectionReturnValue @@ -83,7 +105,7 @@ const TableWithUseBulkSelectionReturnValue = (tableProps: TableWithBulkSelection } const handleToggleBulkSelectionOnRow = useCallback( - (row: RowType) => { + (row: RowType) => { const isRowSelected = selectedIdentifiers[row.id] if (!isRowSelected && !isBulkSelectionApplied) { @@ -126,7 +148,13 @@ const TableWithUseBulkSelectionReturnValue = (tableProps: TableWithBulkSelection ) } -const TableWithBulkSelection = (tableProps: TableWithBulkSelectionProps) => { +const TableWithBulkSelection = < + RowData extends unknown, + FilterVariant extends FiltersTypeEnum, + AdditionalProps extends Record, +>( + tableProps: TableWithBulkSelectionProps, +) => { const { bulkSelectionConfig } = tableProps return bulkSelectionConfig ? ( @@ -143,7 +171,13 @@ const TableWithBulkSelection = (tableProps: TableWithBulkSelectionProps) => { ) } -const VisibleColumnsWrapper = (tableProps: VisibleColumnsWrapperProps) => { +const VisibleColumnsWrapper = < + RowData extends unknown, + FilterVariant extends FiltersTypeEnum, + AdditionalProps extends Record, +>( + tableProps: VisibleColumnsWrapperProps, +) => { const { columns, id, areColumnsConfigurable } = tableProps const [visibleColumns, setVisibleColumns] = useState(getVisibleColumns({ columns, id, areColumnsConfigurable })) @@ -166,7 +200,9 @@ const VisibleColumnsWrapper = (tableProps: VisibleColumnsWrapperProps) => { ) } -const UseStateFilterWrapper = (props: FilterWrapperProps) => { +const UseStateFilterWrapper = >( + props: FilterWrapperProps, +) => { const { additionalFilterProps } = props const filterData = useStateFilters(additionalFilterProps) @@ -174,7 +210,9 @@ const UseStateFilterWrapper = (props: FilterWrapperProps) => { return } -const UseUrlFilterWrapper = (props: FilterWrapperProps) => { +const UseUrlFilterWrapper = >( + props: FilterWrapperProps, +) => { const { additionalFilterProps } = props const filterData = useUrlFilters(additionalFilterProps) @@ -182,18 +220,30 @@ const UseUrlFilterWrapper = (props: FilterWrapperProps) => { return } -const TableWrapper = (tableProps: TableProps) => { +const TableWrapper = < + RowData extends unknown = unknown, + FilterVariant extends FiltersTypeEnum = FiltersTypeEnum.NONE, + AdditionalProps extends Record = {}, +>( + tableProps: TableProps, +) => { const { filtersVariant } = tableProps if (filtersVariant === FiltersTypeEnum.STATE) { - return + return ( + )} /> + ) } if (filtersVariant === FiltersTypeEnum.URL) { - return + return )} /> } - return + return ( + ), filterData: null }} + /> + ) } export default TableWrapper diff --git a/src/Shared/Components/Table/TableContent.tsx b/src/Shared/Components/Table/TableContent.tsx index ffba0e4d1..0b2350167 100644 --- a/src/Shared/Components/Table/TableContent.tsx +++ b/src/Shared/Components/Table/TableContent.tsx @@ -10,11 +10,15 @@ import { CHECKBOX_VALUE } from '@Common/Types' import { BulkSelection } from '../BulkSelection' import BulkSelectionActionWidget from './BulkSelectionActionWidget' import { BULK_ACTION_GUTTER_LABEL, EVENT_TARGET, SHIMMER_DUMMY_ARRAY } from './constants' -import { BulkActionStateType, PaginationEnum, SignalsType, TableContentProps } from './types' +import { BulkActionStateType, FiltersTypeEnum, PaginationEnum, SignalsType, TableContentProps } from './types' import useTableWithKeyboardShortcuts from './useTableWithKeyboardShortcuts' import { getStickyColumnConfig, scrollToShowActiveElementIfNeeded } from './utils' -const TableContent = ({ +const TableContent = < + RowData extends unknown, + FilterVariant extends FiltersTypeEnum, + AdditionalProps extends Record, +>({ filterData, rows, resizableConfig, @@ -31,7 +35,7 @@ const TableContent = ({ pageSizeOptions, filteredRows, areFilteredRowsLoading, -}: TableContentProps) => { +}: TableContentProps) => { const rowsContainerRef = useRef(null) const parentRef = useRef(null) const bulkSelectionButtonRef = useRef(null) @@ -253,7 +257,7 @@ const TableContent = ({ {...additionalProps} /> ) : ( - + {row.data[field]} )} @@ -262,8 +266,8 @@ const TableContent = ({ })} {RowActionsOnHoverComponent && ( -
- +
+
)}
diff --git a/src/Shared/Components/Table/index.ts b/src/Shared/Components/Table/index.ts index a34f6bc86..9802ddd7e 100644 --- a/src/Shared/Components/Table/index.ts +++ b/src/Shared/Components/Table/index.ts @@ -5,6 +5,7 @@ export type { CellComponentProps as TableCellComponentProps, Column as TableColumnType, TableProps, + RowActionsOnHoverComponentProps as TableRowActionsOnHoverComponentProps, ViewWrapperProps as TableViewWrapperProps, } from './types' export { FiltersTypeEnum, PaginationEnum, SignalEnum as TableSignalEnum } from './types' diff --git a/src/Shared/Components/Table/types.ts b/src/Shared/Components/Table/types.ts index f142aeb66..beae53bba 100644 --- a/src/Shared/Components/Table/types.ts +++ b/src/Shared/Components/Table/types.ts @@ -73,16 +73,12 @@ type BaseColumnType = { horizontallySticky?: boolean } -interface AdditionalProps { - [key: string]: unknown -} - -export type RowType = { +export type RowType = { id: string - data: Record + data: Data } -export type RowsType = RowType[] +export type RowsType = RowType[] export enum FiltersTypeEnum { STATE = 'state', @@ -90,21 +86,37 @@ export enum FiltersTypeEnum { NONE = 'none', } -export interface CellComponentProps extends Pick, AdditionalProps { - signals: SignalsType - value: unknown - row: RowType - filterData: T extends FiltersTypeEnum.NONE - ? null - : T extends FiltersTypeEnum.STATE - ? UseFiltersReturnType - : UseUrlFiltersReturnType - isRowActive: boolean -} +export type CellComponentProps< + RowData extends unknown = unknown, + FilterVariant extends FiltersTypeEnum = FiltersTypeEnum.NONE, + AdditionalProps extends Record = {}, +> = Pick & + AdditionalProps & { + signals: SignalsType + value: unknown + row: RowType + filterData: FilterVariant extends FiltersTypeEnum.NONE + ? null + : FilterVariant extends FiltersTypeEnum.STATE + ? UseFiltersReturnType + : UseUrlFiltersReturnType + isRowActive: boolean + } -export type Column = Pick & +export type RowActionsOnHoverComponentProps< + RowData extends unknown = unknown, + AdditionalProps extends Record = {}, +> = { + row: RowType +} & AdditionalProps + +export type Column< + RowData extends unknown = unknown, + FilterVariant extends FiltersTypeEnum = FiltersTypeEnum.NONE, + AdditionalProps extends Record = {}, +> = Pick & BaseColumnType & { - CellComponent?: FunctionComponent + CellComponent?: FunctionComponent> } & ( | { isSortable: true @@ -128,11 +140,13 @@ export interface BulkActionsComponentProps { type BulkSelectionReturnValueType = ReturnType -export interface BulkOperationModalProps - extends Pick { +export interface BulkOperationModalProps< + T extends string = string, + RowData extends Record = Record, +> extends Pick { action: T onClose: () => void - selections: RowsType | null + selections: RowsType | null bulkOperationModalData: unknown } @@ -148,37 +162,77 @@ export enum PaginationEnum { NOT_PAGINATED = 'not-paginated', } -export interface ConfigurableColumnsType { - allColumns: Column[] - visibleColumns: Column[] - setVisibleColumns: Dispatch> +export interface ConfigurableColumnsType< + RowData extends unknown, + FilterVariant extends FiltersTypeEnum, + AdditionalProps extends Record, +> { + allColumns: Column[] + visibleColumns: Column[] + setVisibleColumns: Dispatch[]>> } interface GetRowsProps extends Pick {} -type AdditionalFilterPropsType> = T extends FiltersTypeEnum.URL +type AdditionalFilterPropsType = T extends FiltersTypeEnum.URL ? Pick< UseUrlFiltersProps, 'parseSearchParams' | 'localStorageKey' | 'redirectionMethod' | 'initialSortKey' | 'defaultPageSize' > - : Pick, 'initialSortKey' | 'defaultPageSize'> - -export type ViewWrapperProps = PropsWithChildren< - (T extends FiltersTypeEnum.NONE + : T extends FiltersTypeEnum.STATE + ? Pick, 'initialSortKey' | 'defaultPageSize'> + : never + +export type ViewWrapperProps< + RowData extends unknown = unknown, + FilterVariant extends FiltersTypeEnum = FiltersTypeEnum.NONE, + AdditionalProps extends Record = {}, +> = PropsWithChildren< + (FilterVariant extends FiltersTypeEnum.NONE ? {} : Pick< UseFiltersReturnType, 'offset' | 'handleSearch' | 'searchKey' | 'sortBy' | 'sortOrder' | 'clearFilters' >) & AdditionalProps & - Partial & { + Partial> & { areRowsLoading: boolean - filteredRows: RowsType | null - } & (T extends FiltersTypeEnum.URL ? Pick, 'updateSearchParams'> : {}) + filteredRows: RowsType | null + } & (FilterVariant extends FiltersTypeEnum.URL + ? Pick, 'updateSearchParams'> + : {}) > -export type InternalTableProps = Required> & { +type FilterConfig = { + filtersVariant: FilterVariant + /** + * Props for useUrlFilters/useStateFilters hooks + */ + additionalFilterProps?: AdditionalFilterPropsType + /** + * This func is used to filter the rows based on filter data. + * Only applicable if filtersVariant is NOT set to NONE + * + * If filter is only being used for sorting, then send `noop` in this prop + */ + filter: FilterVariant extends FiltersTypeEnum.NONE + ? null + : (row: RowType, filterData: UseFiltersReturnType) => boolean + clearFilters?: FilterVariant extends FiltersTypeEnum.URL + ? () => void + : FilterVariant extends FiltersTypeEnum.STATE + ? never + : never +} + +export type InternalTableProps< + RowData extends unknown, + FilterVariant extends FiltersTypeEnum, + AdditionalProps extends Record, +> = Required< + Pick, 'visibleColumns' | 'setVisibleColumns'> +> & { id: `table__${string}` loading?: boolean @@ -189,7 +243,7 @@ export type InternalTableProps = Required[] /** If bulk selections are not a concern omit this prop */ bulkSelectionConfig?: BulkSelectionConfigType @@ -197,7 +251,7 @@ export type InternalTableProps = Required noRowsForFilterConfig?: Pick & { - clearFilters: () => void + clearFilters?: () => void } } @@ -223,21 +277,21 @@ export type InternalTableProps = Required + RowActionsOnHoverComponent?: FunctionComponent> bulkSelectionReturnValue: BulkSelectionReturnValueType | null handleClearBulkSelection: () => void - handleToggleBulkSelectionOnRow: (row: RowType) => void + handleToggleBulkSelectionOnRow: (row: RowType) => void - ViewWrapper?: FunctionComponent + ViewWrapper?: FunctionComponent> } & ( | { /** * Direct rows data for frontend-only datasets like resource browser. */ - rows: RowsType + rows: RowsType /** * Use `getRows` function instead for data that needs to be fetched from backend with pagination/sorting/filtering. */ @@ -246,38 +300,7 @@ export type InternalTableProps = Required Promise - } - ) & - ( - | { - filtersVariant: FiltersTypeEnum.URL - - /** - * props for useUrlFilters/useStateFilters hooks - */ - additionalFilterProps?: AdditionalFilterPropsType - - /** - * This func is used to filter the rows based on filter data. - * Only applicable if filtersVariant is NOT set to NONE - * - * If filter is only being used for sorting, then send `noop` in this prop - */ - filter: (row: RowType, filterData: UseFiltersReturnType) => boolean - clearFilters?: () => void - } - | { - filtersVariant: FiltersTypeEnum.STATE - additionalFilterProps?: AdditionalFilterPropsType - filter: (row: RowType, filterData: UseFiltersReturnType) => boolean - clearFilters?: never - } - | { - filtersVariant: FiltersTypeEnum.NONE - additionalFilterProps?: never - filter?: never - clearFilters?: never + getRows: (props: GetRowsProps) => Promise> } ) & ( @@ -289,21 +312,42 @@ export type InternalTableProps = Required pageSizeOptions?: never } - ) - -export type UseResizableTableConfigWrapperProps = Omit - -export type TableWithBulkSelectionProps = Omit< - UseResizableTableConfigWrapperProps, + ) & + FilterConfig + +export type UseResizableTableConfigWrapperProps< + RowData extends unknown, + FilterVariant extends FiltersTypeEnum, + AdditionalProps extends Record, +> = Omit, 'resizableConfig'> + +export type TableWithBulkSelectionProps< + RowData extends unknown, + FilterVariant extends FiltersTypeEnum, + AdditionalProps extends Record, +> = Omit< + UseResizableTableConfigWrapperProps, 'bulkSelectionReturnValue' | 'handleClearBulkSelection' | 'handleToggleBulkSelectionOnRow' > -export type VisibleColumnsWrapperProps = Omit - -export type FilterWrapperProps = Omit - -export type TableProps = Pick< - FilterWrapperProps, +export type VisibleColumnsWrapperProps< + RowData extends unknown, + FilterVariant extends FiltersTypeEnum, + AdditionalProps extends Record, +> = Omit, 'visibleColumns' | 'setVisibleColumns'> + +export type FilterWrapperProps< + RowData extends unknown, + FilterVariant extends FiltersTypeEnum, + AdditionalProps extends Record, +> = Omit, 'filterData'> + +export type TableProps< + RowData extends unknown = unknown, + FilterVariant extends FiltersTypeEnum = FiltersTypeEnum.NONE, + AdditionalProps extends Record = {}, +> = Pick< + FilterWrapperProps, | 'additionalFilterProps' | 'bulkSelectionConfig' | 'areColumnsConfigurable' @@ -335,16 +379,23 @@ export interface BulkSelectionActionWidgetProps setBulkActionState: Dispatch> } -export type ConfigurableColumnsConfigType = Record +export type ConfigurableColumnsConfigType< + RowData extends unknown, + FilterVariant extends FiltersTypeEnum, + AdditionalProps extends Record, +> = Record['visibleColumns']> -export interface GetFilteringPromiseProps { +export interface GetFilteringPromiseProps { searchSortTimeoutRef: React.MutableRefObject - callback: () => Promise | RowsType + callback: () => Promise> | RowsType } -export interface TableContentProps - extends Pick< - InternalTableProps, +export interface TableContentProps< + RowData extends unknown, + FilterVariant extends FiltersTypeEnum, + AdditionalProps extends Record, +> extends Pick< + InternalTableProps, | 'filterData' | 'rows' | 'resizableConfig' @@ -360,6 +411,6 @@ export interface TableContentProps | 'RowActionsOnHoverComponent' | 'pageSizeOptions' > { - filteredRows: RowsType + filteredRows: RowsType areFilteredRowsLoading: boolean } diff --git a/src/Shared/Components/Table/useTableWithKeyboardShortcuts.ts b/src/Shared/Components/Table/useTableWithKeyboardShortcuts.ts index 1b130962f..2f38870c3 100644 --- a/src/Shared/Components/Table/useTableWithKeyboardShortcuts.ts +++ b/src/Shared/Components/Table/useTableWithKeyboardShortcuts.ts @@ -5,15 +5,22 @@ import { useRegisterShortcut } from '@Common/Hooks' import { BulkSelectionEvents } from '../BulkSelection' import { EVENT_TARGET } from './constants' -import { InternalTableProps, RowsType, SignalEnum } from './types' +import { FiltersTypeEnum, InternalTableProps, RowsType, SignalEnum } from './types' -const useTableWithKeyboardShortcuts = ( +const useTableWithKeyboardShortcuts = < + RowData extends unknown, + FilterVariant extends FiltersTypeEnum, + AdditionalProps extends Record, +>( { bulkSelectionConfig, handleToggleBulkSelectionOnRow, bulkSelectionReturnValue, - }: Pick, - visibleRows: RowsType, + }: Pick< + InternalTableProps, + 'bulkSelectionConfig' | 'bulkSelectionReturnValue' | 'handleToggleBulkSelectionOnRow' + >, + visibleRows: RowsType, showPagination: boolean, bulkSelectionButtonRef: React.RefObject, ) => { diff --git a/src/Shared/Components/Table/utils.ts b/src/Shared/Components/Table/utils.ts index 89209a5da..ab037aa81 100644 --- a/src/Shared/Components/Table/utils.ts +++ b/src/Shared/Components/Table/utils.ts @@ -11,17 +11,22 @@ import { Column, ConfigurableColumnsConfigType, ConfigurableColumnsType, + FiltersTypeEnum, GetFilteringPromiseProps, RowsType, TableProps, UseFiltersReturnType, } from './types' -export const searchAndSortRows = ( - rows: TableProps['rows'], - filter: TableProps['filter'], +export const searchAndSortRows = < + RowData extends unknown, + FilterVariant extends FiltersTypeEnum, + AdditionalProps extends Record, +>( + rows: TableProps['rows'], + filter: TableProps['filter'], filterData: UseFiltersReturnType, - comparator?: Column['comparator'], + comparator?: Column['comparator'], ) => { const { sortBy, sortOrder } = filterData ?? {} @@ -35,19 +40,23 @@ export const searchAndSortRows = ( : filteredRows } -export const getVisibleColumnsFromLocalStorage = ({ +export const getVisibleColumnsFromLocalStorage = < + RowData extends unknown, + FilterVariant extends FiltersTypeEnum, + AdditionalProps extends Record, +>({ allColumns, id, -}: Pick & Pick) => { +}: Pick, 'allColumns'> & + Pick, 'id'>) => { if (!LOCAL_STORAGE_EXISTS) { // NOTE: show all headers by default return allColumns } try { - const configurableColumnsConfig: ConfigurableColumnsConfigType = JSON.parse( - localStorage.getItem(LOCAL_STORAGE_KEY_FOR_VISIBLE_COLUMNS), - ) + const configurableColumnsConfig: ConfigurableColumnsConfigType = + JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY_FOR_VISIBLE_COLUMNS)) if (!configurableColumnsConfig?.[id]) { throw new Error() @@ -71,16 +80,21 @@ export const getVisibleColumnsFromLocalStorage = ({ } } -export const setVisibleColumnsToLocalStorage = ({ +export const setVisibleColumnsToLocalStorage = < + RowData extends unknown, + FilterVariant extends FiltersTypeEnum, + AdditionalProps extends Record, +>({ id, visibleColumns, -}: Pick & Pick) => { +}: Pick, 'visibleColumns'> & + Pick, 'id'>) => { if (!LOCAL_STORAGE_EXISTS || !Array.isArray(visibleColumns)) { return } try { - const configurableColumnsConfig: ConfigurableColumnsConfigType = + const configurableColumnsConfig: ConfigurableColumnsConfigType = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY_FOR_VISIBLE_COLUMNS)) ?? {} localStorage.setItem( @@ -92,15 +106,22 @@ export const setVisibleColumnsToLocalStorage = ({ } } -export const getVisibleColumns = ({ +export const getVisibleColumns = < + RowData extends unknown, + FilterVariant extends FiltersTypeEnum, + AdditionalProps extends Record, +>({ areColumnsConfigurable, columns, id, -}: Pick) => +}: Pick, 'areColumnsConfigurable' | 'columns' | 'id'>) => areColumnsConfigurable ? getVisibleColumnsFromLocalStorage({ allColumns: columns, id }) : columns -export const getFilteringPromise = ({ searchSortTimeoutRef, callback }: GetFilteringPromiseProps) => - new Promise((resolve, reject) => { +export const getFilteringPromise = ({ + searchSortTimeoutRef, + callback, +}: GetFilteringPromiseProps) => + new Promise>((resolve, reject) => { if (searchSortTimeoutRef.current !== -1) { clearTimeout(searchSortTimeoutRef.current) } From 7a94bad251b1dcadb93fd61662bc979826ce4b0a Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Wed, 9 Jul 2025 13:44:40 +0530 Subject: [PATCH 54/90] chore(version): bump to 1.17.0-pre-1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2ee805ef0..91d5373e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.17.0-pre-0", + "version": "1.17.0-pre-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.17.0-pre-0", + "version": "1.17.0-pre-1", "hasInstallScript": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 3584ea54a..1076e2ab1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.17.0-pre-0", + "version": "1.17.0-pre-1", "description": "Supporting common component library", "type": "module", "main": "dist/index.js", From 69219a8bb8f7d3db527b228ddcd549746fba259b Mon Sep 17 00:00:00 2001 From: shivani170 Date: Wed, 9 Jul 2025 15:37:54 +0530 Subject: [PATCH 55/90] chore: version bump --- package-lock.json | 4 ++-- package.json | 2 +- .../Hooks/useUserPreferences/useUserPrefrences.tsx | 14 ++------------ src/Shared/Hooks/useUserPreferences/utils.tsx | 2 +- 4 files changed, 6 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2ee805ef0..91d5373e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.17.0-pre-0", + "version": "1.17.0-pre-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.17.0-pre-0", + "version": "1.17.0-pre-1", "hasInstallScript": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 3584ea54a..1076e2ab1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.17.0-pre-0", + "version": "1.17.0-pre-1", "description": "Supporting common component library", "type": "module", "main": "dist/index.js", diff --git a/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx b/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx index 19054e394..6beb7b2ad 100644 --- a/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx +++ b/src/Shared/Hooks/useUserPreferences/useUserPrefrences.tsx @@ -53,17 +53,7 @@ export const useUserPreferences = ({ migrateUserPreferences, recentlyVisitedFetc resourceKind, }) - const updatedUserPreferences = { - ...userPreferencesResponse, - resources: { - ...userPreferencesResponse?.resources, - [resourceKind]: { - ...userPreferencesResponse?.resources?.[resourceKind], - [UserPreferenceResourceActions.RECENTLY_VISITED]: uniqueFilteredApps, - }, - }, - } - localStorage.setItem('userPreferences', JSON.stringify(updatedUserPreferences)) + localStorage.setItem('userPreferences', JSON.stringify(userPreferencesResponse)) setUserPreferences((prev) => ({ ...prev, @@ -82,7 +72,7 @@ export const useUserPreferences = ({ migrateUserPreferences, recentlyVisitedFetc userPreferencesResponse, }) - return updatedUserPreferences + return userPreferencesResponse } const [recentResourcesLoading, recentResourcesResult] = useAsync( diff --git a/src/Shared/Hooks/useUserPreferences/utils.tsx b/src/Shared/Hooks/useUserPreferences/utils.tsx index 489a7bd34..0d27875dd 100644 --- a/src/Shared/Hooks/useUserPreferences/utils.tsx +++ b/src/Shared/Hooks/useUserPreferences/utils.tsx @@ -18,7 +18,7 @@ export const getFilteredUniqueAppList = ({ const isInvalidApp = id && !name const validEntities = _recentApps.filter((app) => { - if (!app?.id || !app?.name) { + if (!app?.id || !app.name) { return false } From eaa127606d5a66ea806b9143c2228eb8d38acf2e Mon Sep 17 00:00:00 2001 From: shivani170 Date: Wed, 9 Jul 2025 15:38:26 +0530 Subject: [PATCH 56/90] chore: version bump --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 91d5373e2..a2fcb6b2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.17.0-pre-1", + "version": "1.17.0-beta-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.17.0-pre-1", + "version": "1.17.0-beta-1", "hasInstallScript": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 1076e2ab1..5869ad883 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.17.0-pre-1", + "version": "1.17.0-beta-1", "description": "Supporting common component library", "type": "module", "main": "dist/index.js", From 1527d947600dcb46c3794b98d56e652873b4985d Mon Sep 17 00:00:00 2001 From: shivani170 Date: Wed, 9 Jul 2025 16:45:09 +0530 Subject: [PATCH 57/90] chore: version bump --- package-lock.json | 4 ++-- package.json | 2 +- src/Assets/IconV2/ic-helm-app.svg | 4 ++++ src/Shared/Components/Icon/Icon.tsx | 2 ++ 4 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 src/Assets/IconV2/ic-helm-app.svg diff --git a/package-lock.json b/package-lock.json index 91d5373e2..6c5bc92e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.17.0-pre-1", + "version": "1.17.0-beta-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.17.0-pre-1", + "version": "1.17.0-beta-2", "hasInstallScript": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 1076e2ab1..98b5617b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.17.0-pre-1", + "version": "1.17.0-beta-2", "description": "Supporting common component library", "type": "module", "main": "dist/index.js", diff --git a/src/Assets/IconV2/ic-helm-app.svg b/src/Assets/IconV2/ic-helm-app.svg new file mode 100644 index 000000000..c76c9be5c --- /dev/null +++ b/src/Assets/IconV2/ic-helm-app.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/Shared/Components/Icon/Icon.tsx b/src/Shared/Components/Icon/Icon.tsx index 94f09c540..ab7495f8f 100644 --- a/src/Shared/Components/Icon/Icon.tsx +++ b/src/Shared/Components/Icon/Icon.tsx @@ -112,6 +112,7 @@ import { ReactComponent as ICHeartGreen } from '@IconsV2/ic-heart-green.svg' import { ReactComponent as ICHeartRed } from '@IconsV2/ic-heart-red.svg' import { ReactComponent as ICHeartRedAnimated } from '@IconsV2/ic-heart-red-animated.svg' import { ReactComponent as ICHelm } from '@IconsV2/ic-helm.svg' +import { ReactComponent as ICHelmApp } from '@IconsV2/ic-helm-app.svg' import { ReactComponent as ICHelpFilled } from '@IconsV2/ic-help-filled.svg' import { ReactComponent as ICHelpOutline } from '@IconsV2/ic-help-outline.svg' import { ReactComponent as ICHibernate } from '@IconsV2/ic-hibernate.svg' @@ -332,6 +333,7 @@ export const iconMap = { 'ic-heart-green': ICHeartGreen, 'ic-heart-red-animated': ICHeartRedAnimated, 'ic-heart-red': ICHeartRed, + 'ic-helm-app': ICHelmApp, 'ic-helm': ICHelm, 'ic-help-filled': ICHelpFilled, 'ic-help-outline': ICHelpOutline, From 041a2be6da5a1eb310d48a1dc52a779fcfd2bb27 Mon Sep 17 00:00:00 2001 From: Amrit Kashyap Borah Date: Wed, 9 Jul 2025 16:58:40 +0530 Subject: [PATCH 58/90] feat: use select picker in RJSF fix: RJSF select menu stacking context issue --- src/Common/RJSF/Form.tsx | 51 ++++++++++--------- src/Common/RJSF/common/FieldRow.tsx | 2 +- src/Common/RJSF/constants.ts | 2 + src/Common/RJSF/rjsfForm.scss | 18 ++++++- src/Common/RJSF/widgets/Select.tsx | 41 +++------------ .../SelectPicker/SelectPicker.component.tsx | 10 ++-- src/Shared/Components/SelectPicker/type.ts | 1 + 7 files changed, 63 insertions(+), 62 deletions(-) diff --git a/src/Common/RJSF/Form.tsx b/src/Common/RJSF/Form.tsx index c8b650439..dc4491a29 100644 --- a/src/Common/RJSF/Form.tsx +++ b/src/Common/RJSF/Form.tsx @@ -20,6 +20,7 @@ import RJSF from '@rjsf/core' import { SCHEMA_07_VALIDATOR } from '@Shared/validations' import { templates, widgets } from './config' +import { RJSF_FORM_SELECT_PORTAL_TARGET_ID } from './constants' import { FormProps } from './types' import { getFormStateFromFormData, @@ -82,28 +83,32 @@ export const RJSFForm = forwardRef((props: FormProps, ref: FormProps['ref']) => } return ( -
+ <> + + {/* NOTE: due to stacking context issues, we send this id to SelectPicker menuPortalTarget prop */} +
+ ) }) diff --git a/src/Common/RJSF/common/FieldRow.tsx b/src/Common/RJSF/common/FieldRow.tsx index 5286413c0..c4cb47add 100644 --- a/src/Common/RJSF/common/FieldRow.tsx +++ b/src/Common/RJSF/common/FieldRow.tsx @@ -31,7 +31,7 @@ export const FieldRowWithLabel = ({
diff --git a/src/Common/RJSF/constants.ts b/src/Common/RJSF/constants.ts index b638e59ce..081011bbe 100644 --- a/src/Common/RJSF/constants.ts +++ b/src/Common/RJSF/constants.ts @@ -27,3 +27,5 @@ export const HIDE_SUBMIT_BUTTON_UI_SCHEMA = { norender: true, }, } + +export const RJSF_FORM_SELECT_PORTAL_TARGET_ID = 'rjsf-form-select-portal-target' diff --git a/src/Common/RJSF/rjsfForm.scss b/src/Common/RJSF/rjsfForm.scss index 3a158ed33..6fe501a82 100644 --- a/src/Common/RJSF/rjsfForm.scss +++ b/src/Common/RJSF/rjsfForm.scss @@ -33,6 +33,22 @@ } } + &__field { + align-items: center; + } + + &__field:has([class^="devtron-rjsf-select__"]), + &__field.rjsf-form-template__field--align-top { + align-items: flex-start; + } + + &__field:has([class^="devtron-rjsf-select__"]) { + label { + line-height: 32px; + } + } + + &__additional-fields { grid-template-columns: 1fr 1fr; } @@ -90,4 +106,4 @@ transition: all 100ms ease-out; } } -} +} \ No newline at end of file diff --git a/src/Common/RJSF/widgets/Select.tsx b/src/Common/RJSF/widgets/Select.tsx index 51cb637bf..6be6296b0 100644 --- a/src/Common/RJSF/widgets/Select.tsx +++ b/src/Common/RJSF/widgets/Select.tsx @@ -15,22 +15,10 @@ */ import { WidgetProps } from '@rjsf/utils' -import ReactSelect, { MenuListProps, components } from 'react-select' -import { PLACEHOLDERS } from '../constants' +import { PLACEHOLDERS, RJSF_FORM_SELECT_PORTAL_TARGET_ID } from '../constants' -import { ReactComponent as ArrowDown } from '../../../Assets/Icon/ic-chevron-down.svg' import { deepEqual } from '@Common/Helper' -import { commonSelectStyles } from '@Shared/Components' - -const MenuList = ({ children, ...props }: MenuListProps) => ( - {Array.isArray(children) ? children.slice(0, 20) : children} -) - -const DropdownIndicator = (props) => ( - - - -) +import { SelectPicker } from '@Shared/Components' export const SelectWidget = (props: WidgetProps) => { const { @@ -47,7 +35,7 @@ export const SelectWidget = (props: WidgetProps) => { placeholder, } = props const { enumOptions: selectOptions = [] } = options - const emptyValue = multiple ? [] : '' + const emptyValue = multiple ? [] : null const handleChange = (option) => { onChange(multiple ? option.map((o) => o.value) : option.value) @@ -59,8 +47,8 @@ export const SelectWidget = (props: WidgetProps) => { : selectOptions.find((option) => deepEqual(value, option.value)) return ( - { onFocus={() => onFocus(id, value)} placeholder={placeholder || PLACEHOLDERS.SELECT} isDisabled={disabled || readonly} - styles={{ - ...commonSelectStyles, - control: (base, state) => ({ - ...commonSelectStyles.control(base, state), - minHeight: '36px', - }), - multiValue: (base, state) => ({ - ...commonSelectStyles.multiValue(base, state), - margin: '2px 8px 2px 2px', - }), - }} - components={{ - IndicatorSeparator: null, - DropdownIndicator, - MenuList, - }} - menuPlacement="auto" + menuPortalTarget={document.getElementById(RJSF_FORM_SELECT_PORTAL_TARGET_ID)} + menuPosition='fixed' /> ) } diff --git a/src/Shared/Components/SelectPicker/SelectPicker.component.tsx b/src/Shared/Components/SelectPicker/SelectPicker.component.tsx index 482dd211a..9f2039689 100644 --- a/src/Shared/Components/SelectPicker/SelectPicker.component.tsx +++ b/src/Shared/Components/SelectPicker/SelectPicker.component.tsx @@ -376,8 +376,12 @@ const SelectPicker = ({ onKeyDown?.(e) } - const handleFocus: ReactSelectProps['onFocus'] = () => { - setIsFocussed(true) + const handleFocus: ReactSelectProps['onFocus'] = (e) => { + if (!shouldRenderTextArea) { + setIsFocussed(true) + } + + props.onFocus?.(e) } const handleBlur: ReactSelectProps['onFocus'] = (e) => { @@ -483,7 +487,7 @@ const SelectPicker = ({ onKeyDown={handleKeyDown} shouldRenderTextArea={shouldRenderTextArea} customDisplayText={customDisplayText} - {...(!shouldRenderTextArea ? { onFocus: handleFocus } : {})} + onFocus={handleFocus} onBlur={handleBlur} onChange={handleChange} controlShouldRenderValue={controlShouldRenderValue} diff --git a/src/Shared/Components/SelectPicker/type.ts b/src/Shared/Components/SelectPicker/type.ts index 4052142d4..e82f6ead6 100644 --- a/src/Shared/Components/SelectPicker/type.ts +++ b/src/Shared/Components/SelectPicker/type.ts @@ -170,6 +170,7 @@ export type SelectPickerProps & Partial< Pick< From a4fb38a2631b7fd896bf776b669c28b24c58ffb7 Mon Sep 17 00:00:00 2001 From: Amrit Kashyap Borah Date: Wed, 9 Jul 2025 17:00:12 +0530 Subject: [PATCH 59/90] chore: bump version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 91d5373e2..46810e012 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.17.0-pre-1", + "version": "1.17.0-pre-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.17.0-pre-1", + "version": "1.17.0-pre-2", "hasInstallScript": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index 1076e2ab1..6bec5c27a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@devtron-labs/devtron-fe-common-lib", - "version": "1.17.0-pre-1", + "version": "1.17.0-pre-2", "description": "Supporting common component library", "type": "module", "main": "dist/index.js", From 32617c60ad6b3c8c51b5378b6a0ddf089762190b Mon Sep 17 00:00:00 2001 From: Rohit Raj Date: Thu, 10 Jul 2025 12:23:16 +0530 Subject: [PATCH 60/90] feat: add ESLint rule for IllustrationBase component usage --- .eslintrc.cjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index c2e95c310..ab8bbe579 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -151,6 +151,10 @@ module.exports = { group: ['IconBase'], message: 'Please use "Icon" component instead.', }, + { + group: ['IllustrationBase'], + message: 'Please use "Illustration" component instead.', + }, ], }, ], From c2aced36ebf3f738a073c1c778a9bc01b5568367 Mon Sep 17 00:00:00 2001 From: Amrit Kashyap Borah Date: Thu, 10 Jul 2025 12:43:07 +0530 Subject: [PATCH 61/90] feat: add support for placeholder field (string|number) --- .eslintignore | 1 - src/Common/Modals/Modal.tsx | 2 +- src/Common/RJSF/common/FieldRow.tsx | 2 +- src/Common/RJSF/templates/BaseInput.tsx | 20 ++++++++++++-------- src/Common/RJSF/templates/TitleField.tsx | 2 +- src/Shared/validations.tsx | 12 ++++++++++++ 6 files changed, 27 insertions(+), 12 deletions(-) diff --git a/.eslintignore b/.eslintignore index 0adc76113..ffd57a46d 100755 --- a/.eslintignore +++ b/.eslintignore @@ -39,7 +39,6 @@ src/Common/PopupMenu.tsx src/Common/Progressing.tsx src/Common/RJSF/config.ts src/Common/RJSF/templates/ArrayFieldTemplate.tsx -src/Common/RJSF/templates/BaseInput.tsx src/Common/RJSF/templates/ButtonTemplates/AddButton.tsx src/Common/RJSF/templates/ButtonTemplates/RemoveButton.tsx src/Common/RJSF/templates/ButtonTemplates/SubmitButton.tsx diff --git a/src/Common/Modals/Modal.tsx b/src/Common/Modals/Modal.tsx index 92c159caa..e58bd9578 100644 --- a/src/Common/Modals/Modal.tsx +++ b/src/Common/Modals/Modal.tsx @@ -45,7 +45,7 @@ export const Modal = ({ function disableWheel(e) { if (!preventWheelDisable) { - if (innerRef?.current.contains(e.target)) { + if (innerRef.current?.contains(e.target)) { if (innerRef.current.clientHeight === innerRef.current.scrollHeight) { e.preventDefault() } diff --git a/src/Common/RJSF/common/FieldRow.tsx b/src/Common/RJSF/common/FieldRow.tsx index c4cb47add..9640cdac4 100644 --- a/src/Common/RJSF/common/FieldRow.tsx +++ b/src/Common/RJSF/common/FieldRow.tsx @@ -38,7 +38,7 @@ export const FieldRowWithLabel = ({ {showLabel && (