From 4a88e80669da3c90628a7f886057e8d85509e7d4 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Fri, 7 Jun 2024 13:42:08 -0700 Subject: [PATCH] Bulk editor (#45) Bulk editor for all pair editors except multipart/form-data --- plugins/importer-curl/src/index.ts | 2 +- plugins/importer-curl/tests/index.test.ts | 38 ++++++++------- plugins/importer-yaak/tests/index.test.ts | 9 ++-- src-web/components/FormUrlencodedEditor.tsx | 5 +- src-web/components/GraphQLEditor.tsx | 2 +- src-web/components/HeadersEditor.tsx | 5 +- src-web/components/UrlParameterEditor.tsx | 5 +- src-web/components/core/BulkPairEditor.tsx | 48 +++++++++++++++++++ src-web/components/core/Editor/Editor.tsx | 2 +- src-web/components/core/Editor/extensions.ts | 2 + .../components/core/Editor/pairs/extension.ts | 11 +++++ .../components/core/Editor/pairs/highlight.ts | 7 +++ .../core/Editor/pairs/pairs.grammar | 9 ++++ src-web/components/core/Editor/pairs/pairs.ts | 19 ++++++++ src-web/components/core/Icon.tsx | 3 ++ src-web/components/core/PairEditor.tsx | 10 ++-- src-web/components/core/PairOrBulkEditor.tsx | 37 ++++++++++++++ src-web/components/core/Separator.tsx | 7 +-- .../components/responseViewers/TextViewer.tsx | 2 +- 19 files changed, 186 insertions(+), 37 deletions(-) create mode 100644 src-web/components/core/BulkPairEditor.tsx create mode 100644 src-web/components/core/Editor/pairs/extension.ts create mode 100644 src-web/components/core/Editor/pairs/highlight.ts create mode 100644 src-web/components/core/Editor/pairs/pairs.grammar create mode 100644 src-web/components/core/Editor/pairs/pairs.ts create mode 100644 src-web/components/core/PairOrBulkEditor.tsx diff --git a/plugins/importer-curl/src/index.ts b/plugins/importer-curl/src/index.ts index 07a50162..376a9a46 100644 --- a/plugins/importer-curl/src/index.ts +++ b/plugins/importer-curl/src/index.ts @@ -43,7 +43,7 @@ type Pair = string | boolean; type PairsByName = Record; -export function pluginHookImport(ctx: any, rawData: string) { +export function pluginHookImport(_: any, rawData: string) { if (!rawData.match(/^\s*curl /)) { return null; } diff --git a/plugins/importer-curl/tests/index.test.ts b/plugins/importer-curl/tests/index.test.ts index 59f09222..f2aac370 100644 --- a/plugins/importer-curl/tests/index.test.ts +++ b/plugins/importer-curl/tests/index.test.ts @@ -2,9 +2,11 @@ import { describe, expect, test } from 'vitest'; import { HttpRequest, Model, Workspace } from '../../../src-web/lib/models'; import { pluginHookImport } from '../src'; +const ctx = {}; + describe('importer-curl', () => { test('Imports basic GET', () => { - expect(pluginHookImport('curl https://yaak.app')).toEqual({ + expect(pluginHookImport(ctx, 'curl https://yaak.app')).toEqual({ resources: { workspaces: [baseWorkspace()], httpRequests: [ @@ -17,7 +19,7 @@ describe('importer-curl', () => { }); test('Explicit URL', () => { - expect(pluginHookImport('curl --url https://yaak.app')).toEqual({ + expect(pluginHookImport(ctx, 'curl --url https://yaak.app')).toEqual({ resources: { workspaces: [baseWorkspace()], httpRequests: [ @@ -30,7 +32,7 @@ describe('importer-curl', () => { }); test('Missing URL', () => { - expect(pluginHookImport('curl -X POST')).toEqual({ + expect(pluginHookImport(ctx, 'curl -X POST')).toEqual({ resources: { workspaces: [baseWorkspace()], httpRequests: [ @@ -43,7 +45,7 @@ describe('importer-curl', () => { }); 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: { workspaces: [baseWorkspace()], httpRequests: [ @@ -57,7 +59,7 @@ describe('importer-curl', () => { }); 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: { workspaces: [baseWorkspace()], httpRequests: [ @@ -70,7 +72,7 @@ describe('importer-curl', () => { }); test('Imports --request method', () => { - expect(pluginHookImport('curl --request POST https://yaak.app')).toEqual({ + expect(pluginHookImport(ctx, 'curl --request POST https://yaak.app')).toEqual({ resources: { workspaces: [baseWorkspace()], httpRequests: [ @@ -84,7 +86,7 @@ describe('importer-curl', () => { }); 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: { workspaces: [baseWorkspace()], httpRequests: [ @@ -99,7 +101,10 @@ describe('importer-curl', () => { test('Imports multiple requests', () => { 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({ resources: { workspaces: [baseWorkspace()], @@ -114,7 +119,7 @@ describe('importer-curl', () => { test('Imports form data', () => { 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({ resources: { workspaces: [baseWorkspace()], @@ -144,7 +149,7 @@ describe('importer-curl', () => { }); 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: { workspaces: [baseWorkspace()], httpRequests: [ @@ -174,7 +179,7 @@ describe('importer-curl', () => { test('Imports data params as text', () => { 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({ resources: { workspaces: [baseWorkspace()], @@ -194,6 +199,7 @@ describe('importer-curl', () => { test('Imports multi-line JSON', () => { expect( pluginHookImport( + ctx, `curl -H Content-Type:application/json -d $'{\n "foo":"bar"\n}' https://yaak.app`, ), ).toEqual({ @@ -214,7 +220,7 @@ describe('importer-curl', () => { test('Imports multiple headers', () => { 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({ resources: { workspaces: [baseWorkspace()], @@ -234,7 +240,7 @@ describe('importer-curl', () => { }); 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: { workspaces: [baseWorkspace()], httpRequests: [ @@ -252,7 +258,7 @@ describe('importer-curl', () => { }); 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: { workspaces: [baseWorkspace()], httpRequests: [ @@ -270,7 +276,7 @@ describe('importer-curl', () => { }); 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: { workspaces: [baseWorkspace()], httpRequests: [ @@ -284,7 +290,7 @@ describe('importer-curl', () => { }); 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: { workspaces: [baseWorkspace()], httpRequests: [ diff --git a/plugins/importer-yaak/tests/index.test.ts b/plugins/importer-yaak/tests/index.test.ts index b225dc64..38e44133 100644 --- a/plugins/importer-yaak/tests/index.test.ts +++ b/plugins/importer-yaak/tests/index.test.ts @@ -1,15 +1,18 @@ import { describe, expect, test } from 'vitest'; import { pluginHookImport } from '../src'; +const ctx = {}; + describe('importer-yaak', () => { test('Skips invalid imports', () => { - expect(pluginHookImport('not JSON')).toBeUndefined(); - expect(pluginHookImport('[]')).toBeUndefined(); - expect(pluginHookImport(JSON.stringify({ resources: {} }))).toBeUndefined(); + expect(pluginHookImport(ctx, 'not JSON')).toBeUndefined(); + expect(pluginHookImport(ctx, '[]')).toBeUndefined(); + expect(pluginHookImport(ctx, JSON.stringify({ resources: {} }))).toBeUndefined(); }); test('converts schema 1 to 2', () => { const imported = pluginHookImport( + ctx, JSON.stringify({ yaakSchema: 1, resources: { diff --git a/src-web/components/FormUrlencodedEditor.tsx b/src-web/components/FormUrlencodedEditor.tsx index 80ca6816..1a9e3cd2 100644 --- a/src-web/components/FormUrlencodedEditor.tsx +++ b/src-web/components/FormUrlencodedEditor.tsx @@ -1,7 +1,7 @@ import { useCallback, useMemo } from 'react'; import type { HttpRequest } from '../lib/models'; import type { Pair, PairEditorProps } from './core/PairEditor'; -import { PairEditor } from './core/PairEditor'; +import { PairOrBulkEditor } from './core/PairOrBulkEditor'; type Props = { forceUpdateKey: string; @@ -27,7 +27,8 @@ export function FormUrlencodedEditor({ body, forceUpdateKey, onChange }: Props) ); return ( -
- + Variables & { + 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 ( + + ); +} + +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; +} diff --git a/src-web/components/core/Editor/Editor.tsx b/src-web/components/core/Editor/Editor.tsx index 61110b7b..41460272 100644 --- a/src-web/components/core/Editor/Editor.tsx +++ b/src-web/components/core/Editor/Editor.tsx @@ -102,7 +102,7 @@ export const Editor = forwardRef(function E // Use ref so we can update the handler without re-initializing the editor const handleChange = useRef(onChange); useEffect(() => { - handleChange.current = onChange; + handleChange.current = onChange ? onChange : onChange; }, [onChange]); // Use ref so we can update the handler without re-initializing the editor diff --git a/src-web/components/core/Editor/extensions.ts b/src-web/components/core/Editor/extensions.ts index 7cab4610..22ae5e36 100644 --- a/src-web/components/core/Editor/extensions.ts +++ b/src-web/components/core/Editor/extensions.ts @@ -35,6 +35,7 @@ import { graphql, graphqlLanguageSupport } from 'cm6-graphql'; import { EditorView } from 'codemirror'; import type { Environment, Workspace } from '../../../lib/models'; import type { EditorProps } from './index'; +import { pairs } from './pairs/extension'; import { text } from './text/extension'; import { twig } from './twig/extension'; import { url } from './url/extension'; @@ -71,6 +72,7 @@ const syntaxExtensions: Record = { 'application/xml': xml(), 'text/xml': xml(), url: url(), + pairs: pairs(), }; export function getLanguageExtension({ diff --git a/src-web/components/core/Editor/pairs/extension.ts b/src-web/components/core/Editor/pairs/extension.ts new file mode 100644 index 00000000..a2b5640a --- /dev/null +++ b/src-web/components/core/Editor/pairs/extension.ts @@ -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, []); +} diff --git a/src-web/components/core/Editor/pairs/highlight.ts b/src-web/components/core/Editor/pairs/highlight.ts new file mode 100644 index 00000000..13fb4b79 --- /dev/null +++ b/src-web/components/core/Editor/pairs/highlight.ts @@ -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, +}); diff --git a/src-web/components/core/Editor/pairs/pairs.grammar b/src-web/components/core/Editor/pairs/pairs.grammar new file mode 100644 index 00000000..a776dedb --- /dev/null +++ b/src-web/components/core/Editor/pairs/pairs.grammar @@ -0,0 +1,9 @@ +@top pairs { (Key? Sep Value)* } + +@tokens { + Sep { ":" } + Key { ![:]+ } + Value { ![\n]+ } +} + +@external propSource highlight from "./highlight" diff --git a/src-web/components/core/Editor/pairs/pairs.ts b/src-web/components/core/Editor/pairs/pairs.ts new file mode 100644 index 00000000..fca38f4f --- /dev/null +++ b/src-web/components/core/Editor/pairs/pairs.ts @@ -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 +}) + diff --git a/src-web/components/core/Icon.tsx b/src-web/components/core/Icon.tsx index c299d4ab..6cef20bf 100644 --- a/src-web/components/core/Icon.tsx +++ b/src-web/components/core/Icon.tsx @@ -5,6 +5,9 @@ import { memo } from 'react'; const icons = { alert: lucide.AlertTriangleIcon, + text: lucide.FileTextIcon, + table: lucide.TableIcon, + fileCode: lucide.FileCodeIcon, archive: lucide.ArchiveIcon, arrowBigDownDash: lucide.ArrowBigDownDashIcon, arrowBigLeftDash: lucide.ArrowBigLeftDashIcon, diff --git a/src-web/components/core/PairEditor.tsx b/src-web/components/core/PairEditor.tsx index 8bf7f8b6..705a618c 100644 --- a/src-web/components/core/PairEditor.tsx +++ b/src-web/components/core/PairEditor.tsx @@ -1,7 +1,7 @@ import { open } from '@tauri-apps/plugin-dialog'; import classNames from 'classnames'; 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 { useDrag, useDrop } from 'react-dnd'; import { v4 as uuid } from 'uuid'; @@ -49,7 +49,7 @@ type PairContainer = { id: string; }; -export const PairEditor = memo(function PairEditor({ +export function PairEditor({ className, forceUpdateKey, nameAutocomplete, @@ -163,8 +163,8 @@ export const PairEditor = memo(function PairEditor({
); -}); +} enum ItemTypes { ROW = 'pair-row', diff --git a/src-web/components/core/PairOrBulkEditor.tsx b/src-web/components/core/PairOrBulkEditor.tsx new file mode 100644 index 00000000..1654608c --- /dev/null +++ b/src-web/components/core/PairOrBulkEditor.tsx @@ -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({ + namespace: 'global', + key: ['bulk_edit', preferenceName], + fallback: false, + }); + + return ( +
+ {useBulk ? : } +
+ setUseBulk((b) => !b)} + icon={useBulk ? 'table' : 'fileCode'} + /> +
+
+ ); +} diff --git a/src-web/components/core/Separator.tsx b/src-web/components/core/Separator.tsx index 43760a45..3cc324b2 100644 --- a/src-web/components/core/Separator.tsx +++ b/src-web/components/core/Separator.tsx @@ -3,18 +3,19 @@ import type { ReactNode } from 'react'; interface Props { orientation?: 'horizontal' | 'vertical'; - variant?: 'primary' | 'secondary'; + dashed?: boolean; className?: string; children?: ReactNode; } -export function Separator({ className, orientation = 'horizontal', children }: Props) { +export function Separator({ className, dashed, orientation = 'horizontal', children }: Props) { return (
{children &&
{children}
}