@deckbuilderkit/view-rest 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,227 @@
1
+ import { useEffect, useState } from 'react';
2
+ import type { RestEnvironment, RestEnvVarInput } from '@deckbuilderkit/server-core/protocol';
3
+ import { type ApiError, ErrorBanner, errorOf, Icon, Modal, useDialogs, useToast } from '@deckbuilderkit/ui';
4
+ import { restApi as api } from '../client';
5
+ import { t } from '../i18n';
6
+
7
+ /** A variable row being edited. `from`: its stored name (secret kept on rename); `replace`: a secret whose value is being typed. */
8
+ interface Row {
9
+ name: string;
10
+ value: string;
11
+ secret: boolean;
12
+ enabled: boolean;
13
+ set: boolean;
14
+ from?: string;
15
+ replace: boolean;
16
+ }
17
+
18
+ const toRows = (env: RestEnvironment | undefined): Row[] =>
19
+ (env?.vars ?? []).map((v) => ({ name: v.name, value: v.value, secret: v.secret, enabled: v.enabled, set: !!v.set, from: v.name, replace: false }));
20
+
21
+ const toInput = (rows: Row[]): RestEnvVarInput[] =>
22
+ rows
23
+ .filter((r) => r.name.trim() || r.value)
24
+ .map((r) => {
25
+ const base = { name: r.name.trim(), secret: r.secret, enabled: r.enabled };
26
+ if (!r.secret || r.replace) return { ...base, value: r.value };
27
+ return r.from && r.from !== base.name ? { ...base, from: r.from } : base;
28
+ });
29
+
30
+ const blank = (): Row => ({ name: '', value: '', secret: false, enabled: true, set: false, replace: false });
31
+
32
+ interface Props {
33
+ environments: RestEnvironment[] | undefined;
34
+ /** The list changed on the server; reload it. */
35
+ onChanged: () => void;
36
+ /** Environment `id` was deleted (clear it when active). */
37
+ onDeleted: (id: string) => void;
38
+ onClose: () => void;
39
+ }
40
+
41
+ /** "Manage environments…": add, rename and delete environments and edit their variables; secret values are write-only. */
42
+ export function EnvironmentsDialog({ environments, onChanged, onDeleted, onClose }: Props) {
43
+ const { confirm, prompt, element } = useDialogs();
44
+ const toast = useToast();
45
+ const [selected, setSelected] = useState<string | undefined>(environments?.[0]?.id);
46
+ const env = environments?.find((e) => e.id === selected);
47
+ const [rows, setRows] = useState<Row[]>(() => toRows(env));
48
+ const [error, setError] = useState<ApiError>();
49
+ const [busy, setBusy] = useState(false);
50
+ const dirty = JSON.stringify(rows) !== JSON.stringify(toRows(env));
51
+
52
+ useEffect(() => {
53
+ if (!env && environments?.length) setSelected(environments[0]!.id);
54
+ }, [env, environments]);
55
+ useEffect(() => setRows(toRows(env)), [env]);
56
+
57
+ const discardOk = async () => !dirty || (await confirm({ title: t('rest.env.discard'), okLabel: t('rest.env.discardOk'), danger: true }));
58
+
59
+ const select = async (id: string) => {
60
+ if (id === selected || !(await discardOk())) return;
61
+ setError(undefined);
62
+ setSelected(id);
63
+ };
64
+
65
+ const run = async (action: () => Promise<void>) => {
66
+ setBusy(true);
67
+ setError(undefined);
68
+ try {
69
+ await action();
70
+ } catch (err) {
71
+ setError(errorOf(err));
72
+ } finally {
73
+ setBusy(false);
74
+ }
75
+ };
76
+
77
+ const askName = (title: string, value: string) =>
78
+ prompt({ title, label: t('rest.env.name'), value, validate: (v) => (v.trim() ? null : t('error.badEnvironmentName')) });
79
+
80
+ const add = async () => {
81
+ if (!(await discardOk())) return;
82
+ const name = await askName(t('rest.env.addTitle'), '');
83
+ if (!name) return;
84
+ await run(async () => {
85
+ const created = await api.createRestEnvironment({ name: name.trim(), vars: [] });
86
+ setSelected(created.id);
87
+ onChanged();
88
+ });
89
+ };
90
+
91
+ const rename = async () => {
92
+ if (!env || !(await discardOk())) return;
93
+ const name = await askName(t('rest.env.renameTitle'), env.name);
94
+ if (!name || name.trim() === env.name) return;
95
+ await run(async () => {
96
+ await api.updateRestEnvironment(env.id, { name: name.trim(), vars: toInput(toRows(env)) });
97
+ onChanged();
98
+ });
99
+ };
100
+
101
+ const remove = async () => {
102
+ if (!env) return;
103
+ if (!(await confirm({ title: t('rest.env.deleteTitle', { name: env.name }), okLabel: t('rest.delete'), danger: true }))) return;
104
+ await run(async () => {
105
+ await api.deleteRestEnvironment(env.id);
106
+ setSelected(environments?.find((e) => e.id !== env.id)?.id);
107
+ onDeleted(env.id);
108
+ onChanged();
109
+ });
110
+ };
111
+
112
+ const save = () =>
113
+ env &&
114
+ run(async () => {
115
+ await api.updateRestEnvironment(env.id, { name: env.name, vars: toInput(rows) });
116
+ onChanged();
117
+ toast({ message: t('rest.env.saved', { name: env.name }) });
118
+ });
119
+
120
+ const close = async () => {
121
+ if (await discardOk()) onClose();
122
+ };
123
+
124
+ const patch = (i: number, change: Partial<Row>) => setRows(rows.map((r, j) => (j === i ? { ...r, ...change } : r)));
125
+
126
+ const footer = (
127
+ <>
128
+ <button className="btn" onClick={() => void close()}>{t('rest.import.close')}</button>
129
+ <button className="btn btn-primary" disabled={busy || !env || !dirty} onClick={() => void save()}>{t('rest.save')}</button>
130
+ </>
131
+ );
132
+
133
+ return (
134
+ <Modal title={t('rest.env.title')} onClose={() => void close()} footer={footer}>
135
+ {error && <ErrorBanner error={error} compact />}
136
+ <div className="rest-env">
137
+ <aside className="rest-env-list">
138
+ <div className="rest-list-head">
139
+ <span className="mono-label">{t('rest.env.list')}</span>
140
+ <button className="btn btn-sm" disabled={busy} onClick={() => void add()}><Icon name="plus" /> {t('rest.new')}</button>
141
+ </div>
142
+ {environments?.length === 0 && <p className="muted">{t('rest.env.empty')}</p>}
143
+ {environments?.map((e) => (
144
+ <div key={e.id} className={`rest-item${e.id === selected ? ' active' : ''}`}>
145
+ <button className="rest-item-main" onClick={() => void select(e.id)}>
146
+ <span className="rest-item-name">{e.name}</span>
147
+ </button>
148
+ {e.id === selected && (
149
+ <span className="rest-item-actions">
150
+ <button className="btn btn-ghost btn-sm btn-icon" disabled={busy} onClick={() => void rename()} aria-label={t('rest.rename')} title={t('rest.rename')}>
151
+ <Icon name="edit" />
152
+ </button>
153
+ <button className="btn btn-ghost btn-sm btn-icon" disabled={busy} onClick={() => void remove()} aria-label={t('rest.delete')} title={t('rest.delete')}>
154
+ <Icon name="trash" />
155
+ </button>
156
+ </span>
157
+ )}
158
+ </div>
159
+ ))}
160
+ </aside>
161
+ {env && (
162
+ <div className="rest-env-vars">
163
+ <table className="rest-env-table">
164
+ <thead>
165
+ <tr>
166
+ <th>{t('rest.env.var.enabled')}</th>
167
+ <th>{t('rest.env.var.name')}</th>
168
+ <th>{t('rest.env.var.value')}</th>
169
+ <th>{t('rest.env.var.secret')}</th>
170
+ <th />
171
+ </tr>
172
+ </thead>
173
+ <tbody>
174
+ {rows.map((r, i) => (
175
+ <tr key={i}>
176
+ <td>
177
+ <input type="checkbox" checked={r.enabled} aria-label={t('rest.env.var.enabled')} onChange={(e) => patch(i, { enabled: e.target.checked })} />
178
+ </td>
179
+ <td>
180
+ <input value={r.name} spellCheck={false} aria-label={t('rest.env.var.name')} onChange={(e) => patch(i, { name: e.target.value })} />
181
+ </td>
182
+ <td>
183
+ {r.secret && !r.replace ? (
184
+ <span className="row">
185
+ <span className="muted">{r.set ? t('rest.env.var.set') : t('rest.env.var.notSet')}</span>
186
+ <button className="btn btn-ghost btn-sm" onClick={() => patch(i, { replace: true, value: '' })}>{t('rest.env.var.replace')}</button>
187
+ </span>
188
+ ) : (
189
+ <input
190
+ type={r.secret ? 'password' : 'text'}
191
+ value={r.value}
192
+ spellCheck={false}
193
+ autoComplete="off"
194
+ aria-label={t('rest.env.var.value')}
195
+ onChange={(e) => patch(i, { value: e.target.value })}
196
+ />
197
+ )}
198
+ </td>
199
+ <td>
200
+ <input
201
+ type="checkbox"
202
+ checked={r.secret}
203
+ aria-label={t('rest.env.var.secret')}
204
+ // A stored secret turned plain is not revealed: its value starts empty.
205
+ onChange={(e) => patch(i, e.target.checked ? { secret: true, replace: true } : { secret: false, replace: false, value: r.secret && !r.replace ? '' : r.value })}
206
+ />
207
+ </td>
208
+ <td>
209
+ <button className="btn btn-ghost btn-sm btn-icon" onClick={() => setRows(rows.filter((_, j) => j !== i))} aria-label={t('rest.env.var.remove')} title={t('rest.env.var.remove')}>
210
+ <Icon name="trash" />
211
+ </button>
212
+ </td>
213
+ </tr>
214
+ ))}
215
+ </tbody>
216
+ </table>
217
+ <div>
218
+ <button className="btn btn-sm" onClick={() => setRows([...rows, blank()])}><Icon name="plus" /> {t('rest.env.var.add')}</button>
219
+ </div>
220
+ <p className="muted">{t('rest.env.hint')}</p>
221
+ </div>
222
+ )}
223
+ </div>
224
+ {element}
225
+ </Modal>
226
+ );
227
+ }
@@ -0,0 +1,71 @@
1
+ import { useEffect, useState } from 'react';
2
+ import type { RestHistoryEntry } from '@deckbuilderkit/server-core/protocol';
3
+ import { type ApiError, ErrorBanner, errorOf, Icon, useAsync, type useDialogs } from '@deckbuilderkit/ui';
4
+ import { restApi as api } from '../client';
5
+ import { t } from '../i18n';
6
+ import { openRestTab } from './tabs-store';
7
+
8
+ const timeOf = (at: number) => new Date(at).toLocaleString();
9
+
10
+ /** Sent REST requests, newest first: search by method and URL, click to open the draft in a new tab, clear with a confirm. */
11
+ export function HistoryPanel({ refresh, confirm, onClose }: { refresh: number; confirm: ReturnType<typeof useDialogs>['confirm']; onClose: () => void }) {
12
+ const [query, setQuery] = useState('');
13
+ const [debounced, setDebounced] = useState('');
14
+ const [actionError, setActionError] = useState<ApiError>();
15
+ useEffect(() => {
16
+ const timer = setTimeout(() => setDebounced(query.trim()), 200);
17
+ return () => clearTimeout(timer);
18
+ }, [query]);
19
+ const history = useAsync(() => api.restHistory(debounced), [debounced, refresh]);
20
+
21
+ const open = (entry: RestHistoryEntry) => {
22
+ setActionError(undefined);
23
+ if (openRestTab(entry.draft) === null) setActionError({ message: t('rest.tab.full') } as ApiError);
24
+ };
25
+
26
+ const clear = async () => {
27
+ if (!(await confirm({ title: t('rest.history.clearTitle'), okLabel: t('rest.history.clear'), danger: true }))) return;
28
+ setActionError(undefined);
29
+ try {
30
+ await api.clearRestHistory();
31
+ } catch (err) {
32
+ setActionError(errorOf(err));
33
+ } finally {
34
+ history.reload();
35
+ }
36
+ };
37
+
38
+ return (
39
+ <aside className="rest-history">
40
+ <div className="rest-history-head">
41
+ <span className="mono-label">{t('rest.history.title')}</span>
42
+ <span className="spacer" />
43
+ <button className="btn btn-sm" onClick={() => void clear()} disabled={!history.data?.length}>{t('rest.history.clear')}</button>
44
+ <button className="btn btn-ghost btn-sm btn-icon" onClick={onClose} title={t('rest.history.close')} aria-label={t('rest.history.close')}>
45
+ <Icon name="close" />
46
+ </button>
47
+ </div>
48
+ <input
49
+ className="rest-history-search"
50
+ type="search"
51
+ value={query}
52
+ placeholder={t('rest.history.search')}
53
+ aria-label={t('rest.history.search')}
54
+ onChange={(e) => setQuery(e.target.value)}
55
+ />
56
+ {actionError && <ErrorBanner error={actionError} compact />}
57
+ {history.error && <ErrorBanner error={history.error} onRetry={history.reload} compact />}
58
+ {history.data?.length === 0 && <p className="muted">{t('rest.history.empty')}</p>}
59
+ <div className="rest-history-list">
60
+ {history.data?.map((entry) => (
61
+ <button key={entry.id} className="rest-history-item" onClick={() => open(entry)} title={entry.url}>
62
+ <span className="rest-method">{entry.method}</span>
63
+ <span className="rest-history-url">{entry.url}</span>
64
+ <span className={`rest-history-status${entry.error || entry.status >= 400 ? ' err' : ''}`}>{entry.error ?? entry.status}</span>
65
+ <span className="rest-history-meta">{entry.ms} ms · {timeOf(entry.at)}</span>
66
+ </button>
67
+ ))}
68
+ </div>
69
+ </aside>
70
+ );
71
+ }
@@ -0,0 +1,138 @@
1
+ import { useState } from 'react';
2
+ import { type ImportedRequest, parseImport, warningText } from '@deckbuilderkit/editor';
3
+ import { type ApiError, ErrorBanner, errorOf, Modal } from '@deckbuilderkit/ui';
4
+ import { restApi as api } from '../client';
5
+ import { t } from '../i18n';
6
+
7
+ export const requestLabel = (r: ImportedRequest) => r.name ?? `${r.draft.method} ${r.draft.url}`;
8
+
9
+ interface Props {
10
+ /** One parsed request goes into the editor. */
11
+ onLoad: (request: ImportedRequest) => void;
12
+ /** The chosen requests of a file with several are saved; rejects with the reason to show. */
13
+ onSaveMany: (requests: ImportedRequest[]) => Promise<void>;
14
+ onClose: () => void;
15
+ }
16
+
17
+ export function ImportDialog({ onLoad, onSaveMany, onClose }: Props) {
18
+ const [text, setText] = useState('');
19
+ const [parsed, setParsed] = useState<ImportedRequest[]>();
20
+ const [checked, setChecked] = useState<boolean[]>([]);
21
+ const [message, setMessage] = useState<string>();
22
+ const [error, setError] = useState<ApiError>();
23
+ const [busy, setBusy] = useState(false);
24
+
25
+ const parse = (source: string) => {
26
+ setError(undefined);
27
+ setMessage(undefined);
28
+ let requests: ImportedRequest[];
29
+ try {
30
+ requests = parseImport(source);
31
+ } catch {
32
+ requests = [];
33
+ }
34
+ if (requests.length === 0) {
35
+ setParsed(undefined);
36
+ setMessage(t('rest.import.nothing'));
37
+ return;
38
+ }
39
+ if (requests.length === 1) {
40
+ onLoad(requests[0]);
41
+ if (requests[0].warnings.length === 0) {
42
+ onClose();
43
+ return;
44
+ }
45
+ }
46
+ setParsed(requests);
47
+ setChecked(requests.map(() => true));
48
+ };
49
+
50
+ const openFile = async () => {
51
+ setError(undefined);
52
+ try {
53
+ const { text: content } = await api.importRequestsFile();
54
+ if (content === null) return;
55
+ setText(content);
56
+ parse(content);
57
+ } catch (err) {
58
+ setError(errorOf(err));
59
+ }
60
+ };
61
+
62
+ const saveSelected = async () => {
63
+ if (!parsed) return;
64
+ setBusy(true);
65
+ setError(undefined);
66
+ try {
67
+ await onSaveMany(parsed.filter((_, i) => checked[i]));
68
+ onClose();
69
+ } catch (err) {
70
+ setError(errorOf(err));
71
+ } finally {
72
+ setBusy(false);
73
+ }
74
+ };
75
+
76
+ const several = parsed !== undefined && parsed.length > 1;
77
+ const loadedOne = parsed !== undefined && parsed.length === 1;
78
+ const footer = several ? (
79
+ <>
80
+ <button className="btn" onClick={onClose}>{t('dialog.cancel')}</button>
81
+ <button className="btn btn-primary" disabled={busy || !checked.some(Boolean)} onClick={saveSelected}>
82
+ {t('rest.import.saveSelected')}
83
+ </button>
84
+ </>
85
+ ) : loadedOne ? (
86
+ <button className="btn btn-primary" onClick={onClose}>{t('rest.import.close')}</button>
87
+ ) : (
88
+ <>
89
+ <button className="btn" onClick={onClose}>{t('dialog.cancel')}</button>
90
+ <button className="btn" onClick={openFile}>{t('rest.import.openFile')}</button>
91
+ <button className="btn btn-primary" disabled={text.trim() === ''} onClick={() => parse(text)}>{t('rest.import.parse')}</button>
92
+ </>
93
+ );
94
+ const warnings = parsed?.flatMap((r) => r.warnings.map((w) => (several ? `${requestLabel(r)}: ${warningText(w)}` : warningText(w)))) ?? [];
95
+
96
+ return (
97
+ <Modal title={t('rest.import.title')} onClose={onClose} footer={footer}>
98
+ {parsed === undefined && (
99
+ <label className="field">
100
+ <span className="mono-label">{t('rest.import.paste')}</span>
101
+ <textarea
102
+ value={text}
103
+ onChange={(e) => setText(e.target.value)}
104
+ spellCheck={false}
105
+ autoFocus
106
+ aria-label={t('rest.import.paste')}
107
+ />
108
+ </label>
109
+ )}
110
+ {message && <p className="muted">{message}</p>}
111
+ {loadedOne && <p>{t('rest.import.loaded', { name: requestLabel(parsed[0]) })}</p>}
112
+ {several && (
113
+ <div className="field">
114
+ <span className="mono-label">{t('rest.import.found', { count: parsed.length })}</span>
115
+ {parsed.map((r, i) => (
116
+ <label key={i} className="row">
117
+ <input
118
+ type="checkbox"
119
+ checked={checked[i] ?? false}
120
+ onChange={(e) => setChecked(checked.map((c, j) => (j === i ? e.target.checked : c)))}
121
+ />
122
+ <span>{requestLabel(r)}</span>
123
+ </label>
124
+ ))}
125
+ </div>
126
+ )}
127
+ {warnings.length > 0 && (
128
+ <div className="field">
129
+ <span className="mono-label">{t('rest.import.warnings')}</span>
130
+ {warnings.map((w, i) => (
131
+ <span key={i} className="muted">{w}</span>
132
+ ))}
133
+ </div>
134
+ )}
135
+ {error && <ErrorBanner error={error} compact />}
136
+ </Modal>
137
+ );
138
+ }
Binary file