chore: start building tv-recorder toolbar
This commit is contained in:
parent
17ed944a84
commit
c9215adc03
|
|
@ -43,6 +43,7 @@ export class RecorderInTraceViewer extends EventEmitter implements IRecorderApp
|
|||
constructor(transport: RecorderTransport, tracePage: Page, traceServer: HttpServer, wsEndpointForTest: string | undefined) {
|
||||
super();
|
||||
this._transport = transport;
|
||||
this._transport.eventSink.resolve(this);
|
||||
this._tracePage = tracePage;
|
||||
this._traceServer = traceServer;
|
||||
this.wsEndpointForTest = wsEndpointForTest;
|
||||
|
|
@ -94,6 +95,7 @@ async function openApp(trace: string, options?: TraceViewerServerOptions & { hea
|
|||
|
||||
class RecorderTransport implements Transport {
|
||||
private _connected = new ManualPromise<void>();
|
||||
readonly eventSink = new ManualPromise<EventEmitter>();
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
|
@ -103,6 +105,8 @@ class RecorderTransport implements Transport {
|
|||
}
|
||||
|
||||
async dispatch(method: string, params: any): Promise<any> {
|
||||
const eventSink = await this.eventSink;
|
||||
eventSink.emit('event', { event: method, params });
|
||||
}
|
||||
|
||||
onclose() {
|
||||
|
|
|
|||
|
|
@ -20,14 +20,6 @@
|
|||
flex: auto;
|
||||
}
|
||||
|
||||
.recorder-chooser {
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
color: var(--vscode-sideBarTitle-foreground);
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.recorder .codicon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
import type { CallLog, Mode, Source } from './recorderTypes';
|
||||
import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
|
||||
import { SplitView } from '@web/components/splitView';
|
||||
import { emptySource, SourceChooser } from '@web/components/sourceChooser';
|
||||
import { TabbedPane } from '@web/components/tabbedPane';
|
||||
import { Toolbar } from '@web/components/toolbar';
|
||||
import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton';
|
||||
|
|
@ -54,15 +55,7 @@ export const Recorder: React.FC<RecorderProps> = ({
|
|||
if (source)
|
||||
return source;
|
||||
}
|
||||
const source: Source = {
|
||||
id: 'default',
|
||||
isRecorded: false,
|
||||
text: '',
|
||||
language: 'javascript',
|
||||
label: '',
|
||||
highlight: []
|
||||
};
|
||||
return source;
|
||||
return emptySource();
|
||||
}, [sources, fileId]);
|
||||
|
||||
const [locator, setLocator] = React.useState('');
|
||||
|
|
@ -152,10 +145,10 @@ export const Recorder: React.FC<RecorderProps> = ({
|
|||
}}></ToolbarButton>
|
||||
<div style={{ flex: 'auto' }}></div>
|
||||
<div>Target:</div>
|
||||
<select className='recorder-chooser' hidden={!sources.length} value={fileId} onChange={event => {
|
||||
setFileId(event.target.selectedOptions[0].value);
|
||||
window.dispatch({ event: 'fileChanged', params: { file: event.target.selectedOptions[0].value } });
|
||||
}}>{renderSourceOptions(sources)}</select>
|
||||
<SourceChooser fileId={fileId} sources={sources} setFileId={fileId => {
|
||||
setFileId(fileId);
|
||||
window.dispatch({ event: 'fileChanged', params: { file: fileId } });
|
||||
}} />
|
||||
<ToolbarButton icon='clear-all' title='Clear' disabled={!source || !source.text} onClick={() => {
|
||||
window.dispatch({ event: 'clear' });
|
||||
}}></ToolbarButton>
|
||||
|
|
@ -184,22 +177,3 @@ export const Recorder: React.FC<RecorderProps> = ({
|
|||
/>
|
||||
</div>;
|
||||
};
|
||||
|
||||
function renderSourceOptions(sources: Source[]): React.ReactNode {
|
||||
const transformTitle = (title: string): string => title.replace(/.*[/\\]([^/\\]+)/, '$1');
|
||||
const renderOption = (source: Source): React.ReactNode => (
|
||||
<option key={source.id} value={source.id}>{transformTitle(source.label)}</option>
|
||||
);
|
||||
|
||||
const hasGroup = sources.some(s => s.group);
|
||||
if (hasGroup) {
|
||||
const groups = new Set(sources.map(s => s.group));
|
||||
return [...groups].filter(Boolean).map(group => (
|
||||
<optgroup label={group} key={group}>
|
||||
{sources.filter(s => s.group === group).map(source => renderOption(source))}
|
||||
</optgroup>
|
||||
));
|
||||
}
|
||||
|
||||
return sources.map(source => renderOption(source));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ import type { SourceLocation } from './modelUtil';
|
|||
import { Workbench } from './workbench';
|
||||
import type { Mode, Source } from '@recorder/recorderTypes';
|
||||
import type { ContextEntry } from '../entries';
|
||||
import { emptySource, SourceChooser } from '@web/components/sourceChooser';
|
||||
import { Toolbar } from '@web/components/toolbar';
|
||||
import { ToolbarButton, ToolbarSeparator } from '@web/components/toolbarButton';
|
||||
import { toggleTheme } from '@web/theme';
|
||||
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const guid = searchParams.get('ws');
|
||||
|
|
@ -29,33 +33,81 @@ const trace = searchParams.get('trace') + '.json';
|
|||
export const RecorderView: React.FunctionComponent = () => {
|
||||
const [connection, setConnection] = React.useState<Connection | null>(null);
|
||||
const [sources, setSources] = React.useState<Source[]>([]);
|
||||
const [mode, setMode] = React.useState<Mode>('none');
|
||||
const [fileId, setFileId] = React.useState<string | undefined>();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!fileId && sources.length > 0)
|
||||
setFileId(sources[0].id);
|
||||
}, [fileId, sources]);
|
||||
|
||||
const source = React.useMemo(() => {
|
||||
if (fileId) {
|
||||
const source = sources.find(s => s.id === fileId);
|
||||
if (source)
|
||||
return source;
|
||||
}
|
||||
return emptySource();
|
||||
}, [sources, fileId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const wsURL = new URL(`../${guid}`, window.location.toString());
|
||||
wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:');
|
||||
const webSocket = new WebSocket(wsURL.toString());
|
||||
setConnection(new Connection(webSocket, { setSources }));
|
||||
setConnection(new Connection(webSocket, { setSources, setMode }));
|
||||
return () => {
|
||||
webSocket.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!connection)
|
||||
return;
|
||||
connection.setMode('recording');
|
||||
}, [connection]);
|
||||
|
||||
return <div className='vbox workbench-loader'>
|
||||
<Toolbar>
|
||||
<ToolbarButton icon='circle-large-filled' title='Record' toggled={mode === 'recording' || mode === 'recording-inspecting' || mode === 'assertingText' || mode === 'assertingVisibility'} onClick={() => {
|
||||
connection?.setMode(mode === 'none' || mode === 'standby' || mode === 'inspecting' ? 'recording' : 'standby');
|
||||
}}>Record</ToolbarButton>
|
||||
<ToolbarSeparator />
|
||||
<ToolbarButton icon='inspect' title='Pick locator' toggled={mode === 'inspecting' || mode === 'recording-inspecting'} onClick={() => {
|
||||
const newMode = ({
|
||||
'inspecting': 'standby',
|
||||
'none': 'inspecting',
|
||||
'standby': 'inspecting',
|
||||
'recording': 'recording-inspecting',
|
||||
'recording-inspecting': 'recording',
|
||||
'assertingText': 'recording-inspecting',
|
||||
'assertingVisibility': 'recording-inspecting',
|
||||
'assertingValue': 'recording-inspecting',
|
||||
} as Record<string, Mode>)[mode];
|
||||
connection?.setMode(newMode);
|
||||
}}></ToolbarButton>
|
||||
<ToolbarButton icon='eye' title='Assert visibility' toggled={mode === 'assertingVisibility'} disabled={mode === 'none' || mode === 'standby' || mode === 'inspecting'} onClick={() => {
|
||||
connection?.setMode(mode === 'assertingVisibility' ? 'recording' : 'assertingVisibility');
|
||||
}}></ToolbarButton>
|
||||
<ToolbarButton icon='whole-word' title='Assert text' toggled={mode === 'assertingText'} disabled={mode === 'none' || mode === 'standby' || mode === 'inspecting'} onClick={() => {
|
||||
connection?.setMode(mode === 'assertingText' ? 'recording' : 'assertingText');
|
||||
}}></ToolbarButton>
|
||||
<ToolbarButton icon='symbol-constant' title='Assert value' toggled={mode === 'assertingValue'} disabled={mode === 'none' || mode === 'standby' || mode === 'inspecting'} onClick={() => {
|
||||
connection?.setMode(mode === 'assertingValue' ? 'recording' : 'assertingValue');
|
||||
}}></ToolbarButton>
|
||||
<ToolbarSeparator />
|
||||
<div style={{ flex: 'auto' }}></div>
|
||||
<div>Target:</div>
|
||||
<SourceChooser fileId={fileId} sources={sources} setFileId={fileId => {
|
||||
setFileId(fileId);
|
||||
}} />
|
||||
<ToolbarButton icon='clear-all' title='Clear' disabled={!source || !source.text} onClick={() => {
|
||||
}}></ToolbarButton>
|
||||
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()}></ToolbarButton>
|
||||
</Toolbar>
|
||||
<TraceView
|
||||
traceLocation={trace}
|
||||
sources={sources} />
|
||||
source={source} />
|
||||
</div>;
|
||||
};
|
||||
|
||||
export const TraceView: React.FC<{
|
||||
traceLocation: string,
|
||||
sources: Source[],
|
||||
}> = ({ traceLocation, sources }) => {
|
||||
source: Source | undefined,
|
||||
}> = ({ traceLocation, source }) => {
|
||||
const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>();
|
||||
const [counter, setCounter] = React.useState(0);
|
||||
const pollTimer = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
|
@ -82,7 +134,7 @@ export const TraceView: React.FC<{
|
|||
}, [counter, traceLocation]);
|
||||
|
||||
const fallbackLocation = React.useMemo(() => {
|
||||
if (!sources.length)
|
||||
if (!source)
|
||||
return undefined;
|
||||
const fallbackLocation: SourceLocation = {
|
||||
file: '',
|
||||
|
|
@ -90,11 +142,11 @@ export const TraceView: React.FC<{
|
|||
column: 0,
|
||||
source: {
|
||||
errors: [],
|
||||
content: sources[0].text
|
||||
content: source.text
|
||||
}
|
||||
};
|
||||
return fallbackLocation;
|
||||
}, [sources]);
|
||||
}, [source]);
|
||||
|
||||
return <Workbench
|
||||
key='workbench'
|
||||
|
|
@ -103,6 +155,7 @@ export const TraceView: React.FC<{
|
|||
fallbackLocation={fallbackLocation}
|
||||
isLive={true}
|
||||
hideTimeline={true}
|
||||
hideMetatada={true}
|
||||
/>;
|
||||
};
|
||||
|
||||
|
|
@ -114,13 +167,19 @@ async function loadSingleTraceFile(url: string): Promise<MultiTraceModel> {
|
|||
return new MultiTraceModel(contextEntries);
|
||||
}
|
||||
|
||||
|
||||
type ConnectionOptions = {
|
||||
setSources: (sources: Source[]) => void;
|
||||
setMode: (mode: Mode) => void;
|
||||
};
|
||||
|
||||
class Connection {
|
||||
private _lastId = 0;
|
||||
private _webSocket: WebSocket;
|
||||
private _callbacks = new Map<number, { resolve: (arg: any) => void, reject: (arg: Error) => void }>();
|
||||
private _options: { setSources: (sources: Source[]) => void; };
|
||||
private _options: ConnectionOptions;
|
||||
|
||||
constructor(webSocket: WebSocket, options: { setSources: (sources: Source[]) => void }) {
|
||||
constructor(webSocket: WebSocket, options: ConnectionOptions) {
|
||||
this._webSocket = webSocket;
|
||||
this._callbacks = new Map();
|
||||
this._options = options;
|
||||
|
|
@ -157,7 +216,7 @@ class Connection {
|
|||
}
|
||||
|
||||
private _sendMessageNoReply(method: string, params?: any) {
|
||||
this._sendMessage(method, params).catch(() => { });
|
||||
this._sendMessage(method, params);
|
||||
}
|
||||
|
||||
private _dispatchEvent(method: string, params?: any) {
|
||||
|
|
@ -166,5 +225,10 @@ class Connection {
|
|||
this._options.setSources(sources);
|
||||
window.playwrightSourcesEchoForTest = sources;
|
||||
}
|
||||
|
||||
if (method === 'setMode') {
|
||||
const { mode } = params as { mode: Mode };
|
||||
this._options.setMode(mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,10 +28,6 @@
|
|||
background-color: var(--vscode-sideBar-background);
|
||||
}
|
||||
|
||||
.snapshot-tab .toolbar .pick-locator {
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.snapshot-controls {
|
||||
flex: none;
|
||||
background-color: var(--vscode-sideBar-background);
|
||||
|
|
|
|||
|
|
@ -211,7 +211,8 @@ export const SnapshotTab: React.FunctionComponent<{
|
|||
iframe={iframeRef1.current}
|
||||
iteration={loadingRef.current.iteration} />
|
||||
<Toolbar>
|
||||
<ToolbarButton className='pick-locator' title={showScreenshotInsteadOfSnapshot ? 'Disable "screenshots instead of snapshots" to pick a locator' : 'Pick locator'} icon='target' toggled={isInspecting} onClick={() => setIsInspecting(!isInspecting)} disabled={showScreenshotInsteadOfSnapshot} />
|
||||
<ToolbarButton title={showScreenshotInsteadOfSnapshot ? 'Disable "screenshots instead of snapshots" to pick a locator' : 'Pick locator'} icon='target' toggled={isInspecting} onClick={() => setIsInspecting(!isInspecting)} disabled={showScreenshotInsteadOfSnapshot} />
|
||||
<div style={{ width: 4 }}></div>
|
||||
{['action', 'before', 'after'].map(tab => {
|
||||
return <TabbedPaneTab
|
||||
key={tab}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
import { SplitView } from '@web/components/splitView';
|
||||
import * as React from 'react';
|
||||
import { useAsyncMemo } from '@web/uiUtils';
|
||||
import { copy, useAsyncMemo } from '@web/uiUtils';
|
||||
import './sourceTab.css';
|
||||
import { StackTraceView } from './stackTrace';
|
||||
import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
|
||||
|
|
@ -111,6 +111,11 @@ export const SourceTab: React.FunctionComponent<{
|
|||
<CopyToClipboard description='Copy filename' value={shortFileName}/>
|
||||
{location && <ToolbarButton icon='link-external' title='Open in VS Code' onClick={openExternally}></ToolbarButton>}
|
||||
</Toolbar>}
|
||||
{!fileName && <div style={{ position: 'absolute', right: 5, top: 5 }}>
|
||||
<ToolbarButton icon='files' title='Copy' onClick={() => {
|
||||
copy(source.content || '');
|
||||
}} />
|
||||
</div>}
|
||||
<CodeMirrorWrapper text={source.content || ''} language='javascript' highlight={highlight} revealLine={targetLine} readOnly={true} lineNumbers={true} />
|
||||
</div>}
|
||||
sidebar={<StackTraceView stack={stack} selectedFrame={selectedFrame} setSelectedFrame={setSelectedFrame} />}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ export const Workbench: React.FunctionComponent<{
|
|||
rootDir?: string,
|
||||
fallbackLocation?: modelUtil.SourceLocation,
|
||||
isLive?: boolean,
|
||||
hideTimeline?: boolean,
|
||||
status?: UITestStatus,
|
||||
annotations?: { type: string; description?: string; }[];
|
||||
inert?: boolean,
|
||||
|
|
@ -58,7 +57,9 @@ export const Workbench: React.FunctionComponent<{
|
|||
onOpenExternally?: (location: modelUtil.SourceLocation) => void,
|
||||
revealSource?: boolean,
|
||||
showSettings?: boolean,
|
||||
}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, hideTimeline, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => {
|
||||
hideTimeline?: boolean,
|
||||
hideMetatada?: boolean,
|
||||
}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, hideTimeline, hideMetatada, status, annotations, inert, openPage, onOpenExternally, revealSource, showSettings }) => {
|
||||
const [selectedCallId, setSelectedCallId] = React.useState<string | undefined>(undefined);
|
||||
const [revealedError, setRevealedError] = React.useState<ErrorDescription | undefined>(undefined);
|
||||
|
||||
|
|
@ -280,10 +281,7 @@ export const Workbench: React.FunctionComponent<{
|
|||
else if (model && model.wallTime)
|
||||
time = Date.now() - model.wallTime;
|
||||
|
||||
const actionsTab: TabbedPaneTabModel = {
|
||||
id: 'actions',
|
||||
title: 'Actions',
|
||||
component: <div className='vbox'>
|
||||
const actionsView = <div className='vbox'>
|
||||
{status && <div className='workbench-run-status'>
|
||||
<span className={clsx('codicon', testStatusIcon(status))}></span>
|
||||
<div>{testStatusText(status)}</div>
|
||||
|
|
@ -301,21 +299,28 @@ export const Workbench: React.FunctionComponent<{
|
|||
revealConsole={() => selectPropertiesTab('console')}
|
||||
isLive={isLive}
|
||||
/>
|
||||
</div>
|
||||
};
|
||||
const metadataTab: TabbedPaneTabModel = {
|
||||
id: 'metadata',
|
||||
title: 'Metadata',
|
||||
component: <MetadataView model={model}/>
|
||||
};
|
||||
const settingsTab: TabbedPaneTabModel = {
|
||||
id: 'settings',
|
||||
title: 'Settings',
|
||||
component: <SettingsView settings={[
|
||||
</div>;
|
||||
const metadataView = hideMetatada ? null : <MetadataView model={model}/>;
|
||||
const settingsView = showSettings ? <SettingsView settings={[
|
||||
{ value: showRouteActions, set: setShowRouteActions, title: 'Show route actions' },
|
||||
{ value: showScreenshot, set: setShowScreenshot, title: 'Show screenshot instead of snapshot' }
|
||||
]}/>,
|
||||
]}/> : null;
|
||||
|
||||
const actionsTab: TabbedPaneTabModel = {
|
||||
id: 'actions',
|
||||
title: 'Actions',
|
||||
component: actionsView,
|
||||
};
|
||||
const metadataTab: TabbedPaneTabModel | null = metadataView ? {
|
||||
id: 'metadata',
|
||||
title: 'Metadata',
|
||||
component: metadataView,
|
||||
} : null;
|
||||
const settingsTab: TabbedPaneTabModel | null = settingsView ? {
|
||||
id: 'settings',
|
||||
title: 'Settings',
|
||||
component: settingsView,
|
||||
} : null;
|
||||
|
||||
return <div className='vbox workbench' {...(inert ? { inert: 'true' } : {})}>
|
||||
{!hideTimeline && <Timeline
|
||||
|
|
@ -348,13 +353,11 @@ export const Workbench: React.FunctionComponent<{
|
|||
highlightedLocator={highlightedLocator}
|
||||
setHighlightedLocator={locatorPicked}
|
||||
openPage={openPage} />}
|
||||
sidebar={
|
||||
<TabbedPane
|
||||
tabs={showSettings ? [actionsTab, metadataTab, settingsTab] : [actionsTab, metadataTab]}
|
||||
sidebar={<TabbedPane
|
||||
tabs={[actionsTab, metadataTab, settingsTab].filter(Boolean) as TabbedPaneTabModel[]}
|
||||
selectedTab={selectedNavigatorTab}
|
||||
setSelectedTab={setSelectedNavigatorTab}
|
||||
/>
|
||||
}
|
||||
/>}
|
||||
/>}
|
||||
sidebar={<TabbedPane
|
||||
tabs={tabs}
|
||||
|
|
|
|||
23
packages/web/src/components/sourceChooser.css
Normal file
23
packages/web/src/components/sourceChooser.css
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
.source-chooser {
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
color: var(--vscode-sideBarTitle-foreground);
|
||||
min-width: 100px;
|
||||
}
|
||||
58
packages/web/src/components/sourceChooser.tsx
Normal file
58
packages/web/src/components/sourceChooser.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
import type { Source } from '@recorder/recorderTypes';
|
||||
|
||||
export const SourceChooser: React.FC<{
|
||||
sources: Source[],
|
||||
fileId: string | undefined,
|
||||
setFileId: (fileId: string) => void,
|
||||
}> = ({ sources, fileId, setFileId }) => {
|
||||
return <select className='source-chooser' hidden={!sources.length} value={fileId} onChange={event => {
|
||||
setFileId(event.target.selectedOptions[0].value);
|
||||
}}>{renderSourceOptions(sources)}</select>;
|
||||
};
|
||||
|
||||
function renderSourceOptions(sources: Source[]): React.ReactNode {
|
||||
const transformTitle = (title: string): string => title.replace(/.*[/\\]([^/\\]+)/, '$1');
|
||||
const renderOption = (source: Source): React.ReactNode => (
|
||||
<option key={source.id} value={source.id}>{transformTitle(source.label)}</option>
|
||||
);
|
||||
|
||||
const hasGroup = sources.some(s => s.group);
|
||||
if (hasGroup) {
|
||||
const groups = new Set(sources.map(s => s.group));
|
||||
return [...groups].filter(Boolean).map(group => (
|
||||
<optgroup label={group} key={group}>
|
||||
{sources.filter(s => s.group === group).map(source => renderOption(source))}
|
||||
</optgroup>
|
||||
));
|
||||
}
|
||||
|
||||
return sources.map(source => renderOption(source));
|
||||
}
|
||||
|
||||
export function emptySource(): Source {
|
||||
return {
|
||||
id: 'default',
|
||||
isRecorded: false,
|
||||
text: '',
|
||||
language: 'javascript',
|
||||
label: '',
|
||||
highlight: []
|
||||
};
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
min-height: 35px;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
padding-right: 4px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.toolbar:after {
|
||||
|
|
|
|||
Loading…
Reference in a new issue