480 lines
14 KiB
TypeScript
480 lines
14 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import type {
|
|
AuthenticationType,
|
|
HttpMethod,
|
|
RestAction,
|
|
} from '../../types/project';
|
|
import {
|
|
HTTP_METHODS,
|
|
isAbsoluteHttpUrl,
|
|
keyValueRowsToRecord,
|
|
validateKeyValueRows,
|
|
validateRestAction,
|
|
} from './actionEditorUtils';
|
|
import type {
|
|
KeyValueRow,
|
|
RequestMapKeyKind,
|
|
} from './actionEditorUtils';
|
|
import styles from './ActionInspector.module.css';
|
|
|
|
const AUTHENTICATION_OPTIONS: Array<{
|
|
value: AuthenticationType;
|
|
label: string;
|
|
}> = [
|
|
{ value: 'anonymous', label: 'Anonymous' },
|
|
{ value: 'bearerToken', label: 'Bearer token (Slice 3)' },
|
|
{ value: 'basicAuth', label: 'Basic authentication (Slice 3)' },
|
|
{ value: 'apiKeyHeader', label: 'API key header (Slice 3)' },
|
|
{ value: 'apiKeyQueryParameter', label: 'API key query parameter (Slice 3)' },
|
|
];
|
|
|
|
type KeyValueEditorProps = {
|
|
fieldName: string;
|
|
title: string;
|
|
keyPlaceholder: string;
|
|
valuePlaceholder?: string;
|
|
keyKind?: RequestMapKeyKind;
|
|
value: Record<string, string>;
|
|
onChange: (value: Record<string, string>) => void;
|
|
onValidityChange: (valid: boolean) => void;
|
|
};
|
|
|
|
function makeRows(value: Record<string, string>): KeyValueRow[] {
|
|
return Object.entries(value).map(([key, entryValue], index) => ({
|
|
id: index + 1,
|
|
key,
|
|
value: entryValue,
|
|
}));
|
|
}
|
|
|
|
function KeyValueEditor({
|
|
fieldName,
|
|
title,
|
|
keyPlaceholder,
|
|
valuePlaceholder = 'Value or {{components.name.value}}',
|
|
keyKind = 'generic',
|
|
value,
|
|
onChange,
|
|
onValidityChange,
|
|
}: KeyValueEditorProps): React.ReactElement {
|
|
const [rows, setRows] = useState<KeyValueRow[]>(() => makeRows(value));
|
|
const [error, setError] = useState<string | null>(null);
|
|
const nextRowId = useRef(rows.length + 1);
|
|
const lastCanonicalValue = useRef(value);
|
|
|
|
useEffect(() => {
|
|
if (value === lastCanonicalValue.current) return;
|
|
|
|
const nextRows = makeRows(value);
|
|
lastCanonicalValue.current = value;
|
|
nextRowId.current = nextRows.length + 1;
|
|
setRows(nextRows);
|
|
setError(null);
|
|
onValidityChange(true);
|
|
}, [onValidityChange, value]);
|
|
|
|
const commitRows = useCallback(
|
|
(nextRows: KeyValueRow[]) => {
|
|
setRows(nextRows);
|
|
const validationError = validateKeyValueRows(nextRows, keyKind);
|
|
setError(validationError);
|
|
onValidityChange(validationError === null);
|
|
if (validationError) return;
|
|
|
|
const nextRecord = keyValueRowsToRecord(nextRows);
|
|
lastCanonicalValue.current = nextRecord;
|
|
onChange(nextRecord);
|
|
},
|
|
[keyKind, onChange, onValidityChange],
|
|
);
|
|
|
|
const handleAdd = useCallback(() => {
|
|
setRows((current) => [
|
|
...current,
|
|
{ id: nextRowId.current++, key: '', value: '' },
|
|
]);
|
|
setError('Every row needs a key before it can be added to the project document.');
|
|
onValidityChange(false);
|
|
}, [onValidityChange]);
|
|
|
|
const handleChange = useCallback(
|
|
(rowId: number, field: 'key' | 'value', nextValue: string) => {
|
|
commitRows(
|
|
rows.map((row) =>
|
|
row.id === rowId ? { ...row, [field]: nextValue } : row,
|
|
),
|
|
);
|
|
},
|
|
[commitRows, rows],
|
|
);
|
|
|
|
const handleRemove = useCallback(
|
|
(rowId: number) => {
|
|
commitRows(rows.filter((row) => row.id !== rowId));
|
|
},
|
|
[commitRows, rows],
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={styles.keyValueEditor}
|
|
data-testid={`action-${fieldName}`}
|
|
>
|
|
<div className={styles.keyValueHeader}>
|
|
<span className={styles.formLabel}>{title}</span>
|
|
<button
|
|
type="button"
|
|
className={styles.smallButton}
|
|
onClick={handleAdd}
|
|
aria-label={`Add ${title.toLowerCase()} row`}
|
|
>
|
|
+ Add row
|
|
</button>
|
|
</div>
|
|
|
|
{rows.length === 0 && (
|
|
<div className={styles.formHint}>No entries configured.</div>
|
|
)}
|
|
|
|
{rows.map((row) => (
|
|
<div className={styles.keyValueRow} key={row.id}>
|
|
<input
|
|
className={styles.formInput}
|
|
aria-label={`${title} key`}
|
|
placeholder={keyPlaceholder}
|
|
value={row.key}
|
|
onChange={(event) => handleChange(row.id, 'key', event.target.value)}
|
|
/>
|
|
<input
|
|
className={styles.formInput}
|
|
aria-label={`${title} value`}
|
|
placeholder={valuePlaceholder}
|
|
value={row.value}
|
|
onChange={(event) => handleChange(row.id, 'value', event.target.value)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className={styles.removeRowButton}
|
|
onClick={() => handleRemove(row.id)}
|
|
aria-label={`Remove ${title.toLowerCase()} row`}
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
))}
|
|
|
|
{error && (
|
|
<div className={styles.inlineError} role="alert">
|
|
{error} Invalid rows remain local until corrected.
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export type RestActionEditorProps = {
|
|
action: RestAction;
|
|
actions: RestAction[];
|
|
onChange: (action: RestAction) => void;
|
|
onDone: () => void;
|
|
};
|
|
|
|
function RestActionEditor({
|
|
action,
|
|
actions,
|
|
onChange,
|
|
onDone,
|
|
}: RestActionEditorProps): React.ReactElement {
|
|
const [nameDraft, setNameDraft] = useState(action.name);
|
|
const [urlDraft, setUrlDraft] = useState(action.url);
|
|
const [invalidMapFields, setInvalidMapFields] = useState<Set<string>>(() => new Set());
|
|
const lastEmittedAction = useRef<RestAction | null>(null);
|
|
|
|
const nameDraftValid = nameDraft.trim().length > 0;
|
|
const urlDraftValid = isAbsoluteHttpUrl(urlDraft);
|
|
const hasInvalidDrafts =
|
|
!nameDraftValid || !urlDraftValid || invalidMapFields.size > 0;
|
|
|
|
const issues = useMemo(
|
|
() => validateRestAction(action, actions),
|
|
[action, actions],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (action === lastEmittedAction.current) {
|
|
lastEmittedAction.current = null;
|
|
return;
|
|
}
|
|
|
|
setNameDraft(action.name);
|
|
setUrlDraft(action.url);
|
|
setInvalidMapFields(new Set());
|
|
}, [action]);
|
|
|
|
const update = useCallback(
|
|
<K extends keyof RestAction>(field: K, value: RestAction[K]) => {
|
|
const nextAction = { ...action, [field]: value };
|
|
lastEmittedAction.current = nextAction;
|
|
onChange(nextAction);
|
|
},
|
|
[action, onChange],
|
|
);
|
|
const handleNameDraftChange = useCallback(
|
|
(value: string) => {
|
|
setNameDraft(value);
|
|
if (value.trim()) update('name', value);
|
|
},
|
|
[update],
|
|
);
|
|
|
|
const handleUrlDraftChange = useCallback(
|
|
(value: string) => {
|
|
setUrlDraft(value);
|
|
if (isAbsoluteHttpUrl(value)) update('url', value);
|
|
},
|
|
[update],
|
|
);
|
|
|
|
const handleMapValidityChange = useCallback(
|
|
(fieldName: string, valid: boolean) => {
|
|
setInvalidMapFields((current) => {
|
|
const alreadyInvalid = current.has(fieldName);
|
|
if ((valid && !alreadyInvalid) || (!valid && alreadyInvalid)) {
|
|
return current;
|
|
}
|
|
|
|
const next = new Set(current);
|
|
if (valid) next.delete(fieldName);
|
|
else next.add(fieldName);
|
|
return next;
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
|
|
const editorId = `rest-action-editor-${action.id}`;
|
|
|
|
return (
|
|
<form
|
|
className={styles.actionEditor}
|
|
aria-label={`Edit REST action ${action.name}`}
|
|
data-testid={editorId}
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
if (hasInvalidDrafts) return;
|
|
onDone();
|
|
}}
|
|
>
|
|
<div className={styles.actionEditorHeader}>
|
|
<div>
|
|
<div className={styles.actionEditorTitle}>Edit REST action</div>
|
|
<div className={styles.formHint}>
|
|
Valid edits update the canonical project document immediately.
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
className={styles.primaryButton}
|
|
disabled={hasInvalidDrafts}
|
|
title={hasInvalidDrafts ? 'Resolve invalid local drafts before closing.' : undefined}
|
|
>
|
|
Done
|
|
</button>
|
|
</div>
|
|
|
|
{issues.length > 0 && (
|
|
<div className={styles.editorIssues} aria-label="Action validation">
|
|
{issues.map((issue, index) => (
|
|
<div
|
|
key={`${issue.field}-${index}`}
|
|
className={
|
|
issue.severity === 'warn'
|
|
? styles.editorIssueWarn
|
|
: styles.editorIssueInfo
|
|
}
|
|
>
|
|
{issue.message}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className={styles.formGrid}>
|
|
<label className={styles.formField}>
|
|
<span className={styles.formLabel}>Action ID</span>
|
|
<input
|
|
className={styles.formInput}
|
|
data-testid="action-id"
|
|
value={action.id}
|
|
readOnly
|
|
/>
|
|
<span className={styles.formHint}>
|
|
Stable because component events and bindings reference this ID.
|
|
</span>
|
|
</label>
|
|
|
|
<label className={styles.formField}>
|
|
<span className={styles.formLabel}>Name</span>
|
|
<input
|
|
className={styles.formInput}
|
|
data-testid="action-name"
|
|
value={nameDraft}
|
|
onChange={(event) => handleNameDraftChange(event.target.value)}
|
|
/>
|
|
{!nameDraftValid && (
|
|
<span className={styles.inlineError} role="alert">
|
|
Name is required. The last valid canonical value is preserved.
|
|
</span>
|
|
)}
|
|
</label>
|
|
</div>
|
|
|
|
<label className={styles.formField}>
|
|
<span className={styles.formLabel}>Description</span>
|
|
<textarea
|
|
className={styles.formTextarea}
|
|
data-testid="action-description"
|
|
value={action.description ?? ''}
|
|
onChange={(event) => update('description', event.target.value)}
|
|
rows={2}
|
|
/>
|
|
</label>
|
|
|
|
<div className={styles.endpointRow}>
|
|
<label className={styles.methodField}>
|
|
<span className={styles.formLabel}>HTTP method</span>
|
|
<select
|
|
className={styles.formSelect}
|
|
data-testid="action-method"
|
|
value={action.method}
|
|
onChange={(event) =>
|
|
update('method', event.target.value as HttpMethod)
|
|
}
|
|
>
|
|
{HTTP_METHODS.map((method) => (
|
|
<option key={method} value={method}>{method}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
<label className={styles.urlField}>
|
|
<span className={styles.formLabel}>Endpoint URL</span>
|
|
<input
|
|
className={styles.formInput}
|
|
data-testid="action-url"
|
|
value={urlDraft}
|
|
placeholder="https://api.example.com/items/{{itemId}}"
|
|
onChange={(event) => handleUrlDraftChange(event.target.value)}
|
|
/>
|
|
{!urlDraftValid && (
|
|
<span className={styles.inlineError} role="alert">
|
|
Enter absolute HTTP or HTTPS without spaces or embedded credentials.
|
|
</span>
|
|
)}
|
|
</label>
|
|
</div>
|
|
|
|
<label className={styles.formField}>
|
|
<span className={styles.formLabel}>Authentication</span>
|
|
<select
|
|
className={styles.formSelect}
|
|
data-testid="action-authentication"
|
|
value={action.authenticationType}
|
|
onChange={(event) => {
|
|
const nextAuthenticationType =
|
|
event.target.value as AuthenticationType;
|
|
if (nextAuthenticationType === 'anonymous') {
|
|
update('authenticationType', nextAuthenticationType);
|
|
} else {
|
|
event.currentTarget.value = action.authenticationType;
|
|
}
|
|
}}
|
|
>
|
|
{AUTHENTICATION_OPTIONS.map((option) => (
|
|
<option
|
|
key={option.value}
|
|
value={option.value}
|
|
disabled={option.value !== 'anonymous'}
|
|
>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<span className={styles.formHint}>
|
|
Slice 2 creates anonymous actions only. Authentication and secrets are
|
|
configured in Slice 3.
|
|
</span>
|
|
</label>
|
|
|
|
<details className={styles.actionEditorDetails} open>
|
|
<summary>Request parameters</summary>
|
|
<div className={styles.detailsBody}>
|
|
<KeyValueEditor
|
|
key={`${action.id}-headers`}
|
|
fieldName="headers"
|
|
title="Headers"
|
|
keyPlaceholder="Accept"
|
|
keyKind="header"
|
|
value={action.headers ?? {}}
|
|
onChange={(nextValue) => update('headers', nextValue)}
|
|
onValidityChange={(valid) =>
|
|
handleMapValidityChange('headers', valid)
|
|
}
|
|
/>
|
|
<KeyValueEditor
|
|
key={`${action.id}-query`}
|
|
fieldName="query-parameters"
|
|
title="Query parameters"
|
|
keyPlaceholder="limit"
|
|
value={action.queryParameters ?? {}}
|
|
onChange={(nextValue) => update('queryParameters', nextValue)}
|
|
onValidityChange={(valid) =>
|
|
handleMapValidityChange('queryParameters', valid)
|
|
}
|
|
/>
|
|
<KeyValueEditor
|
|
key={`${action.id}-path`}
|
|
fieldName="path-parameters"
|
|
title="Path parameters"
|
|
keyPlaceholder="itemId"
|
|
keyKind="path"
|
|
valuePlaceholder="Static replacement value (templates are not supported)"
|
|
value={action.pathParameters ?? {}}
|
|
onChange={(nextValue) => update('pathParameters', nextValue)}
|
|
onValidityChange={(valid) =>
|
|
handleMapValidityChange('pathParameters', valid)
|
|
}
|
|
/>
|
|
</div>
|
|
</details>
|
|
|
|
<details className={styles.actionEditorDetails} open>
|
|
<summary>Request body</summary>
|
|
<div className={styles.detailsBody}>
|
|
<label className={styles.formField}>
|
|
<span className={styles.formLabel}>Body template</span>
|
|
<textarea
|
|
className={[styles.formTextarea, styles.bodyTemplate].join(' ')}
|
|
data-testid="action-body"
|
|
value={action.bodyTemplate ?? ''}
|
|
placeholder={'{"name":"{{components.nameInput.value}}"}'}
|
|
onChange={(event) => update('bodyTemplate', event.target.value)}
|
|
rows={7}
|
|
spellCheck={false}
|
|
/>
|
|
{(action.method === 'GET' || action.method === 'DELETE') && (
|
|
<span className={styles.formHint}>
|
|
{action.method} requests retain this configuration but do not
|
|
send a request body.
|
|
</span>
|
|
)}
|
|
</label>
|
|
</div>
|
|
</details>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export default RestActionEditor;
|