Skip to content

Commit

Permalink
Use monaco-editor for code display (#173)
Browse files Browse the repository at this point in the history
  • Loading branch information
carbonrobot authored Nov 13, 2023
1 parent 267b024 commit 2bc08f4
Show file tree
Hide file tree
Showing 21 changed files with 279 additions and 446 deletions.
5 changes: 5 additions & 0 deletions .changeset/early-hairs-hug.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@envyjs/webui': minor
---

Use monaco-editor for code display
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@
"wait-on": "^7.0.1"
},
"resolutions": {
"@microlink/react-json-view/react": ">=17",
"react-hot-toast/csstype": "^3.0.10"
},
"lint-staged": {
Expand Down
6 changes: 2 additions & 4 deletions packages/webui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@
},
"dependencies": {
"@envyjs/core": "0.8.4",
"@monaco-editor/react": "4.6.0",
"chalk": "^4.1.2",
"monaco-editor": "0.44.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"serve-handler": "^6.1.5",
Expand All @@ -71,7 +73,6 @@
"yargs-parser": "^21.1.1"
},
"devDependencies": {
"@microlink/react-json-view": "^1.22.2",
"@parcel/config-default": "^2.9.3",
"@parcel/core": "^2.9.3",
"@storybook/cli": "^7.4.6",
Expand Down Expand Up @@ -115,9 +116,6 @@
"ts-jest-mock-import-meta": "^1.1.0",
"vite": "^4.4.9"
},
"peerDependencies": {
"@microlink/react-json-view": "^1.22.2"
},
"browserslist": [
">0.2%",
"not ie <= 11"
Expand Down
5 changes: 2 additions & 3 deletions packages/webui/src/components/Authorization.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@ jest.mock('@/components', () => ({
IconButton: function ({ short, Icon, ...safeProps }: any) {
return <button {...safeProps} />;
},
JsonDisplay: function ({ children, ...props }: any) {
const value = typeof children === 'object' ? JSON.stringify(children) : children;
return <div {...props}>{value}</div>;
CodeDisplay: function ({ data, contentType, ...props }: any) {
return <div {...props}>{data}</div>;
},
}));

Expand Down
23 changes: 13 additions & 10 deletions packages/webui/src/components/Authorization.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { safeParseJson } from '@envyjs/core';
import { ChevronDown, ChevronUp, Code2, MoreHorizontal } from 'lucide-react';
import React, { useEffect, useState } from 'react';

import { Code, IconButton, JsonDisplay } from '@/components';
import { Code, CodeDisplay, IconButton } from '@/components';
import { tw } from '@/utils';

enum TokenType {
Expand Down Expand Up @@ -53,7 +52,7 @@ export default function Authorization({ value }: AuthorizationProps) {
if (!value) return null;

function decodeToken(type: TokenType, token: string) {
let decoded;
let decoded = '';

if (type === TokenType.JWT) {
const base64Url = token.split('.')[1];
Expand All @@ -67,13 +66,13 @@ export default function Authorization({ value }: AuthorizationProps) {
.join(''),
);

decoded = safeParseJson(jsonPayload).value;
decoded = jsonPayload;
} else if (type === TokenType.BasicAuth) {
const [un, pw] = atob(token).split(':');
decoded = { username: un, password: pw };
decoded = JSON.stringify({ username: un, password: pw });
}

setDecodedToken(<JsonDisplay>{decoded}</JsonDisplay>);
setDecodedToken(<CodeDisplay contentType="application/json" data={decoded} />);
}

return (
Expand All @@ -84,10 +83,10 @@ export default function Authorization({ value }: AuthorizationProps) {
return (
<div
data-test-id="token-minimal-view"
className="flex"
className="flex hover:bg-gray-100 rounded-sm cursor-pointer"
onClick={() => setTokenState(TokenState.Expanded)}
>
<div className="overflow-y-hidden">
<div className="clamp">
{type} {token}
</div>
<div className="flex items-center justify-end ml-auto">
Expand All @@ -98,11 +97,15 @@ export default function Authorization({ value }: AuthorizationProps) {
case TokenState.Expanded:
return <Code data-test-id="token-expanded-view">{`${type} ${token}`}</Code>;
case TokenState.Decoded:
return <div data-test-id="token-decoded-view">{decodedToken}</div>;
return (
<div data-test-id="token-decoded-view" className="h-[300px]">
{decodedToken}
</div>
);
}
})()}
{tokenState !== TokenState.Minimal && (
<div className={tw('flex flex-row gap-2 bg-slate-100 px-4 pt-4')}>
<div className={tw('flex flex-row gap-2 bg-gray-200 px-4 pt-4')}>
<>
<IconButton
data-test-id="token-expanded-button"
Expand Down
53 changes: 53 additions & 0 deletions packages/webui/src/components/CodeDisplay.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { cleanup, render, waitFor } from '@testing-library/react';

import CodeDisplay from './CodeDisplay';

jest.mock(
'./MonacoEditor',
() =>
function MockEditor({ value, language }: any) {
return <div data-test-id={`lang-${language}`}>{value}</div>;
},
);

describe('CodeDisplay', () => {
afterEach(() => {
cleanup();
});

it('should parse application/json', async () => {
const data = { foo: 'bar' };
const { getByTestId } = render(<CodeDisplay data={JSON.stringify(data)} contentType="application/json" />);

const reactJson = await waitFor(() => getByTestId('lang-json'));
expect(reactJson).toHaveTextContent('{ "foo": "bar" }');
});

it('should parse application/graphql-response+json as json', async () => {
const data = { foo: 'bar' };
const { getByTestId } = render(
<CodeDisplay data={JSON.stringify(data)} contentType="application/graphql-response+json" />,
);

const reactJson = await waitFor(() => getByTestId('lang-json'));
expect(reactJson).toHaveTextContent('{ "foo": "bar" }');
});

it('should parse application/json with charset', async () => {
const data = { foo: 'bar' };
const { getByTestId } = render(
<CodeDisplay data={JSON.stringify(data)} contentType="application/json; ; charset=utf-8" />,
);

const reactJson = await waitFor(() => getByTestId('lang-json'));
expect(reactJson).toHaveTextContent('{ "foo": "bar" }');
});

it('should use txt language when contentType is undefined', async () => {
const data = { foo: 'bar' };
const { getByTestId } = render(<CodeDisplay data={JSON.stringify(data)} />);

const reactJson = await waitFor(() => getByTestId('lang-txt'));
expect(reactJson).toHaveTextContent('{"foo":"bar"}');
});
});
49 changes: 49 additions & 0 deletions packages/webui/src/components/CodeDisplay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { safeParseJson } from '@envyjs/core';
import formatXml from 'xml-formatter';

import Editor, { MonacoEditorProps } from './MonacoEditor';

type CodeDisplayProps = {
contentType?: string | string[] | null;
data: string | null | undefined;
};

const languageMap: Record<string, MonacoEditorProps['language']> = {
'application/json': 'json',
'application/graphql-response+json': 'json',
'application/xml': 'xml',
};

export default function CodeDisplay({ data, contentType }: CodeDisplayProps) {
if (!data) {
return;
}

// content types can be an array or a string value
// each value in the array or string can be a content type with a charset
// example: [content-type: application/json; charset=utf-8]
let resolvedContentType = Array.isArray(contentType) ? contentType[0] : contentType;
resolvedContentType = resolvedContentType && resolvedContentType.split(';')[0];
const lang = resolvedContentType ? languageMap[resolvedContentType as string] : 'txt';

let value = data;
if (lang === 'json') {
const parseResult = safeParseJson(data);
if (parseResult.value) {
value = JSON.stringify(parseResult.value, null, 2);
}
} else if (lang === 'xml') {
value = formatXml(data, {
indentation: ' ',
lineSeparator: '\n',
collapseContent: true,
whiteSpaceAtEndOfSelfclosingTag: true,
});
}

return (
<div className="w-full h-full">
<Editor value={value} language={lang} />
</div>
);
}
45 changes: 0 additions & 45 deletions packages/webui/src/components/JsonDisplay.test.tsx

This file was deleted.

60 changes: 0 additions & 60 deletions packages/webui/src/components/JsonDisplay.tsx

This file was deleted.

50 changes: 50 additions & 0 deletions packages/webui/src/components/MonacoEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Editor, EditorProps, OnMount } from '@monaco-editor/react';
import { useEffect, useRef } from 'react';
import colors from 'tailwindcss/colors';

export type MonacoEditorProps = Pick<EditorProps, 'value' | 'language'>;

const editorOptions: EditorProps['options'] = {
minimap: {
enabled: false,
},
readOnly: true,
scrollBeyondLastLine: false,
showFoldingControls: 'always',
lineNumbers: 'off',
};

export default function MonacoEditor({ value, language }: MonacoEditorProps) {
const editorRef = useRef<Parameters<OnMount>['0'] | null>(null);

const executeFolding = () => {
if (!editorRef.current) return;

// fold deeply nested objects
editorRef.current?.trigger('fold', 'editor.foldLevel7', {});
editorRef.current?.trigger('fold', 'editor.foldLevel6', {});
editorRef.current?.trigger('fold', 'editor.foldLevel5', {});
editorRef.current?.trigger('fold', 'editor.foldLevel4', {});
};

const onMount: OnMount = (editor, monaco) => {
editorRef.current = editor;

monaco.editor.defineTheme('envy', {
base: 'vs',
inherit: true,
colors: {
'editor.background': colors.gray['200'],
'editor.lineHighlightBackground': colors.gray['200'],
},
rules: [],
});
monaco.editor.setTheme('envy');

executeFolding();
};

useEffect(executeFolding, [value, language]);

return <Editor value={value} language={language} options={editorOptions} onMount={onMount} />;
}
Loading

0 comments on commit 2bc08f4

Please sign in to comment.