Bulk editor (#45)

Bulk editor for all pair editors except multipart/form-data
This commit is contained in:
Gregory Schier
2024-06-07 13:42:08 -07:00
committed by GitHub
parent 5108bc92f3
commit 5e058af03e
19 changed files with 186 additions and 37 deletions

View File

@@ -43,7 +43,7 @@ type Pair = string | boolean;
type PairsByName = Record<string, Pair[]>; type PairsByName = Record<string, Pair[]>;
export function pluginHookImport(ctx: any, rawData: string) { export function pluginHookImport(_: any, rawData: string) {
if (!rawData.match(/^\s*curl /)) { if (!rawData.match(/^\s*curl /)) {
return null; return null;
} }

View File

@@ -2,9 +2,11 @@ import { describe, expect, test } from 'vitest';
import { HttpRequest, Model, Workspace } from '../../../src-web/lib/models'; import { HttpRequest, Model, Workspace } from '../../../src-web/lib/models';
import { pluginHookImport } from '../src'; import { pluginHookImport } from '../src';
const ctx = {};
describe('importer-curl', () => { describe('importer-curl', () => {
test('Imports basic GET', () => { test('Imports basic GET', () => {
expect(pluginHookImport('curl https://yaak.app')).toEqual({ expect(pluginHookImport(ctx, 'curl https://yaak.app')).toEqual({
resources: { resources: {
workspaces: [baseWorkspace()], workspaces: [baseWorkspace()],
httpRequests: [ httpRequests: [
@@ -17,7 +19,7 @@ describe('importer-curl', () => {
}); });
test('Explicit URL', () => { test('Explicit URL', () => {
expect(pluginHookImport('curl --url https://yaak.app')).toEqual({ expect(pluginHookImport(ctx, 'curl --url https://yaak.app')).toEqual({
resources: { resources: {
workspaces: [baseWorkspace()], workspaces: [baseWorkspace()],
httpRequests: [ httpRequests: [
@@ -30,7 +32,7 @@ describe('importer-curl', () => {
}); });
test('Missing URL', () => { test('Missing URL', () => {
expect(pluginHookImport('curl -X POST')).toEqual({ expect(pluginHookImport(ctx, 'curl -X POST')).toEqual({
resources: { resources: {
workspaces: [baseWorkspace()], workspaces: [baseWorkspace()],
httpRequests: [ httpRequests: [
@@ -43,7 +45,7 @@ describe('importer-curl', () => {
}); });
test('URL between', () => { test('URL between', () => {
expect(pluginHookImport('curl -v https://yaak.app -X POST')).toEqual({ expect(pluginHookImport(ctx, 'curl -v https://yaak.app -X POST')).toEqual({
resources: { resources: {
workspaces: [baseWorkspace()], workspaces: [baseWorkspace()],
httpRequests: [ httpRequests: [
@@ -57,7 +59,7 @@ describe('importer-curl', () => {
}); });
test('Random flags', () => { test('Random flags', () => {
expect(pluginHookImport('curl --random -Z -Y -S --foo https://yaak.app')).toEqual({ expect(pluginHookImport(ctx, 'curl --random -Z -Y -S --foo https://yaak.app')).toEqual({
resources: { resources: {
workspaces: [baseWorkspace()], workspaces: [baseWorkspace()],
httpRequests: [ httpRequests: [
@@ -70,7 +72,7 @@ describe('importer-curl', () => {
}); });
test('Imports --request method', () => { test('Imports --request method', () => {
expect(pluginHookImport('curl --request POST https://yaak.app')).toEqual({ expect(pluginHookImport(ctx, 'curl --request POST https://yaak.app')).toEqual({
resources: { resources: {
workspaces: [baseWorkspace()], workspaces: [baseWorkspace()],
httpRequests: [ httpRequests: [
@@ -84,7 +86,7 @@ describe('importer-curl', () => {
}); });
test('Imports -XPOST method', () => { test('Imports -XPOST method', () => {
expect(pluginHookImport('curl -XPOST --request POST https://yaak.app')).toEqual({ expect(pluginHookImport(ctx, 'curl -XPOST --request POST https://yaak.app')).toEqual({
resources: { resources: {
workspaces: [baseWorkspace()], workspaces: [baseWorkspace()],
httpRequests: [ httpRequests: [
@@ -99,7 +101,10 @@ describe('importer-curl', () => {
test('Imports multiple requests', () => { test('Imports multiple requests', () => {
expect( expect(
pluginHookImport('curl \\\n https://yaak.app\necho "foo"\ncurl example.com;curl foo.com'), pluginHookImport(
ctx,
'curl \\\n https://yaak.app\necho "foo"\ncurl example.com;curl foo.com',
),
).toEqual({ ).toEqual({
resources: { resources: {
workspaces: [baseWorkspace()], workspaces: [baseWorkspace()],
@@ -114,7 +119,7 @@ describe('importer-curl', () => {
test('Imports form data', () => { test('Imports form data', () => {
expect( expect(
pluginHookImport('curl -X POST -F "a=aaa" -F b=bbb" -F f=@filepath https://yaak.app'), pluginHookImport(ctx, 'curl -X POST -F "a=aaa" -F b=bbb" -F f=@filepath https://yaak.app'),
).toEqual({ ).toEqual({
resources: { resources: {
workspaces: [baseWorkspace()], workspaces: [baseWorkspace()],
@@ -144,7 +149,7 @@ describe('importer-curl', () => {
}); });
test('Imports data params as form url-encoded', () => { test('Imports data params as form url-encoded', () => {
expect(pluginHookImport('curl -d a -d b -d c=ccc https://yaak.app')).toEqual({ expect(pluginHookImport(ctx, 'curl -d a -d b -d c=ccc https://yaak.app')).toEqual({
resources: { resources: {
workspaces: [baseWorkspace()], workspaces: [baseWorkspace()],
httpRequests: [ httpRequests: [
@@ -174,7 +179,7 @@ describe('importer-curl', () => {
test('Imports data params as text', () => { test('Imports data params as text', () => {
expect( expect(
pluginHookImport('curl -H Content-Type:text/plain -d a -d b -d c=ccc https://yaak.app'), pluginHookImport(ctx, 'curl -H Content-Type:text/plain -d a -d b -d c=ccc https://yaak.app'),
).toEqual({ ).toEqual({
resources: { resources: {
workspaces: [baseWorkspace()], workspaces: [baseWorkspace()],
@@ -194,6 +199,7 @@ describe('importer-curl', () => {
test('Imports multi-line JSON', () => { test('Imports multi-line JSON', () => {
expect( expect(
pluginHookImport( pluginHookImport(
ctx,
`curl -H Content-Type:application/json -d $'{\n "foo":"bar"\n}' https://yaak.app`, `curl -H Content-Type:application/json -d $'{\n "foo":"bar"\n}' https://yaak.app`,
), ),
).toEqual({ ).toEqual({
@@ -214,7 +220,7 @@ describe('importer-curl', () => {
test('Imports multiple headers', () => { test('Imports multiple headers', () => {
expect( expect(
pluginHookImport('curl -H Foo:bar --header Name -H AAA:bbb -H :ccc https://yaak.app'), pluginHookImport(ctx, 'curl -H Foo:bar --header Name -H AAA:bbb -H :ccc https://yaak.app'),
).toEqual({ ).toEqual({
resources: { resources: {
workspaces: [baseWorkspace()], workspaces: [baseWorkspace()],
@@ -234,7 +240,7 @@ describe('importer-curl', () => {
}); });
test('Imports basic auth', () => { test('Imports basic auth', () => {
expect(pluginHookImport('curl --user user:pass https://yaak.app')).toEqual({ expect(pluginHookImport(ctx, 'curl --user user:pass https://yaak.app')).toEqual({
resources: { resources: {
workspaces: [baseWorkspace()], workspaces: [baseWorkspace()],
httpRequests: [ httpRequests: [
@@ -252,7 +258,7 @@ describe('importer-curl', () => {
}); });
test('Imports digest auth', () => { test('Imports digest auth', () => {
expect(pluginHookImport('curl --digest --user user:pass https://yaak.app')).toEqual({ expect(pluginHookImport(ctx, 'curl --digest --user user:pass https://yaak.app')).toEqual({
resources: { resources: {
workspaces: [baseWorkspace()], workspaces: [baseWorkspace()],
httpRequests: [ httpRequests: [
@@ -270,7 +276,7 @@ describe('importer-curl', () => {
}); });
test('Imports cookie as header', () => { test('Imports cookie as header', () => {
expect(pluginHookImport('curl --cookie "foo=bar" https://yaak.app')).toEqual({ expect(pluginHookImport(ctx, 'curl --cookie "foo=bar" https://yaak.app')).toEqual({
resources: { resources: {
workspaces: [baseWorkspace()], workspaces: [baseWorkspace()],
httpRequests: [ httpRequests: [
@@ -284,7 +290,7 @@ describe('importer-curl', () => {
}); });
test('Imports query params from the URL', () => { test('Imports query params from the URL', () => {
expect(pluginHookImport('curl "https://yaak.app?foo=bar&baz=a%20a"')).toEqual({ expect(pluginHookImport(ctx, 'curl "https://yaak.app?foo=bar&baz=a%20a"')).toEqual({
resources: { resources: {
workspaces: [baseWorkspace()], workspaces: [baseWorkspace()],
httpRequests: [ httpRequests: [

View File

@@ -1,15 +1,18 @@
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from 'vitest';
import { pluginHookImport } from '../src'; import { pluginHookImport } from '../src';
const ctx = {};
describe('importer-yaak', () => { describe('importer-yaak', () => {
test('Skips invalid imports', () => { test('Skips invalid imports', () => {
expect(pluginHookImport('not JSON')).toBeUndefined(); expect(pluginHookImport(ctx, 'not JSON')).toBeUndefined();
expect(pluginHookImport('[]')).toBeUndefined(); expect(pluginHookImport(ctx, '[]')).toBeUndefined();
expect(pluginHookImport(JSON.stringify({ resources: {} }))).toBeUndefined(); expect(pluginHookImport(ctx, JSON.stringify({ resources: {} }))).toBeUndefined();
}); });
test('converts schema 1 to 2', () => { test('converts schema 1 to 2', () => {
const imported = pluginHookImport( const imported = pluginHookImport(
ctx,
JSON.stringify({ JSON.stringify({
yaakSchema: 1, yaakSchema: 1,
resources: { resources: {

View File

@@ -1,7 +1,7 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import type { HttpRequest } from '../lib/models'; import type { HttpRequest } from '../lib/models';
import type { Pair, PairEditorProps } from './core/PairEditor'; import type { Pair, PairEditorProps } from './core/PairEditor';
import { PairEditor } from './core/PairEditor'; import { PairOrBulkEditor } from './core/PairOrBulkEditor';
type Props = { type Props = {
forceUpdateKey: string; forceUpdateKey: string;
@@ -27,7 +27,8 @@ export function FormUrlencodedEditor({ body, forceUpdateKey, onChange }: Props)
); );
return ( return (
<PairEditor <PairOrBulkEditor
preferenceName="form_urlencoded"
valueAutocompleteVariables valueAutocompleteVariables
nameAutocompleteVariables nameAutocompleteVariables
namePlaceholder="entry_name" namePlaceholder="entry_name"

View File

@@ -125,7 +125,7 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi
{...extraEditorProps} {...extraEditorProps}
/> />
<div className="grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 min-h-[5rem]"> <div className="grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 min-h-[5rem]">
<Separator variant="primary" className="pb-1"> <Separator dashed className="pb-1">
Variables Variables
</Separator> </Separator>
<Editor <Editor

View File

@@ -6,7 +6,7 @@ import { mimeTypes } from '../lib/data/mimetypes';
import type { HttpRequest } from '../lib/models'; import type { HttpRequest } from '../lib/models';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion'; import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
import type { PairEditorProps } from './core/PairEditor'; import type { PairEditorProps } from './core/PairEditor';
import { PairEditor } from './core/PairEditor'; import { PairOrBulkEditor } from './core/PairOrBulkEditor';
type Props = { type Props = {
forceUpdateKey: string; forceUpdateKey: string;
@@ -16,7 +16,8 @@ type Props = {
export function HeadersEditor({ headers, onChange, forceUpdateKey }: Props) { export function HeadersEditor({ headers, onChange, forceUpdateKey }: Props) {
return ( return (
<PairEditor <PairOrBulkEditor
preferenceName="headers"
valueAutocompleteVariables valueAutocompleteVariables
nameAutocompleteVariables nameAutocompleteVariables
pairs={headers} pairs={headers}

View File

@@ -1,5 +1,5 @@
import type { HttpRequest } from '../lib/models'; import type { HttpRequest } from '../lib/models';
import { PairEditor } from './core/PairEditor'; import { PairOrBulkEditor } from './core/PairOrBulkEditor';
type Props = { type Props = {
forceUpdateKey: string; forceUpdateKey: string;
@@ -9,7 +9,8 @@ type Props = {
export function UrlParametersEditor({ urlParameters, forceUpdateKey, onChange }: Props) { export function UrlParametersEditor({ urlParameters, forceUpdateKey, onChange }: Props) {
return ( return (
<PairEditor <PairOrBulkEditor
preferenceName="url_parameters"
valueAutocompleteVariables valueAutocompleteVariables
nameAutocompleteVariables nameAutocompleteVariables
namePlaceholder="param_name" namePlaceholder="param_name"

View File

@@ -0,0 +1,48 @@
import { useCallback, useMemo } from 'react';
import { Editor } from './Editor';
import type { PairEditorProps } from './PairEditor';
type Props = Pick<
PairEditorProps,
'onChange' | 'pairs' | 'namePlaceholder' | 'valuePlaceholder'
> & {
foo?: string;
};
export function BulkPairEditor({ pairs, onChange, namePlaceholder, valuePlaceholder }: Props) {
const pairsText = useMemo(() => {
return pairs
.filter((p) => !(p.name.trim() === '' && p.value.trim() === ''))
.map((p) => `${p.name}: ${p.value}`)
.join('\n');
}, [pairs]);
const handleChange = useCallback(
(text: string) => {
const pairs = text
.split('\n')
.filter((l: string) => l.trim())
.map(lineToPair);
onChange(pairs);
},
[onChange],
);
return (
<Editor
placeholder={`${namePlaceholder ?? 'name'}: ${valuePlaceholder ?? 'value'}`}
defaultValue={pairsText}
contentType="pairs"
onChange={handleChange}
/>
);
}
function lineToPair(l: string): PairEditorProps['pairs'][0] {
const [name, ...values] = l.split(':');
const pair: PairEditorProps['pairs'][0] = {
name: (name ?? '').trim(),
value: values.join(':').trim(),
};
return pair;
}

View File

@@ -102,7 +102,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
// Use ref so we can update the handler without re-initializing the editor // Use ref so we can update the handler without re-initializing the editor
const handleChange = useRef<EditorProps['onChange']>(onChange); const handleChange = useRef<EditorProps['onChange']>(onChange);
useEffect(() => { useEffect(() => {
handleChange.current = onChange; handleChange.current = onChange ? onChange : onChange;
}, [onChange]); }, [onChange]);
// Use ref so we can update the handler without re-initializing the editor // Use ref so we can update the handler without re-initializing the editor

View File

@@ -35,6 +35,7 @@ import { graphql, graphqlLanguageSupport } from 'cm6-graphql';
import { EditorView } from 'codemirror'; import { EditorView } from 'codemirror';
import type { Environment, Workspace } from '../../../lib/models'; import type { Environment, Workspace } from '../../../lib/models';
import type { EditorProps } from './index'; import type { EditorProps } from './index';
import { pairs } from './pairs/extension';
import { text } from './text/extension'; import { text } from './text/extension';
import { twig } from './twig/extension'; import { twig } from './twig/extension';
import { url } from './url/extension'; import { url } from './url/extension';
@@ -71,6 +72,7 @@ const syntaxExtensions: Record<string, LanguageSupport> = {
'application/xml': xml(), 'application/xml': xml(),
'text/xml': xml(), 'text/xml': xml(),
url: url(), url: url(),
pairs: pairs(),
}; };
export function getLanguageExtension({ export function getLanguageExtension({

View File

@@ -0,0 +1,11 @@
import { LanguageSupport, LRLanguage } from '@codemirror/language';
import { parser } from './pairs';
const urlLanguage = LRLanguage.define({
parser,
languageData: {},
});
export function pairs() {
return new LanguageSupport(urlLanguage, []);
}

View File

@@ -0,0 +1,7 @@
import { styleTags, tags as t } from '@lezer/highlight';
export const highlight = styleTags({
Sep: t.bracket,
Key: t.attributeName,
Value: t.string,
});

View File

@@ -0,0 +1,9 @@
@top pairs { (Key? Sep Value)* }
@tokens {
Sep { ":" }
Key { ![:]+ }
Value { ![\n]+ }
}
@external propSource highlight from "./highlight"

View File

@@ -0,0 +1,19 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {highlight} from "./highlight"
export const parser = LRParser.deserialize({
version: 14,
states: "!QQQOPOOOYOQO'#CaO_OPO'#CaQQOPOOOOOO,58{,58{OdOQO,58{OOOO-E6_-E6_OOOO1G.g1G.g",
stateData: "i~OQQORPO~OSSO~ORTO~OSVO~O",
goto: "]UPPPPPVQRORUR",
nodeNames: "⚠ pairs Key Sep Value",
maxTerm: 6,
propSources: [highlight],
skippedNodes: [0],
repeatNodeCount: 1,
tokenData: "#oRRVOYhYZ!UZ![h![!]#[!];'Sh;'S;=`#U<%lOhRoVQPSQOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOhP!ZSQPO![!U!];'S!U;'S;=`!g<%lO!UP!jP;=`<%l!UQ!rSSQOY!mZ;'S!m;'S;=`#O<%lO!mQ#RP;=`<%l!mR#XP;=`<%lhR#cSRPSQOY!mZ;'S!m;'S;=`#O<%lO!m",
tokenizers: [0, 1],
topRules: {"pairs":[0,1]},
tokenPrec: 0
})

View File

@@ -5,6 +5,9 @@ import { memo } from 'react';
const icons = { const icons = {
alert: lucide.AlertTriangleIcon, alert: lucide.AlertTriangleIcon,
text: lucide.FileTextIcon,
table: lucide.TableIcon,
fileCode: lucide.FileCodeIcon,
archive: lucide.ArchiveIcon, archive: lucide.ArchiveIcon,
arrowBigDownDash: lucide.ArrowBigDownDashIcon, arrowBigDownDash: lucide.ArrowBigDownDashIcon,
arrowBigLeftDash: lucide.ArrowBigLeftDashIcon, arrowBigLeftDash: lucide.ArrowBigLeftDashIcon,

View File

@@ -1,7 +1,7 @@
import { open } from '@tauri-apps/plugin-dialog'; import { open } from '@tauri-apps/plugin-dialog';
import classNames from 'classnames'; import classNames from 'classnames';
import type { EditorView } from 'codemirror'; import type { EditorView } from 'codemirror';
import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { XYCoord } from 'react-dnd'; import type { XYCoord } from 'react-dnd';
import { useDrag, useDrop } from 'react-dnd'; import { useDrag, useDrop } from 'react-dnd';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
@@ -49,7 +49,7 @@ type PairContainer = {
id: string; id: string;
}; };
export const PairEditor = memo(function PairEditor({ export function PairEditor({
className, className,
forceUpdateKey, forceUpdateKey,
nameAutocomplete, nameAutocomplete,
@@ -163,8 +163,8 @@ export const PairEditor = memo(function PairEditor({
<div <div
className={classNames( className={classNames(
className, className,
'@container', '@container relative',
'pb-2 mb-auto', 'pb-2 mb-auto h-full',
!noScroll && 'overflow-y-auto max-h-full', !noScroll && 'overflow-y-auto max-h-full',
// Move over the width of the drag handle // Move over the width of the drag handle
'-ml-3', '-ml-3',
@@ -204,7 +204,7 @@ export const PairEditor = memo(function PairEditor({
})} })}
</div> </div>
); );
}); }
enum ItemTypes { enum ItemTypes {
ROW = 'pair-row', ROW = 'pair-row',

View File

@@ -0,0 +1,37 @@
import classNames from 'classnames';
import { useKeyValue } from '../../hooks/useKeyValue';
import { BulkPairEditor } from './BulkPairEditor';
import { IconButton } from './IconButton';
import type { PairEditorProps } from './PairEditor';
import { PairEditor } from './PairEditor';
interface Props extends PairEditorProps {
preferenceName: string;
}
export function PairOrBulkEditor({ preferenceName, ...props }: Props) {
const { value: useBulk, set: setUseBulk } = useKeyValue<boolean>({
namespace: 'global',
key: ['bulk_edit', preferenceName],
fallback: false,
});
return (
<div className="relative h-full w-full group/wrapper">
{useBulk ? <BulkPairEditor {...props} /> : <PairEditor {...props} />}
<div className="absolute right-0 bottom-0">
<IconButton
size="sm"
variant="border"
title={useBulk ? 'Bulk edit' : 'Regular Edit'}
className={classNames(
'transition-opacity opacity-0 group-hover:opacity-80 hover:!opacity-100 shadow',
'bg-background text-fg-subtle hover:text-fg group-hover/wrapper:opacity-100',
)}
onClick={() => setUseBulk((b) => !b)}
icon={useBulk ? 'table' : 'fileCode'}
/>
</div>
</div>
);
}

View File

@@ -3,18 +3,19 @@ import type { ReactNode } from 'react';
interface Props { interface Props {
orientation?: 'horizontal' | 'vertical'; orientation?: 'horizontal' | 'vertical';
variant?: 'primary' | 'secondary'; dashed?: boolean;
className?: string; className?: string;
children?: ReactNode; children?: ReactNode;
} }
export function Separator({ className, orientation = 'horizontal', children }: Props) { export function Separator({ className, dashed, orientation = 'horizontal', children }: Props) {
return ( return (
<div role="separator" className={classNames(className, 'flex items-center')}> <div role="separator" className={classNames(className, 'flex items-center')}>
{children && <div className="text-sm text-fg-subtler mr-2 whitespace-nowrap">{children}</div>} {children && <div className="text-sm text-fg-subtler mr-2 whitespace-nowrap">{children}</div>}
<div <div
className={classNames( className={classNames(
'bg-background-highlight', 'h-0 border-t border-t-background-highlight',
dashed && 'border-dashed',
orientation === 'horizontal' && 'w-full h-[1px]', orientation === 'horizontal' && 'w-full h-[1px]',
orientation === 'vertical' && 'h-full w-[1px]', orientation === 'vertical' && 'h-full w-[1px]',
)} )}

View File

@@ -102,7 +102,7 @@ export function TextViewer({ response, pretty, className }: Props) {
); );
return result; return result;
}, [canFilter, filterText, isJson, isSearching, setFilterText, toggleSearch]); }, [canFilter, filterText, isJson, isSearching, response.id, setFilterText, toggleSearch]);
return ( return (
<Editor <Editor