Skip to content

Commit

Permalink
feat(ui/console): add copy as curl button (#2474)
Browse files Browse the repository at this point in the history
* feat(ui/console): add copy-as-curl button

* feat(ui/console): handle auth case in generating curl command

* fix(ui): make jest resolve modules aliases

* chore: Update ui/src/app/console/Console.tsx

* chore: Update ui/src/app/console/Console.tsx

---------

Co-authored-by: Mark Phelps <209477+markphelps@users.noreply.github.com>
  • Loading branch information
AmineRhazzar and markphelps committed Dec 2, 2023
1 parent 0e86ff8 commit 44e34fa
Show file tree
Hide file tree
Showing 7 changed files with 141 additions and 17 deletions.
4 changes: 3 additions & 1 deletion ui/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ export default {
// ],

// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
// moduleNameMapper: {},
moduleNameMapper: {
"^~/(.*)$": "<rootDir>/src/$1"
},

// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
Expand Down
116 changes: 104 additions & 12 deletions ui/src/app/console/Console.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Form, Formik, useFormikContext } from 'formik';
import hljs from 'highlight.js';
import javascript from 'highlight.js/lib/languages/json';
import 'highlight.js/styles/tomorrow-night-bright.css';
import React, { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { v4 as uuidv4 } from 'uuid';
Expand All @@ -14,21 +14,34 @@ import EmptyState from '~/components/EmptyState';
import Button from '~/components/forms/buttons/Button';
import Combobox from '~/components/forms/Combobox';
import Input from '~/components/forms/Input';
import { evaluateV2, listFlags } from '~/data/api';
import {
evaluateURL,
evaluateV2,
listAuthMethods,
listFlags
} from '~/data/api';
import { useError } from '~/data/hooks/error';
import { useSuccess } from '~/data/hooks/success';
import {
jsonValidation,
keyValidation,
requiredValidation
} from '~/data/validations';
import { IAuthMethod, IAuthMethodList } from '~/types/Auth';
import {
FilterableFlag,
FlagType,
flagTypeToLabel,
IFlag,
IFlagList
} from '~/types/Flag';
import { INamespace } from '~/types/Namespace';
import { classNames, getErrorMessage } from '~/utils/helpers';
import {
classNames,
copyTextToClipboard,
generateCurlCommand,
getErrorMessage
} from '~/utils/helpers';

hljs.registerLanguage('json', javascript);

Expand All @@ -53,12 +66,16 @@ export default function Console() {
const [selectedFlag, setSelectedFlag] = useState<FilterableFlag | null>(null);
const [response, setResponse] = useState<string | null>(null);
const [hasEvaluationError, setHasEvaluationError] = useState<boolean>(false);
const [isAuthRequired, setIsAuthRequired] = useState<boolean>(false);

const { setError, clearError } = useError();
const navigate = useNavigate();
const { setSuccess } = useSuccess();

const namespace = useSelector(selectCurrentNamespace);

const codeRef = useRef<HTMLElement>(null);

const loadData = useCallback(async () => {
const initialFlagList = (await listFlags(namespace.key)) as IFlagList;
const { flags } = initialFlagList;
Expand All @@ -77,6 +94,20 @@ export default function Console() {
);
}, [namespace.key]);

const checkIsAuthRequired = useCallback(() => {
listAuthMethods()
.then((resp: IAuthMethodList) => {
const enabledAuthMethods = resp.methods.filter(
(m: IAuthMethod) => m.enabled
);
setIsAuthRequired(enabledAuthMethods.length != 0);
clearError();
})
.catch((err) => {
setError(err);
});
}, [setError, clearError]);

const handleSubmit = (flag: IFlag | null, values: ConsoleFormValues) => {
const { entityId, context } = values;

Expand Down Expand Up @@ -110,9 +141,54 @@ export default function Console() {
});
};

const handleCopyAsCurl = (values: ConsoleFormValues) => {
let parsed = null;
try {
// need to unescape the context string
parsed = JSON.parse(values.context);
} catch (err) {
setHasEvaluationError(true);
setError('Context provided is invalid.');
return;
}
const uri =
window.location.origin +
evaluateURL +
(selectedFlag?.type === FlagType.BOOLEAN ? '/boolean' : '/variant');

let headers: Record<string, string> = {};

if (isAuthRequired) {
// user can generate an auth token and use it
headers.Authorization = 'Bearer <api-token>';
}

const command = generateCurlCommand({
method: 'POST',
body: {
...values,
context: parsed,
namespaceKey: namespace.key
},
headers,
uri
});

copyTextToClipboard(command);

setSuccess(

Check warning on line 179 in ui/src/app/console/Console.tsx

View workflow job for this annotation

GitHub Actions / Lint UI

Replace `⏎······'Command·copied·to·clipboard'⏎····` with `'Command·copied·to·clipboard'`
'Command copied to clipboard'
);
};

useEffect(() => {
if (codeRef.current) {
// must unset property 'highlighted' so that it can be highlighted again
// otherwise it gets highlighted the first time only
delete codeRef.current.dataset.highlighted;
}
hljs.highlightAll();
}, [response]);
}, [response, codeRef]);

useEffect(() => {
loadData()
Expand All @@ -122,6 +198,10 @@ export default function Console() {
});
}, [clearError, loadData, setError]);

useEffect(() => {
checkIsAuthRequired();
}, [checkIsAuthRequired]);

const initialvalues: ConsoleFormValues = {
flagKey: selectedFlag?.key || '',
entityId: uuidv4(),
Expand Down Expand Up @@ -223,6 +303,16 @@ export default function Console() {
</div>
</div>
<div className="flex justify-end">
<Button
className="ml-3"
type="button"
disabled={!(formik.dirty && formik.isValid)}
onClick={() => {
handleCopyAsCurl(formik.values);
}}
>
Copy as curl
</Button>
<Button
primary
className="ml-3"
Expand All @@ -241,14 +331,16 @@ export default function Console() {
<div className="mt-8 w-full overflow-hidden md:w-1/2 md:pl-4">
{response && (
<pre className="p-2 text-sm md:h-full">
<code
className={classNames(
hasEvaluationError ? 'border-red-400 border-4' : '',
'json rounded-sm md:h-full'
)}
>
{response as React.ReactNode}
</code>
{hasEvaluationError ? (
<p className="border-red-400 border-4">{response}</p>
) : (
<code
className="hljs json rounded-sm md:h-full"
ref={codeRef}
>
{response as React.ReactNode}
</code>
)}
</pre>
)}
{!response && (
Expand Down
5 changes: 3 additions & 2 deletions ui/src/components/console/ContextEditor.module.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.ContextEditor {
width: 100vw;
height: 50vh;
width: 100%;
min-height: 50vh;
height: 100%;
}
2 changes: 1 addition & 1 deletion ui/src/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { IVariantBase } from '~/types/Variant';

export const apiURL = '/api/v1';
const authURL = '/auth/v1';
const evaluateURL = '/evaluate/v1';
export const evaluateURL = '/evaluate/v1';
const metaURL = '/meta';
const csrfTokenHeaderKey = 'x-csrf-token';
const sessionKey = 'session';
Expand Down
6 changes: 5 additions & 1 deletion ui/src/types/Auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
export interface IAuthMethod {
method: 'METHOD_TOKEN' | 'METHOD_OIDC' | 'METHOD_GITHUB';
method:
| 'METHOD_TOKEN'
| 'METHOD_OIDC'
| 'METHOD_GITHUB'
| 'METHOD_KUBERNETES';
enabled: boolean;
sessionCompatible: boolean;
metadata: { [key: string]: any };
Expand Down
6 changes: 6 additions & 0 deletions ui/src/types/Curl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface ICurlOptions {
method: 'GET' | 'POST'; // maybe we'll need to extend this in the future
headers?: Record<string, string>;
body?: any;
uri: string;
}
19 changes: 19 additions & 0 deletions ui/src/utils/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { defaultHeaders } from '~/data/api';
import { ICurlOptions } from '~/types/Curl';

export function classNames(...classes: string[]) {
return classes.filter(Boolean).join(' ');
}
Expand Down Expand Up @@ -87,3 +90,19 @@ function toErrorWithMessage(maybeError: unknown): ErrorWithMessage {
export function getErrorMessage(error: unknown) {
return toErrorWithMessage(error).message;
}

export function generateCurlCommand(curlOptions: ICurlOptions) {
const headers = { ...defaultHeaders(), ...curlOptions.headers };
const curlHeaders = Object.keys(headers)
.map((key) => `-H "${key}: ${headers[key]}"`)
.join(' ');

const curlData = `-d '${JSON.stringify(curlOptions.body)}'`;
return [
'curl',
`-X ${curlOptions.method}`,
curlHeaders,
curlData,
curlOptions.uri
].join(' ');
}

0 comments on commit 44e34fa

Please sign in to comment.