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;