mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-24 02:11:10 +01:00
Auth plugins (#155)
This commit is contained in:
251
src-web/components/DynamicForm.tsx
Normal file
251
src-web/components/DynamicForm.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import type { Folder, HttpRequest } from '@yaakapp-internal/models';
|
||||
import type {
|
||||
FormInput,
|
||||
FormInputCheckbox,
|
||||
FormInputFile,
|
||||
FormInputHttpRequest,
|
||||
FormInputSelect,
|
||||
FormInputText,
|
||||
} from '@yaakapp-internal/plugins';
|
||||
import { useCallback } from 'react';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { useFolders } from '../hooks/useFolders';
|
||||
import { useHttpRequests } from '../hooks/useHttpRequests';
|
||||
import { fallbackRequestName } from '../lib/fallbackRequestName';
|
||||
import { Checkbox } from './core/Checkbox';
|
||||
import { Input } from './core/Input';
|
||||
import { Select } from './core/Select';
|
||||
import { SelectFile } from './SelectFile';
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const DYNAMIC_FORM_NULL_ARG = '__NULL__';
|
||||
|
||||
export function DynamicForm<T extends Record<string, string | boolean>>({
|
||||
config,
|
||||
data,
|
||||
onChange,
|
||||
useTemplating,
|
||||
stateKey,
|
||||
}: {
|
||||
config: FormInput[];
|
||||
onChange: (value: T) => void;
|
||||
data: T;
|
||||
useTemplating?: boolean;
|
||||
stateKey: string;
|
||||
}) {
|
||||
const setDataAttr = useCallback(
|
||||
(name: string, value: string | boolean | null) => {
|
||||
onChange({ ...data, [name]: value == null ? '__NULL__' : value });
|
||||
},
|
||||
[data, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{config.map((a, i) => {
|
||||
switch (a.type) {
|
||||
case 'select':
|
||||
return (
|
||||
<SelectArg
|
||||
key={i + stateKey}
|
||||
arg={a}
|
||||
onChange={(v) => setDataAttr(a.name, v)}
|
||||
value={data[a.name] ? String(data[a.name]) : '__ERROR__'}
|
||||
/>
|
||||
);
|
||||
case 'text':
|
||||
return (
|
||||
<TextArg
|
||||
key={i}
|
||||
stateKey={stateKey}
|
||||
arg={a}
|
||||
useTemplating={useTemplating || false}
|
||||
onChange={(v) => setDataAttr(a.name, v)}
|
||||
value={data[a.name] ? String(data[a.name]) : ''}
|
||||
/>
|
||||
);
|
||||
case 'checkbox':
|
||||
return (
|
||||
<CheckboxArg
|
||||
key={i + stateKey}
|
||||
arg={a}
|
||||
onChange={(v) => setDataAttr(a.name, v)}
|
||||
value={data[a.name] !== undefined ? data[a.name] === true : false}
|
||||
/>
|
||||
);
|
||||
case 'http_request':
|
||||
return (
|
||||
<HttpRequestArg
|
||||
key={i + stateKey}
|
||||
arg={a}
|
||||
onChange={(v) => setDataAttr(a.name, v)}
|
||||
value={data[a.name] ? String(data[a.name]) : '__ERROR__'}
|
||||
/>
|
||||
);
|
||||
case 'file':
|
||||
return (
|
||||
<FileArg
|
||||
key={i + stateKey}
|
||||
arg={a}
|
||||
onChange={(v) => setDataAttr(a.name, v)}
|
||||
filePath={data[a.name] ? String(data[a.name]) : '__ERROR__'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextArg({
|
||||
arg,
|
||||
onChange,
|
||||
value,
|
||||
useTemplating,
|
||||
stateKey,
|
||||
}: {
|
||||
arg: FormInputText;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
useTemplating: boolean;
|
||||
stateKey: string;
|
||||
}) {
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
onChange(value === '' ? DYNAMIC_FORM_NULL_ARG : value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Input
|
||||
name={arg.name}
|
||||
onChange={handleChange}
|
||||
defaultValue={value === DYNAMIC_FORM_NULL_ARG ? '' : value}
|
||||
require={!arg.optional}
|
||||
label={
|
||||
<>
|
||||
{arg.label ?? arg.name}
|
||||
{arg.optional && <span> (optional)</span>}
|
||||
</>
|
||||
}
|
||||
hideLabel={arg.label == null}
|
||||
placeholder={arg.placeholder ?? arg.defaultValue ?? ''}
|
||||
useTemplating={useTemplating}
|
||||
stateKey={stateKey}
|
||||
forceUpdateKey={stateKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectArg({
|
||||
arg,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
arg: FormInputSelect;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
label={arg.label ?? arg.name}
|
||||
name={arg.name}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
options={[
|
||||
...arg.options.map((a) => ({
|
||||
label: a.name + (arg.defaultValue === a.value ? ' (default)' : ''),
|
||||
value: a.value === arg.defaultValue ? DYNAMIC_FORM_NULL_ARG : a.value,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FileArg({
|
||||
arg,
|
||||
filePath,
|
||||
onChange,
|
||||
}: {
|
||||
arg: FormInputFile;
|
||||
filePath: string;
|
||||
onChange: (v: string | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<SelectFile
|
||||
onChange={({ filePath }) => onChange(filePath)}
|
||||
filePath={filePath === '__NULL__' ? null : filePath}
|
||||
directory={!!arg.directory}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HttpRequestArg({
|
||||
arg,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
arg: FormInputHttpRequest;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const folders = useFolders();
|
||||
const httpRequests = useHttpRequests();
|
||||
const activeRequest = useActiveRequest();
|
||||
return (
|
||||
<Select
|
||||
label={arg.label ?? arg.name}
|
||||
name={arg.name}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
options={[
|
||||
...httpRequests.map((r) => {
|
||||
return {
|
||||
label:
|
||||
buildRequestBreadcrumbs(r, folders).join(' / ') +
|
||||
(r.id == activeRequest?.id ? ' (current)' : ''),
|
||||
value: r.id,
|
||||
};
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function buildRequestBreadcrumbs(request: HttpRequest, folders: Folder[]): string[] {
|
||||
const ancestors: (HttpRequest | Folder)[] = [request];
|
||||
|
||||
const next = () => {
|
||||
const latest = ancestors[0];
|
||||
if (latest == null) return [];
|
||||
|
||||
const parent = folders.find((f) => f.id === latest.folderId);
|
||||
if (parent == null) return;
|
||||
|
||||
ancestors.unshift(parent);
|
||||
next();
|
||||
};
|
||||
next();
|
||||
|
||||
return ancestors.map((a) => (a.model === 'folder' ? a.name : fallbackRequestName(a)));
|
||||
}
|
||||
|
||||
function CheckboxArg({
|
||||
arg,
|
||||
onChange,
|
||||
value,
|
||||
}: {
|
||||
arg: FormInputCheckbox;
|
||||
value: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Checkbox
|
||||
onChange={onChange}
|
||||
checked={value}
|
||||
title={arg.label ?? arg.name}
|
||||
hideLabel={arg.label == null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { ShowToastRequest } from '@yaakapp/api';
|
||||
import { useSubscribeActiveWorkspaceId } from '../hooks/useActiveWorkspace';
|
||||
import { useActiveWorkspaceChangedToast } from '../hooks/useActiveWorkspaceChangedToast';
|
||||
import { useGenerateThemeCss } from '../hooks/useGenerateThemeCss';
|
||||
import { useSubscribeHttpAuthentication } from '../hooks/useHttpAuthentication';
|
||||
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
|
||||
import { useNotificationToast } from '../hooks/useNotificationToast';
|
||||
import { useSyncFontSizeSetting } from '../hooks/useSyncFontSizeSetting';
|
||||
@@ -24,6 +25,7 @@ export function GlobalHooks() {
|
||||
|
||||
useSyncWorkspaceChildModels();
|
||||
useSubscribeTemplateFunctions();
|
||||
useSubscribeHttpAuthentication();
|
||||
|
||||
// Other useful things
|
||||
useNotificationToast();
|
||||
|
||||
@@ -6,12 +6,10 @@ import type { CSSProperties } from 'react';
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import { useContainerSize } from '../hooks/useContainerQuery';
|
||||
import type { ReflectResponseService } from '../hooks/useGrpc';
|
||||
import { useHttpAuthentication } from '../hooks/useHttpAuthentication';
|
||||
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
|
||||
import { useUpdateAnyGrpcRequest } from '../hooks/useUpdateAnyGrpcRequest';
|
||||
import { fallbackRequestName } from '../lib/fallbackRequestName';
|
||||
import { AUTH_TYPE_BASIC, AUTH_TYPE_BEARER, AUTH_TYPE_NONE } from '../lib/model_util';
|
||||
import { BasicAuth } from './BasicAuth';
|
||||
import { BearerAuth } from './BearerAuth';
|
||||
import { Button } from './core/Button';
|
||||
import { CountBadge } from './core/CountBadge';
|
||||
import { Icon } from './core/Icon';
|
||||
@@ -22,8 +20,8 @@ import { RadioDropdown } from './core/RadioDropdown';
|
||||
import { HStack, VStack } from './core/Stacks';
|
||||
import type { TabItem } from './core/Tabs/Tabs';
|
||||
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
||||
import { EmptyStateText } from './EmptyStateText';
|
||||
import { GrpcEditor } from './GrpcEditor';
|
||||
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
|
||||
import { MarkdownEditor } from './MarkdownEditor';
|
||||
import { UrlBar } from './UrlBar';
|
||||
|
||||
@@ -71,6 +69,7 @@ export function GrpcConnectionSetupPane({
|
||||
onSend,
|
||||
}: Props) {
|
||||
const updateRequest = useUpdateAnyGrpcRequest();
|
||||
const authentication = useHttpAuthentication();
|
||||
const [activeTabs, setActiveTabs] = useAtom(tabsAtom);
|
||||
const { updateKey: forceUpdateKey } = useRequestUpdateKey(activeRequest.id ?? null);
|
||||
|
||||
@@ -136,11 +135,6 @@ export function GrpcConnectionSetupPane({
|
||||
|
||||
const tabs: TabItem[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: TAB_DESCRIPTION,
|
||||
label: 'Info',
|
||||
rightSlot: activeRequest.description && <CountBadge count={true} />,
|
||||
},
|
||||
{ value: TAB_MESSAGE, label: 'Message' },
|
||||
{
|
||||
value: TAB_AUTH,
|
||||
@@ -148,24 +142,21 @@ export function GrpcConnectionSetupPane({
|
||||
options: {
|
||||
value: activeRequest.authenticationType,
|
||||
items: [
|
||||
{ label: 'Basic Auth', shortLabel: 'Basic', value: AUTH_TYPE_BASIC },
|
||||
{ label: 'Bearer Token', shortLabel: 'Bearer', value: AUTH_TYPE_BEARER },
|
||||
...authentication.map((a) => ({
|
||||
label: a.name,
|
||||
value: a.pluginName,
|
||||
})),
|
||||
{ type: 'separator' },
|
||||
{ label: 'No Authentication', shortLabel: 'Auth', value: AUTH_TYPE_NONE },
|
||||
{ label: 'No Authentication', shortLabel: 'Auth', value: null },
|
||||
],
|
||||
onChange: async (authenticationType) => {
|
||||
onChange: (authenticationType) => {
|
||||
let authentication: GrpcRequest['authentication'] = activeRequest.authentication;
|
||||
if (authenticationType === AUTH_TYPE_BASIC) {
|
||||
if (activeRequest.authenticationType !== authenticationType) {
|
||||
authentication = {
|
||||
username: authentication.username ?? '',
|
||||
password: authentication.password ?? '',
|
||||
};
|
||||
} else if (authenticationType === AUTH_TYPE_BEARER) {
|
||||
authentication = {
|
||||
token: authentication.token ?? '',
|
||||
// Reset auth if changing types
|
||||
};
|
||||
}
|
||||
await updateRequest.mutateAsync({
|
||||
updateRequest.mutate({
|
||||
id: activeRequest.id,
|
||||
update: { authenticationType, authentication },
|
||||
});
|
||||
@@ -173,12 +164,18 @@ export function GrpcConnectionSetupPane({
|
||||
},
|
||||
},
|
||||
{ value: TAB_METADATA, label: 'Metadata' },
|
||||
{
|
||||
value: TAB_DESCRIPTION,
|
||||
label: 'Info',
|
||||
rightSlot: activeRequest.description && <CountBadge count={true} />,
|
||||
},
|
||||
],
|
||||
[
|
||||
activeRequest.authentication,
|
||||
activeRequest.authenticationType,
|
||||
activeRequest.description,
|
||||
activeRequest.id,
|
||||
authentication,
|
||||
updateRequest,
|
||||
],
|
||||
);
|
||||
@@ -213,6 +210,7 @@ export function GrpcConnectionSetupPane({
|
||||
)}
|
||||
>
|
||||
<UrlBar
|
||||
key={forceUpdateKey}
|
||||
url={activeRequest.url ?? ''}
|
||||
method={null}
|
||||
submitIcon={null}
|
||||
@@ -321,16 +319,10 @@ export function GrpcConnectionSetupPane({
|
||||
protoFiles={protoFiles}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value="auth">
|
||||
{activeRequest.authenticationType === AUTH_TYPE_BASIC ? (
|
||||
<BasicAuth key={forceUpdateKey} request={activeRequest} />
|
||||
) : activeRequest.authenticationType === AUTH_TYPE_BEARER ? (
|
||||
<BearerAuth key={forceUpdateKey} request={activeRequest} />
|
||||
) : (
|
||||
<EmptyStateText>No Authentication {activeRequest.authenticationType}</EmptyStateText>
|
||||
)}
|
||||
<TabContent value={TAB_AUTH}>
|
||||
<HttpAuthenticationEditor request={activeRequest} />
|
||||
</TabContent>
|
||||
<TabContent value="metadata">
|
||||
<TabContent value={TAB_METADATA}>
|
||||
<PairOrBulkEditor
|
||||
preferenceName="grpc_metadata"
|
||||
valueAutocompleteVariables
|
||||
|
||||
48
src-web/components/HttpAuthenticationEditor.tsx
Normal file
48
src-web/components/HttpAuthenticationEditor.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { GrpcRequest, HttpRequest } from '@yaakapp-internal/models';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useHttpAuthentication } from '../hooks/useHttpAuthentication';
|
||||
import { useUpdateAnyGrpcRequest } from '../hooks/useUpdateAnyGrpcRequest';
|
||||
import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest';
|
||||
import { DynamicForm } from './DynamicForm';
|
||||
import { EmptyStateText } from './EmptyStateText';
|
||||
|
||||
interface Props {
|
||||
request: HttpRequest | GrpcRequest;
|
||||
}
|
||||
|
||||
export function HttpAuthenticationEditor({ request }: Props) {
|
||||
const updateHttpRequest = useUpdateAnyHttpRequest();
|
||||
const updateGrpcRequest = useUpdateAnyGrpcRequest();
|
||||
const auths = useHttpAuthentication();
|
||||
const auth = auths.find((a) => a.pluginName === request.authenticationType);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(authentication: Record<string, boolean>) => {
|
||||
if (request.model === 'http_request') {
|
||||
updateHttpRequest.mutate({
|
||||
id: request.id,
|
||||
update: (r) => ({ ...r, authentication }),
|
||||
});
|
||||
} else {
|
||||
updateGrpcRequest.mutate({
|
||||
id: request.id,
|
||||
update: (r) => ({ ...r, authentication }),
|
||||
});
|
||||
}
|
||||
},
|
||||
[request.id, request.model, updateGrpcRequest, updateHttpRequest],
|
||||
);
|
||||
|
||||
if (auth == null) {
|
||||
return <EmptyStateText>No Authentication {request.authenticationType}</EmptyStateText>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DynamicForm
|
||||
stateKey={`auth.${request.id}.${request.authenticationType}`}
|
||||
config={auth.config}
|
||||
data={request.authentication}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -32,8 +32,9 @@ export function ImportCurlButton() {
|
||||
variant="border"
|
||||
color="success"
|
||||
className="rounded-full"
|
||||
leftSlot={<Icon icon="paste" size="sm" />}
|
||||
rightSlot={<Icon icon="import" size="sm" />}
|
||||
isLoading={isLoading}
|
||||
title="Import Curl command from clipboard"
|
||||
onClick={async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import classNames from 'classnames';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useKeyPressEvent } from 'react-use';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { getActiveWorkspaceId } from '../hooks/useActiveWorkspace';
|
||||
import { grpcRequestsAtom } from '../hooks/useGrpcRequests';
|
||||
import { useHotKey } from '../hooks/useHotKey';
|
||||
import { httpRequestsAtom } from '../hooks/useHttpRequests';
|
||||
import { useKeyboardEvent } from '../hooks/useKeyboardEvent';
|
||||
import { useRecentRequests } from '../hooks/useRecentRequests';
|
||||
import { fallbackRequestName } from '../lib/fallbackRequestName';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
@@ -27,16 +27,16 @@ export function RecentRequestsDropdown({ className }: Props) {
|
||||
// Handle key-up
|
||||
// TODO: Somehow make useHotKey have this functionality. Note: e.key does not work
|
||||
// on Linux, for example, when Control is mapped to CAPS. This will never fire.
|
||||
useKeyPressEvent('Control', undefined, () => {
|
||||
if (!dropdownRef.current?.isOpen) return;
|
||||
dropdownRef.current?.select?.();
|
||||
useKeyboardEvent('keyup', 'Control', () => {
|
||||
if (dropdownRef.current?.isOpen) {
|
||||
dropdownRef.current?.select?.();
|
||||
}
|
||||
});
|
||||
|
||||
useHotKey('request_switcher.prev', () => {
|
||||
if (!dropdownRef.current?.isOpen) {
|
||||
dropdownRef.current?.open();
|
||||
// Select the second because the first is the current request
|
||||
dropdownRef.current?.next?.(2);
|
||||
dropdownRef.current?.open(1);
|
||||
} else {
|
||||
dropdownRef.current?.next?.();
|
||||
}
|
||||
|
||||
@@ -52,7 +52,6 @@ export const RecentResponsesDropdown = function ResponsePane({
|
||||
label: 'Delete',
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: deleteResponse.mutate,
|
||||
disabled: activeResponse.state !== 'closed',
|
||||
},
|
||||
{
|
||||
key: 'unpin',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { activeRequestIdAtom } from '../hooks/useActiveRequestId';
|
||||
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
|
||||
import { useContentTypeFromHeaders } from '../hooks/useContentTypeFromHeaders';
|
||||
import { grpcRequestsAtom } from '../hooks/useGrpcRequests';
|
||||
import { useHttpAuthentication } from '../hooks/useHttpAuthentication';
|
||||
import { httpRequestsAtom } from '../hooks/useHttpRequests';
|
||||
import { useImportCurl } from '../hooks/useImportCurl';
|
||||
import { useImportQuerystring } from '../hooks/useImportQuerystring';
|
||||
@@ -23,9 +24,6 @@ import { fallbackRequestName } from '../lib/fallbackRequestName';
|
||||
import { tryFormatJson } from '../lib/formatters';
|
||||
import { generateId } from '../lib/generateId';
|
||||
import {
|
||||
AUTH_TYPE_BASIC,
|
||||
AUTH_TYPE_BEARER,
|
||||
AUTH_TYPE_NONE,
|
||||
BODY_TYPE_BINARY,
|
||||
BODY_TYPE_FORM_MULTIPART,
|
||||
BODY_TYPE_FORM_URLENCODED,
|
||||
@@ -36,8 +34,6 @@ import {
|
||||
BODY_TYPE_XML,
|
||||
} from '../lib/model_util';
|
||||
import { showToast } from '../lib/toast';
|
||||
import { BasicAuth } from './BasicAuth';
|
||||
import { BearerAuth } from './BearerAuth';
|
||||
import { BinaryFileEditor } from './BinaryFileEditor';
|
||||
import { CountBadge } from './core/CountBadge';
|
||||
import { Editor } from './core/Editor/Editor';
|
||||
@@ -55,6 +51,7 @@ import { FormMultipartEditor } from './FormMultipartEditor';
|
||||
import { FormUrlencodedEditor } from './FormUrlencodedEditor';
|
||||
import { GraphQLEditor } from './GraphQLEditor';
|
||||
import { HeadersEditor } from './HeadersEditor';
|
||||
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
|
||||
import { MarkdownEditor } from './MarkdownEditor';
|
||||
import { UrlBar } from './UrlBar';
|
||||
import { UrlParametersEditor } from './UrlParameterEditor';
|
||||
@@ -97,6 +94,7 @@ export const RequestPane = memo(function RequestPane({
|
||||
const { updateKey: forceUpdateKey } = useRequestUpdateKey(activeRequest.id ?? null);
|
||||
const [{ urlKey }] = useRequestEditor();
|
||||
const contentType = useContentTypeFromHeaders(activeRequest.headers);
|
||||
const authentication = useHttpAuthentication();
|
||||
|
||||
const handleContentTypeChange = useCallback(
|
||||
async (contentType: string | null) => {
|
||||
@@ -236,21 +234,18 @@ export const RequestPane = memo(function RequestPane({
|
||||
options: {
|
||||
value: activeRequest.authenticationType,
|
||||
items: [
|
||||
{ label: 'Basic Auth', shortLabel: 'Basic', value: AUTH_TYPE_BASIC },
|
||||
{ label: 'Bearer Token', shortLabel: 'Bearer', value: AUTH_TYPE_BEARER },
|
||||
...authentication.map((a) => ({
|
||||
label: a.name,
|
||||
value: a.pluginName,
|
||||
})),
|
||||
{ type: 'separator' },
|
||||
{ label: 'No Authentication', shortLabel: 'Auth', value: AUTH_TYPE_NONE },
|
||||
{ label: 'No Authentication', shortLabel: 'Auth', value: null },
|
||||
],
|
||||
onChange: async (authenticationType) => {
|
||||
let authentication: HttpRequest['authentication'] = activeRequest.authentication;
|
||||
if (authenticationType === AUTH_TYPE_BASIC) {
|
||||
if (activeRequest.authenticationType !== authenticationType) {
|
||||
authentication = {
|
||||
username: authentication.username ?? '',
|
||||
password: authentication.password ?? '',
|
||||
};
|
||||
} else if (authenticationType === AUTH_TYPE_BEARER) {
|
||||
authentication = {
|
||||
token: authentication.token ?? '',
|
||||
// Reset auth if changing types
|
||||
};
|
||||
}
|
||||
updateRequest({
|
||||
@@ -272,6 +267,7 @@ export const RequestPane = memo(function RequestPane({
|
||||
activeRequest.headers,
|
||||
activeRequest.method,
|
||||
activeRequestId,
|
||||
authentication,
|
||||
handleContentTypeChange,
|
||||
numParams,
|
||||
updateRequest,
|
||||
@@ -384,15 +380,7 @@ export const RequestPane = memo(function RequestPane({
|
||||
tabListClassName="mt-2 !mb-1.5"
|
||||
>
|
||||
<TabContent value={TAB_AUTH}>
|
||||
{activeRequest.authenticationType === AUTH_TYPE_BASIC ? (
|
||||
<BasicAuth key={forceUpdateKey} request={activeRequest} />
|
||||
) : activeRequest.authenticationType === AUTH_TYPE_BEARER ? (
|
||||
<BearerAuth key={forceUpdateKey} request={activeRequest} />
|
||||
) : (
|
||||
<EmptyStateText>
|
||||
No Authentication {activeRequest.authenticationType}
|
||||
</EmptyStateText>
|
||||
)}
|
||||
<HttpAuthenticationEditor request={activeRequest} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_HEADERS}>
|
||||
<HeadersEditor
|
||||
|
||||
@@ -1,32 +1,14 @@
|
||||
import type { Folder, HttpRequest } from '@yaakapp-internal/models';
|
||||
import type {
|
||||
TemplateFunction,
|
||||
TemplateFunctionArg,
|
||||
TemplateFunctionCheckboxArg,
|
||||
TemplateFunctionFileArg,
|
||||
TemplateFunctionHttpRequestArg,
|
||||
TemplateFunctionSelectArg,
|
||||
TemplateFunctionTextArg,
|
||||
} from '@yaakapp-internal/plugins';
|
||||
import type { TemplateFunction } from '@yaakapp-internal/plugins';
|
||||
import type { FnArg, Tokens } from '@yaakapp-internal/templates';
|
||||
import classNames from 'classnames';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useDebouncedValue } from '../hooks/useDebouncedValue';
|
||||
import { useFolders } from '../hooks/useFolders';
|
||||
import { useHttpRequests } from '../hooks/useHttpRequests';
|
||||
import { useRenderTemplate } from '../hooks/useRenderTemplate';
|
||||
import { useTemplateTokensToString } from '../hooks/useTemplateTokensToString';
|
||||
import { fallbackRequestName } from '../lib/fallbackRequestName';
|
||||
import { Button } from './core/Button';
|
||||
import { Checkbox } from './core/Checkbox';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { PlainInput } from './core/PlainInput';
|
||||
import { Select } from './core/Select';
|
||||
import { VStack } from './core/Stacks';
|
||||
import { SelectFile } from './SelectFile';
|
||||
|
||||
const NULL_ARG = '__NULL__';
|
||||
import { DYNAMIC_FORM_NULL_ARG, DynamicForm } from './DynamicForm';
|
||||
|
||||
interface Props {
|
||||
templateFunction: TemplateFunction;
|
||||
@@ -49,21 +31,17 @@ export function TemplateFunctionDialog({ templateFunction, hide, initialTokens,
|
||||
? initialArg?.value.text
|
||||
: // TODO: Implement variable-based args
|
||||
'__NULL__';
|
||||
initial[arg.name] = initialArgValue ?? NULL_ARG;
|
||||
initial[arg.name] = initialArgValue ?? DYNAMIC_FORM_NULL_ARG;
|
||||
}
|
||||
|
||||
return initial;
|
||||
});
|
||||
|
||||
const setArgValue = useCallback((name: string, value: string | boolean | null) => {
|
||||
setArgValues((v) => ({ ...v, [name]: value == null ? '__NULL__' : value }));
|
||||
}, []);
|
||||
|
||||
const tokens: Tokens = useMemo(() => {
|
||||
const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({
|
||||
name,
|
||||
value:
|
||||
argValues[name] === NULL_ARG
|
||||
argValues[name] === DYNAMIC_FORM_NULL_ARG
|
||||
? { type: 'null' }
|
||||
: typeof argValues[name] === 'boolean'
|
||||
? { type: 'bool', value: argValues[name] === true }
|
||||
@@ -100,57 +78,12 @@ export function TemplateFunctionDialog({ templateFunction, hide, initialTokens,
|
||||
return (
|
||||
<VStack className="pb-3" space={4}>
|
||||
<h1 className="font-mono !text-base">{templateFunction.name}(…)</h1>
|
||||
<VStack space={2}>
|
||||
{templateFunction.args.map((a: TemplateFunctionArg, i: number) => {
|
||||
switch (a.type) {
|
||||
case 'select':
|
||||
return (
|
||||
<SelectArg
|
||||
key={i}
|
||||
arg={a}
|
||||
onChange={(v) => setArgValue(a.name, v)}
|
||||
value={argValues[a.name] ? String(argValues[a.name]) : '__ERROR__'}
|
||||
/>
|
||||
);
|
||||
case 'text':
|
||||
return (
|
||||
<TextArg
|
||||
key={i}
|
||||
arg={a}
|
||||
onChange={(v) => setArgValue(a.name, v)}
|
||||
value={argValues[a.name] ? String(argValues[a.name]) : '__ERROR__'}
|
||||
/>
|
||||
);
|
||||
case 'checkbox':
|
||||
return (
|
||||
<CheckboxArg
|
||||
key={i}
|
||||
arg={a}
|
||||
onChange={(v) => setArgValue(a.name, v)}
|
||||
value={argValues[a.name] !== undefined ? argValues[a.name] === true : false}
|
||||
/>
|
||||
);
|
||||
case 'http_request':
|
||||
return (
|
||||
<HttpRequestArg
|
||||
key={i}
|
||||
arg={a}
|
||||
onChange={(v) => setArgValue(a.name, v)}
|
||||
value={argValues[a.name] ? String(argValues[a.name]) : '__ERROR__'}
|
||||
/>
|
||||
);
|
||||
case 'file':
|
||||
return (
|
||||
<FileArg
|
||||
key={i}
|
||||
arg={a}
|
||||
onChange={(v) => setArgValue(a.name, v)}
|
||||
filePath={argValues[a.name] ? String(argValues[a.name]) : '__ERROR__'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</VStack>
|
||||
<DynamicForm
|
||||
config={templateFunction.args}
|
||||
data={argValues}
|
||||
onChange={setArgValues}
|
||||
stateKey={`template_function.${templateFunction.name}`}
|
||||
/>
|
||||
<VStack className="w-full">
|
||||
<div className="text-sm text-text-subtle">Preview</div>
|
||||
<InlineCode
|
||||
@@ -168,147 +101,3 @@ export function TemplateFunctionDialog({ templateFunction, hide, initialTokens,
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function TextArg({
|
||||
arg,
|
||||
onChange,
|
||||
value,
|
||||
}: {
|
||||
arg: TemplateFunctionTextArg;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
onChange(value === '' ? NULL_ARG : value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<PlainInput
|
||||
name={arg.name}
|
||||
onChange={handleChange}
|
||||
defaultValue={value === NULL_ARG ? '' : value}
|
||||
require={!arg.optional}
|
||||
label={
|
||||
<>
|
||||
{arg.label ?? arg.name}
|
||||
{arg.optional && <span> (optional)</span>}
|
||||
</>
|
||||
}
|
||||
hideLabel={arg.label == null}
|
||||
placeholder={arg.placeholder ?? arg.defaultValue ?? ''}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectArg({
|
||||
arg,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
arg: TemplateFunctionSelectArg;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
label={arg.label ?? arg.name}
|
||||
name={arg.name}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
options={[
|
||||
...arg.options.map((a) => ({
|
||||
label: a.label + (arg.defaultValue === a.value ? ' (default)' : ''),
|
||||
value: a.value === arg.defaultValue ? NULL_ARG : a.value,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FileArg({
|
||||
arg,
|
||||
filePath,
|
||||
onChange,
|
||||
}: {
|
||||
arg: TemplateFunctionFileArg;
|
||||
filePath: string;
|
||||
onChange: (v: string | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<SelectFile
|
||||
onChange={({ filePath }) => onChange(filePath)}
|
||||
filePath={filePath === '__NULL__' ? null : filePath}
|
||||
directory={!!arg.directory}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HttpRequestArg({
|
||||
arg,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
arg: TemplateFunctionHttpRequestArg;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const folders = useFolders();
|
||||
const httpRequests = useHttpRequests();
|
||||
const activeRequest = useActiveRequest();
|
||||
return (
|
||||
<Select
|
||||
label={arg.label ?? arg.name}
|
||||
name={arg.name}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
options={[
|
||||
...httpRequests.map((r) => {
|
||||
return {
|
||||
label: buildRequestBreadcrumbs(r, folders).join(' / ') + (r.id == activeRequest?.id ? ' (current)' : ''),
|
||||
value: r.id,
|
||||
};
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function buildRequestBreadcrumbs(request: HttpRequest, folders: Folder[]): string[] {
|
||||
const ancestors: (HttpRequest | Folder)[] = [request];
|
||||
|
||||
const next = () => {
|
||||
const latest = ancestors[0];
|
||||
if (latest == null) return [];
|
||||
|
||||
const parent = folders.find((f) => f.id === latest.folderId);
|
||||
if (parent == null) return;
|
||||
|
||||
ancestors.unshift(parent);
|
||||
next();
|
||||
};
|
||||
next();
|
||||
|
||||
return ancestors.map((a) => (a.model === 'folder' ? a.name : fallbackRequestName(a)));
|
||||
}
|
||||
|
||||
function CheckboxArg({
|
||||
arg,
|
||||
onChange,
|
||||
value,
|
||||
}: {
|
||||
arg: TemplateFunctionCheckboxArg;
|
||||
value: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Checkbox
|
||||
onChange={onChange}
|
||||
checked={value}
|
||||
title={arg.label ?? arg.name}
|
||||
hideLabel={arg.label == null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { generateId } from '../../lib/generateId';
|
||||
import { Editor } from './Editor/Editor';
|
||||
import type { PairEditorProps } from './PairEditor';
|
||||
import type { PairEditorProps, PairWithId } from './PairEditor';
|
||||
|
||||
type Props = PairEditorProps;
|
||||
|
||||
@@ -45,14 +45,12 @@ export function BulkPairEditor({
|
||||
);
|
||||
}
|
||||
|
||||
function lineToPair(line: string): PairEditorProps['pairs'][0] {
|
||||
function lineToPair(line: string): PairWithId {
|
||||
const [, name, value] = line.match(/^(:?[^:]+):\s+(.*)$/) ?? [];
|
||||
|
||||
const pair: PairEditorProps['pairs'][0] = {
|
||||
return {
|
||||
enabled: true,
|
||||
name: (name ?? '').trim(),
|
||||
value: (value ?? '').trim(),
|
||||
id: generateId(),
|
||||
};
|
||||
return pair;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import classNames from 'classnames';
|
||||
import { motion } from 'framer-motion';
|
||||
import { atom, useAtom } from 'jotai';
|
||||
import { atom } from 'jotai';
|
||||
import type {
|
||||
CSSProperties,
|
||||
FocusEvent as ReactFocusEvent,
|
||||
@@ -12,11 +12,11 @@ import type {
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
import React, {
|
||||
useEffect,
|
||||
Children,
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -29,6 +29,7 @@ import { useHotKey } from '../../hooks/useHotKey';
|
||||
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
|
||||
import { generateId } from '../../lib/generateId';
|
||||
import { getNodeText } from '../../lib/getNodeText';
|
||||
import { jotaiStore } from '../../lib/jotai';
|
||||
import { Overlay } from '../Overlay';
|
||||
import { Button } from './Button';
|
||||
import { HotKey } from './HotKey';
|
||||
@@ -62,15 +63,13 @@ export type DropdownItem = DropdownItemDefault | DropdownItemSeparator;
|
||||
export interface DropdownProps {
|
||||
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
|
||||
items: DropdownItem[];
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
fullWidth?: boolean;
|
||||
hotKeyAction?: HotkeyAction;
|
||||
}
|
||||
|
||||
export interface DropdownRef {
|
||||
isOpen: boolean;
|
||||
open: () => void;
|
||||
open: (index?: number) => void;
|
||||
toggle: () => void;
|
||||
close?: () => void;
|
||||
next?: (incrBy?: number) => void;
|
||||
@@ -84,46 +83,52 @@ export interface DropdownRef {
|
||||
const openAtom = atom<string | null>(null);
|
||||
|
||||
export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown(
|
||||
{ children, items, onOpen, onClose, hotKeyAction, fullWidth }: DropdownProps,
|
||||
{ children, items, hotKeyAction, fullWidth }: DropdownProps,
|
||||
ref,
|
||||
) {
|
||||
const id = useRef(generateId()).current;
|
||||
const [openId, setOpenId] = useAtom(openAtom);
|
||||
const isOpen = openId === id;
|
||||
const id = useRef(generateId());
|
||||
const [isOpen, _setIsOpen] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
return jotaiStore.sub(openAtom, () => {
|
||||
const globalOpenId = jotaiStore.get(openAtom);
|
||||
const newIsOpen = globalOpenId === id.current;
|
||||
if (newIsOpen !== isOpen) {
|
||||
_setIsOpen(newIsOpen);
|
||||
}
|
||||
});
|
||||
}, [isOpen, _setIsOpen]);
|
||||
|
||||
// const [isOpen, _setIsOpen] = useState<boolean>(false);
|
||||
const [defaultSelectedIndex, setDefaultSelectedIndex] = useState<number | null>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<Omit<DropdownRef, 'open'>>(null);
|
||||
|
||||
const setIsOpen = useCallback(
|
||||
(o: SetStateAction<boolean>) => {
|
||||
setOpenId((prevId) => {
|
||||
const prevIsOpen = prevId === id;
|
||||
const newIsOpen = typeof o === 'function' ? o(prevIsOpen) : o;
|
||||
return newIsOpen ? id : null; // Set global atom to current ID to signify open state
|
||||
});
|
||||
},
|
||||
[id, setOpenId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const setIsOpen = useCallback((o: SetStateAction<boolean>) => {
|
||||
jotaiStore.set(openAtom, (prevId) => {
|
||||
const prevIsOpen = prevId === id.current;
|
||||
const newIsOpen = typeof o === 'function' ? o(prevIsOpen) : o;
|
||||
// Persist background color of button until we close the dropdown
|
||||
buttonRef.current!.style.backgroundColor = window
|
||||
.getComputedStyle(buttonRef.current!)
|
||||
.getPropertyValue('background-color');
|
||||
onOpen?.();
|
||||
} else {
|
||||
onClose?.();
|
||||
if (newIsOpen) {
|
||||
buttonRef.current!.style.backgroundColor = window
|
||||
.getComputedStyle(buttonRef.current!)
|
||||
.getPropertyValue('background-color');
|
||||
}
|
||||
return newIsOpen ? id.current : null; // Set global atom to current ID to signify open state
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Because a different dropdown can cause ours to close, a useEffect([isOpen]) is the only method
|
||||
// we have of detecting the dropdown closed, to do cleanup.
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
buttonRef.current?.focus(); // Focus button
|
||||
buttonRef.current!.style.backgroundColor = ''; // Clear persisted BG
|
||||
// Set to different value when opened and closed to force it to update. This is to force
|
||||
// <Menu/> to reset its selected-index state, which it does when this prop changes
|
||||
setDefaultSelectedIndex(null);
|
||||
}
|
||||
|
||||
// Set to different value when opened and closed to force it to update. This is to force
|
||||
// <Menu/> to reset its selected-index state, which it does when this prop changes
|
||||
setDefaultSelectedIndex(isOpen ? -1 : null);
|
||||
}, [isOpen, onClose, onOpen]);
|
||||
}, [isOpen]);
|
||||
|
||||
// Pull into variable so linter forces us to add it as a hook dep to useImperativeHandle. If we don't,
|
||||
// the ref will not update when menuRef updates, causing stale callback state to be used.
|
||||
@@ -138,8 +143,9 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
|
||||
if (!isOpen) this.open();
|
||||
else this.close();
|
||||
},
|
||||
open() {
|
||||
open(index?: number) {
|
||||
setIsOpen(true);
|
||||
setDefaultSelectedIndex(index ?? -1);
|
||||
},
|
||||
close() {
|
||||
setIsOpen(false);
|
||||
@@ -264,11 +270,18 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
||||
ref,
|
||||
) {
|
||||
const [selectedIndex, setSelectedIndex] = useStateWithDeps<number | null>(
|
||||
defaultSelectedIndex ?? null,
|
||||
defaultSelectedIndex ?? -1,
|
||||
[defaultSelectedIndex],
|
||||
);
|
||||
const [filter, setFilter] = useState<string>('');
|
||||
|
||||
// HACK: Use a ref to track selectedIndex so our closure functions (eg. select()) can
|
||||
// have access to the latest value.
|
||||
const selectedIndexRef = useRef(selectedIndex);
|
||||
useEffect(() => {
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
}, [selectedIndex]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClose();
|
||||
setFilter('');
|
||||
@@ -380,12 +393,12 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
||||
prev: handlePrev,
|
||||
next: handleNext,
|
||||
select() {
|
||||
const item = items[selectedIndex ?? -1] ?? null;
|
||||
const item = items[selectedIndexRef.current ?? -1] ?? null;
|
||||
if (!item) return;
|
||||
handleSelect(item);
|
||||
},
|
||||
};
|
||||
}, [handleClose, handleNext, handlePrev, handleSelect, items, selectedIndex]);
|
||||
}, [handleClose, handleNext, handlePrev, handleSelect, items]);
|
||||
|
||||
const styles = useMemo<{
|
||||
container: CSSProperties;
|
||||
|
||||
@@ -51,14 +51,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Don't show selection on blurred input */
|
||||
|
||||
.cm-selectionBackground {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
&.cm-focused .cm-selectionBackground {
|
||||
@apply bg-selection;
|
||||
@apply bg-selection !important;
|
||||
}
|
||||
|
||||
/* Style gutters */
|
||||
|
||||
@@ -26,10 +26,7 @@ import { useActiveEnvironmentVariables } from '../../../hooks/useActiveEnvironme
|
||||
import { parseTemplate } from '../../../hooks/useParseTemplate';
|
||||
import { useRequestEditor } from '../../../hooks/useRequestEditor';
|
||||
import { useSettings } from '../../../hooks/useSettings';
|
||||
import {
|
||||
useTemplateFunctions,
|
||||
useTwigCompletionOptions,
|
||||
} from '../../../hooks/useTemplateFunctions';
|
||||
import { useTemplateFunctionCompletionOptions } from '../../../hooks/useTemplateFunctions';
|
||||
import { showDialog } from '../../../lib/dialog';
|
||||
import { TemplateFunctionDialog } from '../../TemplateFunctionDialog';
|
||||
import { TemplateVariableDialog } from '../../TemplateVariableDialog';
|
||||
@@ -93,6 +90,10 @@ const stateFields = { history: historyField, folds: foldState };
|
||||
const emptyVariables: EnvironmentVariable[] = [];
|
||||
const emptyExtension: Extension = [];
|
||||
|
||||
// NOTE: For some reason, the cursor doesn't appear if the field is empty and there is no
|
||||
// placeholder. So we set it to a space to force it to show.
|
||||
const emptyPlaceholder = ' ';
|
||||
|
||||
export const Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
|
||||
{
|
||||
readOnly,
|
||||
@@ -126,7 +127,6 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
) {
|
||||
const settings = useSettings();
|
||||
|
||||
const templateFunctions = useTemplateFunctions();
|
||||
const allEnvironmentVariables = useActiveEnvironmentVariables();
|
||||
const environmentVariables = autocompleteVariables ? allEnvironmentVariables : emptyVariables;
|
||||
|
||||
@@ -178,7 +178,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
useEffect(
|
||||
function configurePlaceholder() {
|
||||
if (cm.current === null) return;
|
||||
const ext = placeholderExt(placeholderElFromText(placeholder ?? ''));
|
||||
const ext = placeholderExt(placeholderElFromText(placeholder || emptyPlaceholder));
|
||||
const effect = placeholderCompartment.current.reconfigure(ext);
|
||||
cm.current?.view.dispatch({ effects: effect });
|
||||
},
|
||||
@@ -300,7 +300,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
[focusParamValue],
|
||||
);
|
||||
|
||||
const completionOptions = useTwigCompletionOptions(onClickFunction);
|
||||
const completionOptions = useTemplateFunctionCompletionOptions(onClickFunction);
|
||||
|
||||
// Update the language extension when the language changes
|
||||
useEffect(() => {
|
||||
@@ -322,7 +322,6 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
autocomplete,
|
||||
useTemplating,
|
||||
environmentVariables,
|
||||
templateFunctions,
|
||||
onClickFunction,
|
||||
onClickVariable,
|
||||
onClickMissingVariable,
|
||||
@@ -355,7 +354,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
const extensions = [
|
||||
languageCompartment.of(langExt),
|
||||
placeholderCompartment.current.of(
|
||||
placeholderExt(placeholderElFromText(placeholder ?? '')),
|
||||
placeholderExt(placeholderElFromText(placeholder || emptyPlaceholder)),
|
||||
),
|
||||
wrapLinesCompartment.current.of(wrapLines ? EditorView.lineWrapping : []),
|
||||
keymapCompartment.current.of(
|
||||
@@ -601,13 +600,16 @@ const placeholderElFromText = (text: string) => {
|
||||
|
||||
function saveCachedEditorState(stateKey: string | null, state: EditorState | null) {
|
||||
if (!stateKey || state == null) return;
|
||||
sessionStorage.setItem(stateKey, JSON.stringify(state.toJSON(stateFields)));
|
||||
sessionStorage.setItem(
|
||||
computeFullStateKey(stateKey),
|
||||
JSON.stringify(state.toJSON(stateFields)),
|
||||
);
|
||||
}
|
||||
|
||||
function getCachedEditorState(doc: string, stateKey: string | null) {
|
||||
if (stateKey == null) return;
|
||||
|
||||
const stateStr = sessionStorage.getItem(stateKey);
|
||||
const stateStr = sessionStorage.getItem(computeFullStateKey(stateKey));
|
||||
if (stateStr == null) return null;
|
||||
|
||||
try {
|
||||
@@ -621,3 +623,7 @@ function getCachedEditorState(doc: string, stateKey: string | null) {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function computeFullStateKey(stateKey: string): string {
|
||||
return `editor.${stateKey}`;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ const icons = {
|
||||
help: lucide.CircleHelpIcon,
|
||||
history: lucide.HistoryIcon,
|
||||
house: lucide.HomeIcon,
|
||||
import: lucide.ImportIcon,
|
||||
info: lucide.InfoIcon,
|
||||
keyboard: lucide.KeyboardIcon,
|
||||
left_panel_hidden: lucide.PanelLeftOpenIcon,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import classNames from 'classnames';
|
||||
import type { EditorView } from 'codemirror';
|
||||
import type { ReactNode } from 'react';
|
||||
import { forwardRef, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
||||
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
|
||||
import type { EditorProps } from './Editor/Editor';
|
||||
import { Editor } from './Editor/Editor';
|
||||
@@ -46,7 +46,7 @@ export type InputProps = Pick<
|
||||
stateKey: EditorProps['stateKey'];
|
||||
};
|
||||
|
||||
export const Input = forwardRef<EditorView | undefined, InputProps>(function Input(
|
||||
export const Input = forwardRef<EditorView, InputProps>(function Input(
|
||||
{
|
||||
className,
|
||||
containerClassName,
|
||||
@@ -79,6 +79,8 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
|
||||
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
|
||||
const [currentValue, setCurrentValue] = useState(defaultValue ?? '');
|
||||
const [focused, setFocused] = useState(false);
|
||||
const editorRef = useRef<EditorView | null>(null);
|
||||
useImperativeHandle<EditorView | null, EditorView | null>(ref, () => editorRef.current);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
if (readOnly) return;
|
||||
@@ -88,6 +90,7 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setFocused(false);
|
||||
editorRef.current?.dispatch({ selection: { anchor: 0 } });
|
||||
onBlur?.();
|
||||
}, [onBlur]);
|
||||
|
||||
@@ -164,7 +167,7 @@ export const Input = forwardRef<EditorView | undefined, InputProps>(function Inp
|
||||
)}
|
||||
>
|
||||
<Editor
|
||||
ref={ref}
|
||||
ref={editorRef}
|
||||
id={id}
|
||||
singleLine
|
||||
stateKey={stateKey}
|
||||
|
||||
@@ -43,7 +43,7 @@ export type PairEditorProps = {
|
||||
namePlaceholder?: string;
|
||||
nameValidate?: InputProps['validate'];
|
||||
noScroll?: boolean;
|
||||
onChange: (pairs: Pair[]) => void;
|
||||
onChange: (pairs: PairWithId[]) => void;
|
||||
pairs: Pair[];
|
||||
stateKey: InputProps['stateKey'];
|
||||
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
|
||||
@@ -54,7 +54,7 @@ export type PairEditorProps = {
|
||||
};
|
||||
|
||||
export type Pair = {
|
||||
id: string;
|
||||
id?: string;
|
||||
enabled?: boolean;
|
||||
name: string;
|
||||
value: string;
|
||||
@@ -63,6 +63,10 @@ export type Pair = {
|
||||
readOnlyName?: boolean;
|
||||
};
|
||||
|
||||
export type PairWithId = Pair & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
/** Max number of pairs to show before prompting the user to reveal the rest */
|
||||
const MAX_INITIAL_PAIRS = 50;
|
||||
|
||||
@@ -90,7 +94,7 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
|
||||
const [forceFocusNamePairId, setForceFocusNamePairId] = useState<string | null>(null);
|
||||
const [forceFocusValuePairId, setForceFocusValuePairId] = useState<string | null>(null);
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const [pairs, setPairs] = useState<Pair[]>([]);
|
||||
const [pairs, setPairs] = useState<PairWithId[]>([]);
|
||||
const [showAll, toggleShowAll] = useToggle(false);
|
||||
|
||||
useImperativeHandle(
|
||||
@@ -105,14 +109,13 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Remove empty headers on initial render and ensure they all have valid ids (pairs didn't used to have IDs)
|
||||
const newPairs = [];
|
||||
// Remove empty headers on initial render and ensure they all have valid ids (pairs didn't use to have IDs)
|
||||
const newPairs: PairWithId[] = [];
|
||||
for (let i = 0; i < originalPairs.length; i++) {
|
||||
const p = originalPairs[i];
|
||||
if (!p) continue; // Make TS happy
|
||||
if (isPairEmpty(p)) continue;
|
||||
if (!p.id) p.id = generateId();
|
||||
newPairs.push(p);
|
||||
newPairs.push({ ...p, id: p.id ?? generateId() });
|
||||
}
|
||||
|
||||
// Add empty last pair if there is none
|
||||
@@ -127,7 +130,7 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
|
||||
}, [forceUpdateKey]);
|
||||
|
||||
const setPairsAndSave = useCallback(
|
||||
(fn: (pairs: Pair[]) => Pair[]) => {
|
||||
(fn: (pairs: PairWithId[]) => PairWithId[]) => {
|
||||
setPairs((oldPairs) => {
|
||||
const pairs = fn(oldPairs);
|
||||
onChange(pairs);
|
||||
@@ -165,7 +168,7 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(pair: Pair) => setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
|
||||
(pair: PairWithId) => setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
|
||||
[setPairsAndSave],
|
||||
);
|
||||
|
||||
@@ -267,15 +270,15 @@ enum ItemTypes {
|
||||
|
||||
type PairEditorRowProps = {
|
||||
className?: string;
|
||||
pair: Pair;
|
||||
pair: PairWithId;
|
||||
forceFocusNamePairId?: string | null;
|
||||
forceFocusValuePairId?: string | null;
|
||||
onMove: (id: string, side: 'above' | 'below') => void;
|
||||
onEnd: (id: string) => void;
|
||||
onChange: (pair: Pair) => void;
|
||||
onDelete?: (pair: Pair, focusPrevious: boolean) => void;
|
||||
onFocus?: (pair: Pair) => void;
|
||||
onSubmit?: (pair: Pair) => void;
|
||||
onChange: (pair: PairWithId) => void;
|
||||
onDelete?: (pair: PairWithId, focusPrevious: boolean) => void;
|
||||
onFocus?: (pair: PairWithId) => void;
|
||||
onSubmit?: (pair: PairWithId) => void;
|
||||
isLast?: boolean;
|
||||
index: number;
|
||||
} & Pick<
|
||||
@@ -618,7 +621,7 @@ function FileActionsDropdown({
|
||||
);
|
||||
}
|
||||
|
||||
function emptyPair(): Pair {
|
||||
function emptyPair(): PairWithId {
|
||||
return {
|
||||
enabled: true,
|
||||
name: '',
|
||||
|
||||
Reference in New Issue
Block a user