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(console, phrases): implement the copy, clear and reset button #5490

Merged
merged 4 commits into from
Mar 11, 2024
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useTranslation } from 'react-i18next';

import ClearIcon from '@/assets/icons/clear.svg';

import ActionButton from './index';

type Props = {
onClick: () => void;
};

function CodeClearButton({ onClick }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });

return (
<ActionButton
actionTip={t('jwt_claims.clear')}
actionSuccessTip={t('jwt_claims.cleared')}
icon={<ClearIcon />}
onClick={onClick}
/>
);
}

export default CodeClearButton;
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useTranslation } from 'react-i18next';

import RedoIcon from '@/assets/icons/redo.svg';

import ActionButton from './index';

type Props = {
onClick: () => void;
};

function CodeRestoreButton({ onClick }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });

return (
<ActionButton
actionTip={t('jwt_claims.restore')}
actionSuccessTip={t('jwt_claims.restored')}
icon={<RedoIcon />}
onClick={onClick}
/>
);
}

export default CodeRestoreButton;
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useCallback, useRef, useState, type MouseEventHandler, useEffect } from 'react';

import IconButton from '@/ds-components/IconButton';
import { Tooltip } from '@/ds-components/Tip';

type Props = {
actionTip: string;
actionSuccessTip: string;
actionLoadingTip?: string;
className?: string;
icon: React.ReactNode;
onClick: () => Promise<void> | void;
};

function ActionButton({
actionTip,
actionSuccessTip,
actionLoadingTip,
className,
icon,
onClick,
}: Props) {
const [tipContent, setTipContent] = useState(actionTip);
const iconButtonRef = useRef<HTMLButtonElement>(null);

useEffect(() => {
const mouseLeaveHandler = () => {
setTipContent(actionTip);
};

iconButtonRef.current?.addEventListener('mouseleave', mouseLeaveHandler);

return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps -- iconButtonRef.current is not a dependency
iconButtonRef.current?.removeEventListener('mouseleave', mouseLeaveHandler);
};
});

const handleClick = useCallback<MouseEventHandler<HTMLButtonElement>>(async () => {
iconButtonRef.current?.blur();

if (actionLoadingTip) {
setTipContent(actionLoadingTip);
}

await onClick();
setTipContent(actionSuccessTip);
}, [actionLoadingTip, actionSuccessTip, onClick]);

return (
<div className={className}>
<Tooltip content={tipContent} isSuccessful={tipContent === actionSuccessTip}>
<IconButton ref={iconButtonRef} size="small" onClick={handleClick}>
{icon}
</IconButton>
</Tooltip>
</div>
);
}

export default ActionButton;
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
}
}

.actions {
.actionButtons {
display: flex;
gap: _.unit(2);
align-items: center;
Expand Down
127 changes: 77 additions & 50 deletions packages/console/src/pages/JwtClaims/MonacoCodeEditor/index.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,69 @@
import { Editor, type BeforeMount, type OnMount, useMonaco } from '@monaco-editor/react';
import { type Nullable } from '@silverhand/essentials';
import classNames from 'classnames';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { useCallback, useEffect, useMemo, useRef } from 'react';

import Copy from '@/assets/icons/copy.svg';
import IconButton from '@/ds-components/IconButton';
import CopyToClipboard from '@/ds-components/CopyToClipboard';
import { onKeyDownHandler } from '@/utils/a11y';

import CodeClearButton from './ActionButton/CodeClearButton.js';
import CodeRestoreButton from './ActionButton/CodeRestoreButton.js';
import { logtoDarkTheme, defaultOptions } from './config.js';
import * as styles from './index.module.scss';
import type { IStandaloneCodeEditor, Model } from './type.js';
import type { IStandaloneCodeEditor, ModelSettings } from './type.js';
import useEditorHeight from './use-editor-height.js';

export type { Model } from './type.js';
export type { ModelSettings, ModelControl } from './type.js';

type ActionButtonType = 'clear' | 'restore' | 'copy';

type Props = {
className?: string;
actions?: React.ReactNode;
models: Model[];
enabledActions?: ActionButtonType[];
models: ModelSettings[];
activeModelName?: string;
setActiveModel?: (name: string) => void;
value?: string;
onChange?: (value: string | undefined) => void;
};

function MonacoCodeEditor({ className, actions, models }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
/**
* Monaco code editor component.
* @param {Props} prop
* @param {string} [prop.className] - The class name of the component.
* @param {ActionButtonType[]} prop.enabledActions - The enabled action buttons, available values are 'clear', 'restore', 'copy'.
* @param {ModelSettings[]} prop.models - The static model settings (all tabs) for the code editor.
* @param {string} prop.activeModelName - The active model name.
* @param {(name: string) => void} prop.setActiveModel - The callback function to set the active model. Used to switch between tabs.
* @param {string} prop.value - The value of the code editor for the current active model.
* @param {(value: string | undefined) => void} prop.onChange - The callback function to handle the value change of the code editor.
*
* @returns
*/
function MonacoCodeEditor({
className,
enabledActions = ['copy'],
models,
activeModelName,
value,
setActiveModel,
onChange,
}: Props) {
const monaco = useMonaco();
const editorRef = useRef<Nullable<IStandaloneCodeEditor>>(null);

const [activeModelName, setActiveModelName] = useState<string>();
console.log('code', value);

const activeModel = useMemo(
() => models.find((model) => model.name === activeModelName),
() => activeModelName && models.find((model) => model.name === activeModelName),
[activeModelName, models]
);

const isMultiModals = useMemo(() => models.length > 1, [models]);

// Get the container ref and the editor height
const { containerRef, editorHeight } = useEditorHeight();

// Set the first model as the active model
useEffect(() => {
setActiveModelName(models[0]?.name);
}, [models]);

useEffect(() => {
// Add global declarations
// monaco will be ready after the editor is mounted, useEffect will be called after the monaco is ready
// Monaco will be ready after the editor is mounted, useEffect will be called after the monaco is ready
if (!monaco || !activeModel) {
return;
}
Expand All @@ -62,16 +80,6 @@ function MonacoCodeEditor({ className, actions, models }: Props) {
}
}, [activeModel, monaco]);

const handleCodeCopy = useCallback(async () => {
const editor = editorRef.current;

if (editor) {
const code = editor.getValue();
await navigator.clipboard.writeText(code);
toast.success(t('general.copied'));
}
}, [t]);

const handleEditorWillMount = useCallback<BeforeMount>((monaco) => {
// Register the new logto theme
monaco.editor.defineTheme('logto-dark', logtoDarkTheme);
Expand All @@ -98,10 +106,10 @@ function MonacoCodeEditor({ className, actions, models }: Props) {
role: 'button',
tabIndex: 0,
onClick: () => {
setActiveModelName(name);
setActiveModel?.(name);
},
onKeyDown: onKeyDownHandler(() => {
setActiveModelName(name);
setActiveModel?.(name);
}),
})}
>
Expand All @@ -110,25 +118,44 @@ function MonacoCodeEditor({ className, actions, models }: Props) {
</div>
))}
</div>
<div className={styles.actions}>
{actions}
<IconButton size="small" onClick={handleCodeCopy}>
<Copy />
</IconButton>
<div className={styles.actionButtons}>
{enabledActions.includes('clear') && (
<CodeClearButton
onClick={() => {
if (activeModel) {
onChange?.(undefined);
}
}}
/>
)}
{enabledActions.includes('restore') && (
<CodeRestoreButton
onClick={() => {
if (activeModel) {
onChange?.(activeModel.defaultValue);
}
}}
/>
)}
{enabledActions.includes('copy') && (
<CopyToClipboard variant="icon" value={editorRef.current?.getValue() ?? ''} />
)}
</div>
</header>
<div ref={containerRef} className={styles.editorContainer}>
<Editor
height={editorHeight}
language={activeModel?.language ?? 'typescript'}
// TODO: need to check on the usage of value and defaultValue
defaultValue={activeModel?.defaultValue}
path={activeModel?.name}
theme="logto-dark"
options={defaultOptions}
beforeMount={handleEditorWillMount}
onMount={handleEditorDidMount}
/>
{activeModel && (
<Editor
height={editorHeight}
language={activeModel.language}
path={activeModel.name}
theme="logto-dark"
options={defaultOptions}
value={value ?? activeModel.defaultValue}
beforeMount={handleEditorWillMount}
onMount={handleEditorDidMount}
onChange={onChange}
/>
)}
</div>
</div>
);
Expand Down
15 changes: 13 additions & 2 deletions packages/console/src/pages/JwtClaims/MonacoCodeEditor/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,25 @@ export type IStandaloneThemeData = Parameters<Monaco['editor']['defineTheme']>[1

export type IStandaloneCodeEditor = Parameters<OnMount>[0];

export type Model = {
export type ModelSettings = {
/** Used as the unique key for the monaco editor model @see {@link https://github.com/suren-atoyan/monaco-react?tab=readme-ov-file#multi-model-editor} */
name: string;
/** The icon of the model, will be displayed on the tab */
icon?: React.ReactNode;
/** The title of the model */
title: string;
defaultValue: string;
/** The default value of the file */
defaultValue?: string;
value?: string;
language: string;
/** ExtraLibs can be loaded to the code editor
* @see {@link https://microsoft.github.io/monaco-editor/typedoc/interfaces/languages.typescript.LanguageServiceDefaults.html#setExtraLibs}
* We use this to load the global type declarations for the active model
*/
globalDeclarations?: string;
};

export type ModelControl = {
value?: string;
onChange?: (value: string | undefined) => void;
};
36 changes: 27 additions & 9 deletions packages/console/src/pages/JwtClaims/ScriptSection.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
/* Code Editor for the custom JWT claims script. */
import { useMemo } from 'react';
import { useFormContext } from 'react-hook-form';
import { useFormContext, Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';

import Card from '@/ds-components/Card';

import MonacoCodeEditor, { type Model } from './MonacoCodeEditor';
import MonacoCodeEditor, { type ModelSettings } from './MonacoCodeEditor';
import { userJwtFile, machineToMachineJwtFile, JwtTokenType } from './config';
import * as styles from './index.module.scss';
import { type JwtClaimsFormType } from './type';
Expand All @@ -18,22 +18,40 @@ const titlePhrases = Object.freeze({
function ScriptSection() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });

const { watch } = useFormContext<JwtClaimsFormType>();
const { watch, control } = useFormContext<JwtClaimsFormType>();
const tokenType = watch('tokenType');

// TODO: API integration, read/write the custom claims code value
const activeModel = useMemo<Model>(() => {
return tokenType === JwtTokenType.UserAccessToken ? userJwtFile : machineToMachineJwtFile;
}, [tokenType]);

const activeModel = useMemo<ModelSettings>(
() => (tokenType === JwtTokenType.UserAccessToken ? userJwtFile : machineToMachineJwtFile),
[tokenType]
);
return (
<Card className={styles.codePanel}>
<div className={styles.cardTitle}>
{t('jwt_claims.code_editor_title', {
token: t(`jwt_claims.${titlePhrases[tokenType]}`),
})}
</div>
<MonacoCodeEditor className={styles.flexGrow} models={[activeModel]} />
<Controller
// Force rerender the controller when the token type changes
// Otherwise the input field will not be updated
key={tokenType}
shouldUnregister
control={control}
name="script"
render={({ field: { onChange, value } }) => (
<MonacoCodeEditor
className={styles.flexGrow}
enabledActions={['clear', 'copy']}
models={[activeModel]}
activeModelName={activeModel.name}
value={value}
onChange={(newValue) => {
onChange(newValue);
}}
/>
)}
/>
</Card>
);
}
Expand Down
Loading
Loading