diff --git a/redisinsight/ui/src/components/environment-badge/EnvironmentBadge.constants.ts b/redisinsight/ui/src/components/environment-badge/EnvironmentBadge.constants.ts new file mode 100644 index 0000000000..55dc3b1dc4 --- /dev/null +++ b/redisinsight/ui/src/components/environment-badge/EnvironmentBadge.constants.ts @@ -0,0 +1,24 @@ +import { Environment } from 'apiClient' + +import { EnvironmentBadgeConfig } from './EnvironmentBadge.types' + +export const PRODUCTION_TOOLTIP = + 'Production — Adds an extra layer of protection to prevent unintended changes. Includes additional confirmation dialogs before modifying data and stronger friction before running dangerous commands.' + +export const DEVELOPMENT_TOOLTIP = + 'Development — Skips standard confirmation dialogs when modifying data, for faster work on development and test databases.' + +export const BADGE_CONFIG: Partial< + Record +> = { + [Environment.Production]: { + label: 'PROD', + variant: 'danger', + tooltip: PRODUCTION_TOOLTIP, + }, + [Environment.Development]: { + label: 'DEV', + variant: 'default', + tooltip: DEVELOPMENT_TOOLTIP, + }, +} diff --git a/redisinsight/ui/src/components/environment-badge/EnvironmentBadge.spec.tsx b/redisinsight/ui/src/components/environment-badge/EnvironmentBadge.spec.tsx new file mode 100644 index 0000000000..c0726b6d1f --- /dev/null +++ b/redisinsight/ui/src/components/environment-badge/EnvironmentBadge.spec.tsx @@ -0,0 +1,87 @@ +import { cloneDeep, set } from 'lodash' +import React from 'react' + +import { Environment } from 'apiClient' +import { FeatureFlags } from 'uiSrc/constants' +import { + initialStateDefault, + mockStore, + render, + screen, +} from 'uiSrc/utils/test-utils' + +import { EnvironmentBadge } from './EnvironmentBadge' + +const withDevProdFlag = (flag: boolean) => { + const state = set( + cloneDeep(initialStateDefault), + `app.features.featureFlags.features.${FeatureFlags.devProdMode}`, + { flag }, + ) + return { store: mockStore(state) } +} + +describe('EnvironmentBadge', () => { + it('renders the PROD badge for production environment', () => { + render( + , + withDevProdFlag(true), + ) + + expect( + screen.getByTestId(`environment-badge-${Environment.Production}`), + ).toBeInTheDocument() + expect(screen.getByText('PROD')).toBeInTheDocument() + }) + + it('renders the DEV label for development environment', () => { + render( + , + withDevProdFlag(true), + ) + + expect( + screen.getByTestId(`environment-badge-${Environment.Development}`), + ).toBeInTheDocument() + expect(screen.getByText('DEV')).toBeInTheDocument() + }) + + it('renders nothing for unspecified environment', () => { + const { container } = render( + , + withDevProdFlag(true), + ) + + expect(container).toBeEmptyDOMElement() + }) + + it('renders nothing when environment is undefined', () => { + const { container } = render( + , + withDevProdFlag(true), + ) + + expect(container).toBeEmptyDOMElement() + }) + + it('renders nothing when the dev-prodMode feature flag is off', () => { + const { container } = render( + , + withDevProdFlag(false), + ) + + expect(container).toBeEmptyDOMElement() + }) + + it('uses the provided dataTestId override', () => { + render( + , + withDevProdFlag(true), + ) + + expect(screen.getByTestId('custom-badge')).toBeInTheDocument() + }) +}) diff --git a/redisinsight/ui/src/components/environment-badge/EnvironmentBadge.tsx b/redisinsight/ui/src/components/environment-badge/EnvironmentBadge.tsx new file mode 100644 index 0000000000..6e77cce6a0 --- /dev/null +++ b/redisinsight/ui/src/components/environment-badge/EnvironmentBadge.tsx @@ -0,0 +1,33 @@ +import React from 'react' +import { useSelector } from 'react-redux' + +import { appFeatureFlagDevProdModeSelector } from 'uiSrc/slices/app/features' +import { RiBadge } from 'uiSrc/components/base/display/badge/RiBadge' +import { RiTooltip } from 'uiSrc/components/base/tooltip/RITooltip' + +import { EnvironmentBadgeProps } from './EnvironmentBadge.types' +import { BADGE_CONFIG } from './EnvironmentBadge.constants' + +export const EnvironmentBadge = ({ + environment, + dataTestId, +}: EnvironmentBadgeProps) => { + const flagEnabled = useSelector(appFeatureFlagDevProdModeSelector) + + if (!flagEnabled || !environment) return null + + const config = BADGE_CONFIG[environment] + if (!config) return null + + return ( + + + + ) +} + +export default EnvironmentBadge diff --git a/redisinsight/ui/src/components/environment-badge/EnvironmentBadge.types.ts b/redisinsight/ui/src/components/environment-badge/EnvironmentBadge.types.ts new file mode 100644 index 0000000000..747a509743 --- /dev/null +++ b/redisinsight/ui/src/components/environment-badge/EnvironmentBadge.types.ts @@ -0,0 +1,15 @@ +import React from 'react' +import { Environment } from 'apiClient' + +import { BadgeVariants } from 'uiSrc/components/base/display/badge/RiBadge' + +export interface EnvironmentBadgeProps { + environment?: Environment + dataTestId?: string +} + +export interface EnvironmentBadgeConfig { + label: string + variant: BadgeVariants + tooltip: React.ReactNode +} diff --git a/redisinsight/ui/src/components/environment-badge/index.ts b/redisinsight/ui/src/components/environment-badge/index.ts new file mode 100644 index 0000000000..f923f87d48 --- /dev/null +++ b/redisinsight/ui/src/components/environment-badge/index.ts @@ -0,0 +1,2 @@ +export { EnvironmentBadge } from './EnvironmentBadge' +export type { EnvironmentBadgeProps } from './EnvironmentBadge.types' diff --git a/redisinsight/ui/src/components/instance-header/InstanceHeader.spec.tsx b/redisinsight/ui/src/components/instance-header/InstanceHeader.spec.tsx index 824e995b71..4b7fd08090 100644 --- a/redisinsight/ui/src/components/instance-header/InstanceHeader.spec.tsx +++ b/redisinsight/ui/src/components/instance-header/InstanceHeader.spec.tsx @@ -2,6 +2,7 @@ import { cloneDeep, set } from 'lodash' import React from 'react' import reactRouterDom from 'react-router-dom' import { instance, mock } from 'ts-mockito' +import { Environment } from 'apiClient' import { cleanup, act, @@ -66,13 +67,18 @@ jest.mock('react-router-dom', () => ({ }), })) +const mockedConnectedInstanceSelector = connectedInstanceSelector as jest.Mock +const mockedConnectedInstanceInfoSelector = + connectedInstanceInfoSelector as jest.Mock +const mockedAppContextDbIndex = appContextDbIndex as jest.Mock + describe('InstanceHeader', () => { it('should render', () => { expect(render()).toBeTruthy() }) it('should render change index button with databases = 1', () => { - ;(connectedInstanceInfoSelector as jest.Mock).mockReturnValueOnce({ + mockedConnectedInstanceInfoSelector.mockReturnValueOnce({ databases: 1, }) @@ -114,7 +120,7 @@ describe('InstanceHeader', () => { }) it('should be disabled db index button with loading state', () => { - ;(connectedInstanceSelector as jest.Mock).mockReturnValueOnce({ + mockedConnectedInstanceSelector.mockReturnValueOnce({ loading: true, }) @@ -124,7 +130,7 @@ describe('InstanceHeader', () => { }) it('should be disabled db index button with disabled state', () => { - ;(appContextDbIndex as jest.Mock).mockReturnValueOnce({ + mockedAppContextDbIndex.mockReturnValueOnce({ disabled: true, }) @@ -256,6 +262,94 @@ describe('InstanceHeader', () => { ).toHaveTextContent('Test account #40') }) + describe('environment indicator', () => { + const renderWithEnv = (env: Environment) => { + mockedConnectedInstanceSelector.mockReturnValue({ + username: 'username', + id: 'instanceId', + loading: false, + environment: env, + }) + + const state = set( + cloneDeep(initialStateDefault), + `app.features.featureFlags.features.${FeatureFlags.devProdMode}`, + { flag: true }, + ) + + return render(, { + store: mockStore(state), + }) + } + + it('renders the PROD badge and marks the header as production', () => { + renderWithEnv(Environment.Production) + + expect(screen.getByTestId('instance-header')).toHaveAttribute( + 'data-environment', + Environment.Production, + ) + expect( + screen.getByTestId(`environment-badge-${Environment.Production}`), + ).toBeInTheDocument() + }) + + it('renders the Development label without the production data attribute', () => { + renderWithEnv(Environment.Development) + + expect(screen.getByTestId('instance-header')).toHaveAttribute( + 'data-environment', + Environment.Development, + ) + expect( + screen.getByTestId(`environment-badge-${Environment.Development}`), + ).toBeInTheDocument() + }) + + it('renders no environment badge for Unspecified', () => { + renderWithEnv(Environment.Unspecified) + + expect(screen.getByTestId('instance-header')).toHaveAttribute( + 'data-environment', + Environment.Unspecified, + ) + expect( + screen.queryByTestId(`environment-badge-${Environment.Production}`), + ).not.toBeInTheDocument() + expect( + screen.queryByTestId(`environment-badge-${Environment.Development}`), + ).not.toBeInTheDocument() + }) + + it('does not render the badge when the dev-prodMode flag is off', () => { + mockedConnectedInstanceSelector.mockReturnValue({ + username: 'username', + id: 'instanceId', + loading: false, + environment: Environment.Production, + }) + + const state = set( + cloneDeep(initialStateDefault), + `app.features.featureFlags.features.${FeatureFlags.devProdMode}`, + { flag: false }, + ) + + render(, { + store: mockStore(state), + }) + + expect( + screen.queryByTestId(`environment-badge-${Environment.Production}`), + ).not.toBeInTheDocument() + // hook returns Unspecified when the flag is off, even if the stored env is Production + expect(screen.getByTestId('instance-header')).toHaveAttribute( + 'data-environment', + Environment.Unspecified, + ) + }) + }) + it('should not show sso user profile if cloud ads feature is off', async () => { const initialStoreState = set( cloneDeep(initialStateDefault), diff --git a/redisinsight/ui/src/components/instance-header/InstanceHeader.styles.ts b/redisinsight/ui/src/components/instance-header/InstanceHeader.styles.ts new file mode 100644 index 0000000000..e95653c03e --- /dev/null +++ b/redisinsight/ui/src/components/instance-header/InstanceHeader.styles.ts @@ -0,0 +1,25 @@ +import React from 'react' +import styled, { css } from 'styled-components' + +import { FlexItem } from 'uiSrc/components/base/layout/flex' + +interface InstanceHeaderContainerProps + extends React.HTMLAttributes { + $isProductionEnv?: boolean +} + +const productionTint = css` + background-color: ${({ theme }) => theme.semantic.color.background.danger100}; +` + +export const InstanceHeaderContainer = styled.div` + ${({ $isProductionEnv }) => $isProductionEnv && productionTint} +` + +export const EnvironmentBadgeSlot = styled(FlexItem)` + padding-left: 8px; + + &:empty { + display: none; + } +` diff --git a/redisinsight/ui/src/components/instance-header/InstanceHeader.tsx b/redisinsight/ui/src/components/instance-header/InstanceHeader.tsx index f20d844234..d67c5023b8 100644 --- a/redisinsight/ui/src/components/instance-header/InstanceHeader.tsx +++ b/redisinsight/ui/src/components/instance-header/InstanceHeader.tsx @@ -3,8 +3,11 @@ import { useDispatch, useSelector } from 'react-redux' import { useHistory } from 'react-router-dom' import cx from 'classnames' import { useTheme } from '@redis-ui/styles' +import { Environment } from 'apiClient' import { FeatureFlags, Pages } from 'uiSrc/constants' +import { useDatabaseEnvironment } from 'uiSrc/components/hooks/useDatabaseEnvironment' +import { EnvironmentBadge } from 'uiSrc/components/environment-badge' import { selectOnFocus } from 'uiSrc/utils' import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/telemetry' import { BuildType } from 'uiSrc/constants/env' @@ -46,6 +49,10 @@ import { NumericInput } from 'uiSrc/components/base/inputs' import { RiIcon } from 'uiSrc/components/base/icons/RiIcon' import { Link } from 'uiSrc/components/base/link/Link' import InstancesNavigationPopover from './components/instances-navigation-popover' +import { + EnvironmentBadgeSlot, + InstanceHeaderContainer, +} from './InstanceHeader.styles' import styles from './styles.module.scss' const riConfig = getConfig() @@ -82,6 +89,8 @@ const InstanceHeader = ({ onChangeDbIndex }: Props) => { databaseChatFeature, documentationChatFeature, ]) + const { environment } = useDatabaseEnvironment() + const isProductionEnv = environment === Environment.Production const history = useHistory() const [dbIndex, setDbIndex] = useState(String(db || 0)) @@ -133,11 +142,14 @@ const InstanceHeader = ({ onChangeDbIndex }: Props) => { } return ( -
{ )} + + + {databases > 1 && (
{ -
+ ) } diff --git a/redisinsight/ui/src/components/instance-header/components/instances-navigation-popover/components/instances-list/InstancesList.tsx b/redisinsight/ui/src/components/instance-header/components/instances-navigation-popover/components/instances-list/InstancesList.tsx index e7c602c5d5..dc35bc3cc1 100644 --- a/redisinsight/ui/src/components/instance-header/components/instances-navigation-popover/components/instances-list/InstancesList.tsx +++ b/redisinsight/ui/src/components/instance-header/components/instances-navigation-popover/components/instances-list/InstancesList.tsx @@ -72,6 +72,7 @@ const InstancesList = ({ databaseId: instance.id, source: 'navigation_panel', provider: instance.provider, + environment: instance.environment, ...modulesSummary, ...infoData, }, diff --git a/redisinsight/ui/src/components/oauth/oauth-connect-free-db/OAuthConnectFreeDb.spec.tsx b/redisinsight/ui/src/components/oauth/oauth-connect-free-db/OAuthConnectFreeDb.spec.tsx index 8ac9ac0fe9..c8a4cfd1b6 100644 --- a/redisinsight/ui/src/components/oauth/oauth-connect-free-db/OAuthConnectFreeDb.spec.tsx +++ b/redisinsight/ui/src/components/oauth/oauth-connect-free-db/OAuthConnectFreeDb.spec.tsx @@ -1,5 +1,6 @@ import React from 'react' import { cloneDeep } from 'lodash' +import { Environment } from 'apiClient' import { cleanup, mockedStore, render, userEvent } from 'uiSrc/utils/test-utils' import { getRedisModulesSummary, @@ -28,14 +29,19 @@ jest.mock('uiSrc/slices/oauth/cloud', () => ({ }), })) -jest.mock('uiSrc/slices/instances/instances', () => ({ - ...jest.requireActual('uiSrc/slices/instances/instances'), - freeInstancesSelector: jest.fn().mockReturnValue([ - { - id: 'instanceId', - }, - ]), -})) +jest.mock('uiSrc/slices/instances/instances', () => { + // eslint-disable-next-line global-require + const { Environment: MockEnvironment } = require('apiClient') + return { + ...jest.requireActual('uiSrc/slices/instances/instances'), + freeInstancesSelector: jest.fn().mockReturnValue([ + { + id: 'instanceId', + environment: MockEnvironment.Production, + }, + ]), + } +}) let store: typeof mockedStore beforeEach(() => { @@ -68,6 +74,7 @@ describe('OAuthConnectFreeDb', () => { databaseId: 'providedId', provider: undefined, source: OAuthSocialSource.ListOfDatabases, + environment: Environment.Production, ...getRedisModulesSummary(), ...MOCK_ADDITIONAL_INFO, }, diff --git a/redisinsight/ui/src/components/oauth/oauth-connect-free-db/OAuthConnectFreeDb.tsx b/redisinsight/ui/src/components/oauth/oauth-connect-free-db/OAuthConnectFreeDb.tsx index c2426dc252..14a11fa88e 100644 --- a/redisinsight/ui/src/components/oauth/oauth-connect-free-db/OAuthConnectFreeDb.tsx +++ b/redisinsight/ui/src/components/oauth/oauth-connect-free-db/OAuthConnectFreeDb.tsx @@ -41,6 +41,7 @@ const OAuthConnectFreeDb = ({ const [firstFreeInstance] = useSelector(freeInstancesSelector) ?? [] const targetDatabaseId = id || firstFreeInstance?.id + const targetEnvironment = firstFreeInstance?.environment const dispatch = useDispatch() const { search } = useLocation() @@ -58,6 +59,7 @@ const OAuthConnectFreeDb = ({ databaseId: targetDatabaseId, provider, source, + environment: targetEnvironment, ...modulesSummary, ...infoData, }, diff --git a/redisinsight/ui/src/pages/home/components/databases-list/components/DatabasesListCellName/DatabasesListCellName.spec.tsx b/redisinsight/ui/src/pages/home/components/databases-list/components/DatabasesListCellName/DatabasesListCellName.spec.tsx new file mode 100644 index 0000000000..e77d993fd6 --- /dev/null +++ b/redisinsight/ui/src/pages/home/components/databases-list/components/DatabasesListCellName/DatabasesListCellName.spec.tsx @@ -0,0 +1,112 @@ +import { cloneDeep, set } from 'lodash' +import React from 'react' + +import { Environment } from 'apiClient' +import { Instance } from 'uiSrc/slices/interfaces' +import { FeatureFlags } from 'uiSrc/constants' +import { + initialStateDefault, + mockStore, + render, + screen, +} from 'uiSrc/utils/test-utils' + +import DatabasesListCellName from './DatabasesListCellName' + +const renderCell = ( + instance: Partial, + { devProdMode = true }: { devProdMode?: boolean } = {}, +) => { + const state = set( + cloneDeep(initialStateDefault), + `app.features.featureFlags.features.${FeatureFlags.devProdMode}`, + { flag: devProdMode }, + ) + + const cellProps = { row: { original: instance as Instance } } as any + return render(, { + store: mockStore(state), + }) +} + +describe('DatabasesListCellName', () => { + const baseInstance: Partial = { + id: 'db-1', + name: 'My Database', + db: 0, + new: false, + cloudDetails: undefined, + } + + describe('environment badge', () => { + it('renders the PROD badge when environment is Production', () => { + renderCell({ ...baseInstance, environment: Environment.Production }) + + expect(screen.getByTestId('environment-badge-db-1')).toBeInTheDocument() + expect(screen.getByText('PROD')).toBeInTheDocument() + }) + + it('renders the DEV label when environment is Development', () => { + renderCell({ ...baseInstance, environment: Environment.Development }) + + expect(screen.getByTestId('environment-badge-db-1')).toBeInTheDocument() + expect(screen.getByText('DEV')).toBeInTheDocument() + }) + + it('renders no badge when environment is Unspecified', () => { + renderCell({ ...baseInstance, environment: Environment.Unspecified }) + + expect( + screen.queryByTestId('environment-badge-db-1'), + ).not.toBeInTheDocument() + expect(screen.queryByText('PROD')).not.toBeInTheDocument() + expect(screen.queryByText('DEV')).not.toBeInTheDocument() + }) + + it('does not render the badge when the dev-prodMode flag is off', () => { + renderCell( + { ...baseInstance, environment: Environment.Production }, + { devProdMode: false }, + ) + + expect( + screen.queryByTestId('environment-badge-db-1'), + ).not.toBeInTheDocument() + }) + }) + + describe('with a temporary indicator', () => { + it('renders the env badge alongside the "new" temp indicator', () => { + renderCell({ + ...baseInstance, + new: true, + environment: Environment.Production, + }) + + expect(screen.getByTestId('database-status-new-db-1')).toBeInTheDocument() + expect(screen.getByTestId('environment-badge-db-1')).toBeInTheDocument() + }) + + it('renders the env badge alongside the cloud warning indicator', () => { + const fiveDaysAgo = new Date() + fiveDaysAgo.setDate(fiveDaysAgo.getDate() - 5) + + renderCell({ + ...baseInstance, + lastConnection: fiveDaysAgo, + cloudDetails: { free: true } as Instance['cloudDetails'], + environment: Environment.Development, + }) + + expect(screen.getByTestId('environment-badge-db-1')).toBeInTheDocument() + }) + }) + + it('renders the database name', () => { + renderCell({ ...baseInstance, name: 'production-cache', db: 3 }) + + expect(screen.getByTestId('instance-name-db-1')).toHaveTextContent( + 'production-cache', + ) + }) +}) diff --git a/redisinsight/ui/src/pages/home/components/databases-list/components/DatabasesListCellName/DatabasesListCellName.tsx b/redisinsight/ui/src/pages/home/components/databases-list/components/DatabasesListCellName/DatabasesListCellName.tsx index f7ffecc3de..1870b3bfeb 100644 --- a/redisinsight/ui/src/pages/home/components/databases-list/components/DatabasesListCellName/DatabasesListCellName.tsx +++ b/redisinsight/ui/src/pages/home/components/databases-list/components/DatabasesListCellName/DatabasesListCellName.tsx @@ -5,6 +5,7 @@ import { RiTooltip } from 'uiSrc/components' import { replaceSpaces, formatLongName, getDbIndex } from 'uiSrc/utils' import DbStatus from 'uiSrc/pages/home/components/db-status' import { Text } from 'uiSrc/components/base/text' +import { EnvironmentBadge } from 'uiSrc/components/environment-badge' import { StyledCellNameWrapper } from './DatabasesListCellName.styles' @@ -18,6 +19,7 @@ const DatabasesListCellName: IDatabaseListCell = ({ row }) => { lastConnection, createdAt, cloudDetails, + environment, } = instance const cellContent = replaceSpaces(name.substring(0, 200)) @@ -32,6 +34,10 @@ const DatabasesListCellName: IDatabaseListCell = ({ row }) => { isFree={cloudDetails?.free} />
+ { @@ -97,6 +100,7 @@ describe('databases-list methods/handlers', () => { eventData: expect.objectContaining({ databaseId: '1', provider: 'LOCALHOST', + environment: Environment.Production, }), }), ) diff --git a/redisinsight/ui/src/pages/home/components/databases-list/methods/handlers.ts b/redisinsight/ui/src/pages/home/components/databases-list/methods/handlers.ts index 4cd2d87086..b080e7646a 100644 --- a/redisinsight/ui/src/pages/home/components/databases-list/methods/handlers.ts +++ b/redisinsight/ui/src/pages/home/components/databases-list/methods/handlers.ts @@ -32,7 +32,7 @@ const connectToInstance = (id: string) => { } export const handleCheckConnectToInstance = async (instance: Instance) => { - const { id, provider, modules } = instance + const { id, provider, modules, environment } = instance const { contextInstanceId } = appContextSelector(store.getState()) dispatch( @@ -53,6 +53,7 @@ export const handleCheckConnectToInstance = async (instance: Instance) => { databaseId: id, provider, source: 'db_list', + environment, ...modulesSummary, ...infoData, },