Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ui/console): add copy as curl button #2474

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 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 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 @@
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 @@
);
}, [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 @@
});
};

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 @@
});
}, [clearError, loadData, setError]);

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

const initialvalues: ConsoleFormValues = {
flagKey: selectedFlag?.key || '',
entityId: uuidv4(),
Expand Down Expand Up @@ -223,6 +303,16 @@
</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 @@
<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';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch!

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(' ');
}