From 8e3863a27e6505a0a64b31dbb0817bf05cc525de Mon Sep 17 00:00:00 2001 From: andreas-unleash Date: Wed, 25 Oct 2023 17:55:33 +0300 Subject: [PATCH] feat: add input for api token in playground (#5130) Adds a token input in playground. In the case of tokens that span multiple projects ie `[]:development.etc` it will look into the token definitions to find the token and get the projects Otherwise it will try to parse the project and environment from the token itself (without checking for it being a valid token) Also, does not support admin tokens `*:*.etc` Closes # [1-1507](https://linear.app/unleash/issue/1-1507/create-a-token-input-in-the-playground-form) Screenshot 2023-10-23 at 16 38 11 Screenshot 2023-10-23 at 16 38 33 Screenshot 2023-10-25 at 17 06 43 --------- Signed-off-by: andreas-unleash --- .../Playground/AdvancedPlayground.tsx | 18 +- .../PlaygroundConnectionFieldset.test.tsx | 150 ++++++++++++ .../PlaygroundConnectionFieldset.tsx | 219 ++++++++++++++---- .../PlaygroundForm/PlaygroundForm.tsx | 6 + .../Playground/playground.utils.test.ts | 46 ++++ .../playground/Playground/playground.utils.ts | 17 ++ 6 files changed, 411 insertions(+), 45 deletions(-) create mode 100644 frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundConnectionFieldset/PlaygroundConnectionFieldset.test.tsx diff --git a/frontend/src/component/playground/Playground/AdvancedPlayground.tsx b/frontend/src/component/playground/Playground/AdvancedPlayground.tsx index 6bb3557365..d0e00bb063 100644 --- a/frontend/src/component/playground/Playground/AdvancedPlayground.tsx +++ b/frontend/src/component/playground/Playground/AdvancedPlayground.tsx @@ -17,11 +17,11 @@ import { } from './playground.utils'; import { PlaygroundGuidance } from './PlaygroundGuidance/PlaygroundGuidance'; import { PlaygroundGuidancePopper } from './PlaygroundGuidancePopper/PlaygroundGuidancePopper'; -import Loader from '../../common/Loader/Loader'; +import Loader from 'component/common/Loader/Loader'; import { AdvancedPlaygroundResultsTable } from './AdvancedPlaygroundResultsTable/AdvancedPlaygroundResultsTable'; import { AdvancedPlaygroundResponseSchema } from 'openapi'; import { createLocalStorage } from 'utils/createLocalStorage'; -import { BadRequestError } from '../../../utils/apiUtils'; +import { BadRequestError } from 'utils/apiUtils'; const StyledAlert = styled(Alert)(({ theme }) => ({ marginBottom: theme.spacing(3), @@ -34,6 +34,7 @@ export const AdvancedPlayground: VFC<{ projects: string[]; environments: string[]; context?: string; + token?: string; } = { projects: [], environments: [] }; const { value, setValue } = createLocalStorage( 'AdvancedPlayground:v1', @@ -49,6 +50,7 @@ export const AdvancedPlayground: VFC<{ value.environments, ); const [projects, setProjects] = useState(value.projects); + const [token, setToken] = useState(value.token); const [context, setContext] = useState(value.context); const [results, setResults] = useState< AdvancedPlaygroundResponseSchema | undefined @@ -76,6 +78,7 @@ export const AdvancedPlayground: VFC<{ const environments = resolveEnvironmentsFromUrl(); const projects = resolveProjectsFromUrl(); const context = resolveContextFromUrl(); + const token = resolveTokenFromUrl(); const makePlaygroundRequest = async () => { if (environments && context) { await evaluatePlaygroundContext( @@ -124,6 +127,15 @@ export const AdvancedPlayground: VFC<{ return contextFromUrl; }; + const resolveTokenFromUrl = () => { + let tokenFromUrl = searchParams.get('token'); + if (tokenFromUrl) { + tokenFromUrl = decodeURI(tokenFromUrl); + setToken(tokenFromUrl); + } + return tokenFromUrl; + }; + const evaluatePlaygroundContext = async ( environments: string[] | string, projects: string[] | string, @@ -249,6 +261,8 @@ export const AdvancedPlayground: VFC<{ availableEnvironments={availableEnvironments} projects={projects} environments={environments} + token={token} + setToken={setToken} setProjects={setProjects} setEnvironments={setEnvironments} /> diff --git a/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundConnectionFieldset/PlaygroundConnectionFieldset.test.tsx b/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundConnectionFieldset/PlaygroundConnectionFieldset.test.tsx new file mode 100644 index 0000000000..cd0242d4e8 --- /dev/null +++ b/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundConnectionFieldset/PlaygroundConnectionFieldset.test.tsx @@ -0,0 +1,150 @@ +import { testServerRoute, testServerSetup } from 'utils/testServer'; +import { render } from 'utils/testRenderer'; +import { fireEvent, screen, within } from '@testing-library/react'; +import { PlaygroundConnectionFieldset } from './PlaygroundConnectionFieldset'; +import { useState } from 'react'; + +const server = testServerSetup(); + +beforeEach(() => { + testServerRoute(server, '/api/admin/ui-config', { + versionInfo: { + current: { oss: 'version', enterprise: 'version' }, + }, + flags: { + playgroundImprovements: true, + }, + }); + testServerRoute( + server, + '/api/admin/projects', + { + projects: [ + { + id: 'default', + name: 'Default', + }, + { + id: 'MyProject', + name: 'MyProject', + }, + ], + }, + 'get', + 200, + ); + testServerRoute( + server, + '/api/admin/api-tokens', + { + tokens: [ + { + secret: '[]:development.964a287e1b728cb5f4f3e0120df92cb5', + projects: ['default', 'MyProject'], + }, + ], + }, + 'get', + 200, + ); +}); + +const Component = () => { + const [environments, setEnvironments] = useState([]); + const [projects, setProjects] = useState([]); + const [token, setToken] = useState(); + + const availableEnvironments = ['development', 'production']; + + return ( + + ); +}; + +test('should parse project and environment from token input', async () => { + render(); + + const tokenInput = await screen.findByLabelText('Api token'); + fireEvent.change(tokenInput, { + target: { + value: 'default:development.964a287e1b728cb5f4f3e0120df92cb5', + }, + }); + + const projectAutocomplete = await screen.findByTestId( + 'PLAYGROUND_PROJECT_SELECT', + ); + const projectInput = within(projectAutocomplete).getByRole('combobox'); + + const environmentAutocomplete = await screen.findByTestId( + 'PLAYGROUND_ENVIRONMENT_SELECT', + ); + const environmentInput = within(environmentAutocomplete).getByRole( + 'combobox', + ); + + expect(projectInput).toBeDisabled(); + expect(environmentInput).toBeDisabled(); + await within(projectAutocomplete).findByText('Default'); + await within(environmentAutocomplete).findByText('development'); +}); + +test('should load projects from token definition if project is []', async () => { + render(); + + const tokenInput = await screen.findByLabelText('Api token'); + fireEvent.change(tokenInput, { + target: { value: '[]:development.964a287e1b728cb5f4f3e0120df92cb5' }, + }); + + const projectAutocomplete = await screen.findByTestId( + 'PLAYGROUND_PROJECT_SELECT', + ); + const projectInput = within(projectAutocomplete).getByRole('combobox'); + + const environmentAutocomplete = await screen.findByTestId( + 'PLAYGROUND_ENVIRONMENT_SELECT', + ); + const environmentInput = within(environmentAutocomplete).getByRole( + 'combobox', + ); + + expect(projectInput).toBeDisabled(); + expect(environmentInput).toBeDisabled(); + await within(projectAutocomplete).findByText('Default'); + await within(projectAutocomplete).findByText('MyProject'); + await within(environmentAutocomplete).findByText('development'); +}); + +test('should show an error when admin token', async () => { + render(); + + const tokenInput = await screen.findByLabelText('Api token'); + fireEvent.change(tokenInput, { + target: { value: '*:*.964a287e1b728cb5f4f3e0120df92cb5' }, + }); + + const projectAutocomplete = await screen.findByTestId( + 'PLAYGROUND_PROJECT_SELECT', + ); + const projectInput = within(projectAutocomplete).getByRole('combobox'); + + const environmentAutocomplete = await screen.findByTestId( + 'PLAYGROUND_ENVIRONMENT_SELECT', + ); + const environmentInput = within(environmentAutocomplete).getByRole( + 'combobox', + ); + + expect(projectInput).toBeDisabled(); + expect(environmentInput).toBeDisabled(); + await screen.findByText('Admin tokens are not supported in the playground'); +}); diff --git a/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundConnectionFieldset/PlaygroundConnectionFieldset.tsx b/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundConnectionFieldset/PlaygroundConnectionFieldset.tsx index 9bb4b7de80..a8be4be041 100644 --- a/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundConnectionFieldset/PlaygroundConnectionFieldset.tsx +++ b/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundConnectionFieldset/PlaygroundConnectionFieldset.tsx @@ -1,19 +1,33 @@ -import React, { ComponentProps, VFC } from 'react'; +import React, { ComponentProps, useState, VFC } from 'react'; import { Autocomplete, Box, TextField, + Tooltip, Typography, useTheme, } from '@mui/material'; import useProjects from 'hooks/api/getters/useProjects/useProjects'; import { renderOption } from '../renderOption'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { useUiFlag } from 'hooks/useUiFlag'; +import { + IApiToken, + useApiTokens, +} from 'hooks/api/getters/useApiTokens/useApiTokens'; +import Input from 'component/common/Input/Input'; +import { + extractProjectEnvironmentFromToken, + validateTokenFormat, +} from '../../playground.utils'; interface IPlaygroundConnectionFieldsetProps { environments: string[]; projects: string[]; + token?: string; setProjects: (projects: string[]) => void; setEnvironments: (environments: string[]) => void; + setToken?: (token: string) => void; availableEnvironments: string[]; } @@ -29,11 +43,16 @@ export const PlaygroundConnectionFieldset: VFC< > = ({ environments, projects, + token, setProjects, setEnvironments, + setToken, availableEnvironments, }) => { const theme = useTheme(); + const playgroundImprovements = useUiFlag('playgroundImprovements'); + const { tokens } = useApiTokens(); + const [tokenError, setTokenError] = useState(); const { projects: availableProjects = [] } = useProjects(); const projectsOptions = [ @@ -96,12 +115,92 @@ export const PlaygroundConnectionFieldset: VFC< }; const isAllProjects = - projects.length === 0 || (projects.length === 1 && projects[0] === '*'); + projects && + (projects.length === 0 || + (projects.length === 1 && projects[0] === '*')); const envValue = environmentOptions.filter(({ id }) => environments.includes(id), ); + const onSetToken: ComponentProps['onChange'] = async ( + event, + ) => { + const tempToken = event.target.value; + setToken?.(tempToken); + + if (tempToken === '') { + resetTokenState(); + return; + } + + try { + validateTokenFormat(tempToken); + setTokenError(undefined); + processToken(tempToken); + } catch (e: any) { + setTokenError(e.message); + } + }; + + const processToken = (tempToken: string) => { + const [tokenProject, tokenEnvironment] = + extractProjectEnvironmentFromToken(tempToken); + setEnvironments([tokenEnvironment]); + + switch (tokenProject) { + case '[]': + handleTokenWithSomeProjects(tempToken); + break; + case '*': + handleTokenWithAllProjects(); + break; + default: + handleSpecificProjectToken(tokenProject); + } + }; + + const updateProjectsBasedOnValidToken = (validToken: IApiToken) => { + if (!validToken.projects || validToken.projects === '*') { + setProjects([allOption.id]); + } else if (typeof validToken.projects === 'string') { + setProjects([validToken.projects]); + } else if (Array.isArray(validToken.projects)) { + setProjects(validToken.projects); + } + }; + + const handleTokenWithSomeProjects = (tempToken: string) => { + const validToken = tokens.find(({ secret }) => secret === tempToken); + if (validToken) { + updateProjectsBasedOnValidToken(validToken); + } else { + setTokenError( + 'Invalid token. Ensure you use a valid token from this Unleash instance.', + ); + } + }; + + const handleTokenWithAllProjects = () => { + setProjects([allOption.id]); + }; + + const handleSpecificProjectToken = (tokenProject: string) => { + if ( + !projectsOptions.map((option) => option.id).includes(tokenProject) + ) { + setTokenError( + `Invalid token. Project ${tokenProject} does not exist.`, + ); + } else { + setProjects([tokenProject]); + } + }; + + const resetTokenState = () => { + setTokenError(undefined); + }; + return ( @@ -114,49 +213,83 @@ export const PlaygroundConnectionFieldset: VFC< - ( - - )} - renderOption={renderOption} - getOptionLabel={({ label }) => label} - disableCloseOnSelect={false} - size='small' - value={envValue} - onChange={onEnvironmentsChange} - data-testid={'PLAYGROUND_ENVIRONMENT_SELECT'} - /> - ( - - )} - renderOption={renderOption} - getOptionLabel={({ label }) => label} - disableCloseOnSelect - size='small' - value={ - isAllProjects - ? allOption - : projectsOptions.filter(({ id }) => - projects.includes(id), - ) + + > + ( + + )} + renderOption={renderOption} + getOptionLabel={({ label }) => label} + disableCloseOnSelect={false} + size='small' + value={envValue} + onChange={onEnvironmentsChange} + disabled={Boolean(token)} + data-testid={'PLAYGROUND_ENVIRONMENT_SELECT'} + /> + + + ( + + )} + renderOption={renderOption} + getOptionLabel={({ label }) => label} + disableCloseOnSelect + size='small' + value={ + isAllProjects + ? allOption + : projectsOptions.filter(({ id }) => + projects.includes(id), + ) + } + onChange={onProjectsChange} + disabled={Boolean(token)} + data-testid={'PLAYGROUND_PROJECT_SELECT'} + /> + + + } + /> ); }; diff --git a/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundForm.tsx b/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundForm.tsx index 8a01617182..e8ffb362ae 100644 --- a/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundForm.tsx +++ b/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundForm.tsx @@ -9,6 +9,8 @@ interface IPlaygroundFormProps { onSubmit: (event: FormEvent) => void; environments: string | string[]; projects: string[]; + token?: string; + setToken?: React.Dispatch>; setProjects: React.Dispatch>; setEnvironments: React.Dispatch>; context: string | undefined; @@ -20,6 +22,8 @@ export const PlaygroundForm: VFC = ({ environments, onSubmit, projects, + token, + setToken, setProjects, setEnvironments, context, @@ -39,6 +43,8 @@ export const PlaygroundForm: VFC = ({ Array.isArray(environments) ? environments : [environments] } projects={projects} + token={token} + setToken={setToken} setEnvironments={setEnvironments} setProjects={setProjects} availableEnvironments={availableEnvironments.map( diff --git a/frontend/src/component/playground/Playground/playground.utils.test.ts b/frontend/src/component/playground/Playground/playground.utils.test.ts index 16e53201e7..153fd5a46f 100644 --- a/frontend/src/component/playground/Playground/playground.utils.test.ts +++ b/frontend/src/component/playground/Playground/playground.utils.test.ts @@ -1,6 +1,7 @@ import { normalizeCustomContextProperties, NormalizedContextProperties, + validateTokenFormat, } from './playground.utils'; test('should keep standard properties in their place', () => { @@ -76,3 +77,48 @@ test('should add multiple standard properties without breaking custom properties }, }); }); + +describe('validateTokenFormat', () => { + it('should throw an error for invalid token format without colon', () => { + const invalidToken = 'invalidToken'; + expect(() => validateTokenFormat(invalidToken)).toThrow( + 'Invalid token format', + ); + }); + + it('should not throw an error for invalid token format without period', () => { + const invalidToken = 'project:environment'; + expect(() => validateTokenFormat(invalidToken)).not.toThrow(); + }); + + it('should throw an error for tokens with an empty project', () => { + const invalidToken = ':environment.abc123'; + expect(() => validateTokenFormat(invalidToken)).toThrow( + 'Invalid token format', + ); + }); + + it('should throw an error for tokens with an empty environment', () => { + const invalidToken = 'project:.abc123'; + expect(() => validateTokenFormat(invalidToken)).toThrow( + 'Invalid token format', + ); + }); + + it('should throw an error for admin tokens', () => { + const adminToken = 'project:*.abc123'; + expect(() => validateTokenFormat(adminToken)).toThrow( + 'Admin tokens are not supported in the playground', + ); + }); + + it('should not throw an error for valid token formats', () => { + const validToken = 'project:environment.abc123'; + expect(() => validateTokenFormat(validToken)).not.toThrow(); + }); + + it('should not throw an error for valid token format and all projects', () => { + const validToken = '*:environment.abc123'; + expect(() => validateTokenFormat(validToken)).not.toThrow(); + }); +}); diff --git a/frontend/src/component/playground/Playground/playground.utils.ts b/frontend/src/component/playground/Playground/playground.utils.ts index 74a47f9533..a7220fdd59 100644 --- a/frontend/src/component/playground/Playground/playground.utils.ts +++ b/frontend/src/component/playground/Playground/playground.utils.ts @@ -125,3 +125,20 @@ export const normalizeCustomContextProperties = ( return output; }; + +export const validateTokenFormat = (token: string): void => { + const [projectEnvAccess] = token.split('.'); + const [project, environment] = projectEnvAccess.split(':'); + if (!project || !environment) { + throw new Error('Invalid token format'); + } + + if (environment === '*') { + throw new Error('Admin tokens are not supported in the playground'); + } +}; + +export const extractProjectEnvironmentFromToken = (token: string) => { + const [projectEnvAccess] = token.split('.'); + return projectEnvAccess.split(':'); +};