@riducms/plugin-graphql 0.5.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.
- package/LICENSE +21 -0
- package/README.md +26 -0
- package/package.json +52 -0
- package/src/editor.ts +211 -0
- package/src/index.ts +22 -0
- package/src/loader.ts +26 -0
- package/src/messages.ts +115 -0
- package/src/operation.ts +90 -0
- package/src/playground-controller.svelte.ts +140 -0
- package/src/playground-route.svelte +47 -0
- package/src/playground.scss +301 -0
- package/src/playground.svelte +252 -0
- package/src/schema-docs.ts +171 -0
- package/src/schema-explorer.svelte +189 -0
- package/src/starter-query.ts +53 -0
- package/src/vite-env.d.ts +7 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { isRecord } from "@riducms/protocol";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
executeOperation,
|
|
5
|
+
operationNameAt,
|
|
6
|
+
parseVariables,
|
|
7
|
+
type OperationResponse,
|
|
8
|
+
} from "./operation";
|
|
9
|
+
|
|
10
|
+
export type PlaygroundResult =
|
|
11
|
+
| { kind: "empty" }
|
|
12
|
+
| { kind: "running" }
|
|
13
|
+
| { kind: "response"; response: OperationResponse }
|
|
14
|
+
| { kind: "failed"; message: string };
|
|
15
|
+
|
|
16
|
+
interface SavedSession {
|
|
17
|
+
query: string;
|
|
18
|
+
variables: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Owns the playground's documents, its one in-flight request, and the docs explorer's trail.
|
|
23
|
+
* Only the latest run may set the result: a superseded or cancelled request is aborted and ignored.
|
|
24
|
+
*/
|
|
25
|
+
export class PlaygroundController {
|
|
26
|
+
query = "";
|
|
27
|
+
variables = "";
|
|
28
|
+
result: PlaygroundResult = $state.raw({ kind: "empty" });
|
|
29
|
+
variablesInvalid = $state(false);
|
|
30
|
+
/** Types opened in the docs explorer, most recent last; undefined when the explorer is closed. */
|
|
31
|
+
docs: readonly string[] | undefined = $state.raw();
|
|
32
|
+
|
|
33
|
+
readonly #endpoint: string;
|
|
34
|
+
readonly #storageKey: string;
|
|
35
|
+
readonly #fetch: typeof fetch | undefined;
|
|
36
|
+
#request: AbortController | undefined;
|
|
37
|
+
#generation = 0;
|
|
38
|
+
|
|
39
|
+
constructor(endpoint: string, starter: string, fetchOperation?: typeof fetch) {
|
|
40
|
+
this.#endpoint = endpoint;
|
|
41
|
+
this.#storageKey = `ridu:graphql-playground:${endpoint}`;
|
|
42
|
+
this.#fetch = fetchOperation;
|
|
43
|
+
const saved = this.#read();
|
|
44
|
+
this.query = saved?.query ?? starter;
|
|
45
|
+
this.variables = saved?.variables ?? "";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get running() {
|
|
49
|
+
return this.result.kind === "running";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async run(cursor: number) {
|
|
53
|
+
const variables = parseVariables(this.variables);
|
|
54
|
+
if (!variables.ok) {
|
|
55
|
+
this.variablesInvalid = true;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
this.variablesInvalid = false;
|
|
59
|
+
this.#request?.abort();
|
|
60
|
+
const request = new AbortController();
|
|
61
|
+
const generation = ++this.#generation;
|
|
62
|
+
this.#request = request;
|
|
63
|
+
const query = this.query;
|
|
64
|
+
const operationName = operationNameAt(query, cursor);
|
|
65
|
+
this.result = { kind: "running" };
|
|
66
|
+
this.save();
|
|
67
|
+
try {
|
|
68
|
+
const response = await executeOperation(
|
|
69
|
+
this.#endpoint,
|
|
70
|
+
{
|
|
71
|
+
query,
|
|
72
|
+
...(variables.value === undefined ? {} : { variables: variables.value }),
|
|
73
|
+
...(operationName === undefined ? {} : { operationName }),
|
|
74
|
+
},
|
|
75
|
+
request.signal,
|
|
76
|
+
this.#fetch
|
|
77
|
+
);
|
|
78
|
+
if (generation === this.#generation) this.result = { kind: "response", response };
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (generation !== this.#generation) return;
|
|
81
|
+
this.result = {
|
|
82
|
+
kind: "failed",
|
|
83
|
+
message: error instanceof Error ? error.message : String(error),
|
|
84
|
+
};
|
|
85
|
+
} finally {
|
|
86
|
+
if (this.#request === request) this.#request = undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
cancel() {
|
|
91
|
+
if (!this.running) return;
|
|
92
|
+
this.#generation++;
|
|
93
|
+
this.#request?.abort();
|
|
94
|
+
this.result = { kind: "empty" };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
variablesEdited() {
|
|
98
|
+
this.variablesInvalid = false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
openDocs(type?: string) {
|
|
102
|
+
const trail = this.docs ?? [];
|
|
103
|
+
this.docs = type === undefined || trail.at(-1) === type ? trail : [...trail, type];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
docsBack() {
|
|
107
|
+
if (this.docs !== undefined) this.docs = this.docs.slice(0, -1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
closeDocs() {
|
|
111
|
+
this.docs = undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Keeps the documents for this browser, so returning to the page restores the last session. */
|
|
115
|
+
save() {
|
|
116
|
+
try {
|
|
117
|
+
const session: SavedSession = { query: this.query, variables: this.variables };
|
|
118
|
+
localStorage.setItem(this.#storageKey, JSON.stringify(session));
|
|
119
|
+
} catch {
|
|
120
|
+
// Storage can be unavailable (private windows, blocked site data); the session still works.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
dispose() {
|
|
125
|
+
this.save();
|
|
126
|
+
this.#generation++;
|
|
127
|
+
this.#request?.abort();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#read(): SavedSession | undefined {
|
|
131
|
+
try {
|
|
132
|
+
const value: unknown = JSON.parse(localStorage.getItem(this.#storageKey) ?? "null");
|
|
133
|
+
if (isRecord(value) && typeof value.query === "string" && typeof value.variables === "string")
|
|
134
|
+
return { query: value.query, variables: value.variables };
|
|
135
|
+
} catch {
|
|
136
|
+
// Treat unreadable storage as an empty session.
|
|
137
|
+
}
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { AdminExtensionProps, AdminLoaderProps } from "@riducms/plugin";
|
|
3
|
+
import { Button } from "@riducms/ui";
|
|
4
|
+
|
|
5
|
+
import type { PlaygroundData } from "./loader";
|
|
6
|
+
|
|
7
|
+
let props: AdminExtensionProps & AdminLoaderProps<PlaygroundData> = $props();
|
|
8
|
+
|
|
9
|
+
// CodeMirror, the GraphQL language tools, and the explorer load only when the page opens,
|
|
10
|
+
// so admins who never visit it do not download them.
|
|
11
|
+
const playground = import("./playground.svelte");
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
{#await playground}
|
|
15
|
+
<section class="state" aria-busy="true" aria-label={props.i18n.t("plugin.graphql:loading")}>
|
|
16
|
+
<p class="message">{props.i18n.t("plugin.graphql:loading")}</p>
|
|
17
|
+
</section>
|
|
18
|
+
{:then { default: Playground }}
|
|
19
|
+
<Playground {...props} />
|
|
20
|
+
{:catch}
|
|
21
|
+
<section class="state" role="alert">
|
|
22
|
+
<p class="message message--error">{props.i18n.t("plugin.graphql:loadFailed")}</p>
|
|
23
|
+
<Button onclick={() => window.location.reload()}>
|
|
24
|
+
{props.i18n.t("plugin.graphql:reload")}
|
|
25
|
+
</Button>
|
|
26
|
+
</section>
|
|
27
|
+
{/await}
|
|
28
|
+
|
|
29
|
+
<style>
|
|
30
|
+
.state {
|
|
31
|
+
display: grid;
|
|
32
|
+
place-items: center;
|
|
33
|
+
align-content: center;
|
|
34
|
+
gap: 16px;
|
|
35
|
+
min-block-size: 18rem;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.message {
|
|
39
|
+
margin: 0;
|
|
40
|
+
font-size: 12px;
|
|
41
|
+
color: var(--foreground-faint, var(--muted-foreground));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.message--error {
|
|
45
|
+
color: var(--destructive);
|
|
46
|
+
}
|
|
47
|
+
</style>
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
@use "@riducms/ui/styles.scss" as ui;
|
|
2
|
+
|
|
3
|
+
@layer ridu.components {
|
|
4
|
+
.ridu-graphql {
|
|
5
|
+
display: flex;
|
|
6
|
+
flex-direction: column;
|
|
7
|
+
gap: 12px;
|
|
8
|
+
min-block-size: 560px;
|
|
9
|
+
block-size: calc(100dvh - var(--admin-header-height, 56px) - 2 * var(--admin-gutter, 24px));
|
|
10
|
+
color: var(--foreground);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.ridu-graphql__header {
|
|
14
|
+
display: flex;
|
|
15
|
+
flex-wrap: wrap;
|
|
16
|
+
align-items: flex-end;
|
|
17
|
+
justify-content: space-between;
|
|
18
|
+
gap: 12px;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.ridu-graphql__title {
|
|
22
|
+
margin: 0;
|
|
23
|
+
font-size: 1.5rem;
|
|
24
|
+
line-height: 1.2;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.ridu-graphql__description {
|
|
28
|
+
display: flex;
|
|
29
|
+
flex-wrap: wrap;
|
|
30
|
+
gap: 8px;
|
|
31
|
+
margin: 4px 0 0;
|
|
32
|
+
color: var(--muted-foreground);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.ridu-graphql__description code {
|
|
36
|
+
font-family: var(--font-mono);
|
|
37
|
+
color: var(--foreground);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.ridu-graphql__actions {
|
|
41
|
+
display: flex;
|
|
42
|
+
flex-wrap: wrap;
|
|
43
|
+
gap: 8px;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.ridu-graphql__shortcut {
|
|
47
|
+
margin-inline-start: 6px;
|
|
48
|
+
font-family: var(--font-sans);
|
|
49
|
+
font-size: 0.75em;
|
|
50
|
+
opacity: 0.75;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.ridu-graphql__notice {
|
|
54
|
+
margin: 0;
|
|
55
|
+
padding: 8px 12px;
|
|
56
|
+
border: 1px solid var(--status-warning-border);
|
|
57
|
+
border-radius: var(--radius);
|
|
58
|
+
background: var(--status-warning-surface);
|
|
59
|
+
color: var(--status-warning-foreground);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.ridu-graphql__notice--error {
|
|
63
|
+
border-color: var(--status-error-border);
|
|
64
|
+
background: var(--status-error-surface);
|
|
65
|
+
color: var(--status-error-foreground);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.ridu-graphql__workspace {
|
|
69
|
+
display: grid;
|
|
70
|
+
flex: 1;
|
|
71
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
72
|
+
gap: 12px;
|
|
73
|
+
min-block-size: 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.ridu-graphql__workspace[data-docs="open"] {
|
|
77
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(260px, 340px);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.ridu-graphql__request {
|
|
81
|
+
display: grid;
|
|
82
|
+
grid-template-rows: minmax(0, 3fr) minmax(0, 1fr);
|
|
83
|
+
gap: 12px;
|
|
84
|
+
min-block-size: 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.ridu-graphql__pane {
|
|
88
|
+
display: flex;
|
|
89
|
+
flex-direction: column;
|
|
90
|
+
min-block-size: 0;
|
|
91
|
+
overflow: hidden;
|
|
92
|
+
border: 1px solid var(--border);
|
|
93
|
+
border-radius: var(--radius);
|
|
94
|
+
background: var(--background-surface, var(--card));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.ridu-graphql__pane-heading {
|
|
98
|
+
display: flex;
|
|
99
|
+
align-items: center;
|
|
100
|
+
justify-content: space-between;
|
|
101
|
+
gap: 8px;
|
|
102
|
+
border-block-end: 1px solid var(--border);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.ridu-graphql__pane-title {
|
|
106
|
+
margin: 0;
|
|
107
|
+
padding: 8px 12px;
|
|
108
|
+
font-size: 0.8125rem;
|
|
109
|
+
font-weight: 600;
|
|
110
|
+
color: var(--muted-foreground);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.ridu-graphql__pane > .ridu-graphql__pane-title {
|
|
114
|
+
border-block-end: 1px solid var(--border);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.ridu-graphql__status {
|
|
118
|
+
padding-inline-end: 12px;
|
|
119
|
+
font-family: var(--font-mono);
|
|
120
|
+
font-size: 0.75rem;
|
|
121
|
+
color: var(--muted-foreground);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.ridu-graphql__editor {
|
|
125
|
+
flex: 1;
|
|
126
|
+
min-block-size: 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.ridu-graphql__editor[hidden] {
|
|
130
|
+
display: none;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.ridu-graphql__empty {
|
|
134
|
+
margin: 0;
|
|
135
|
+
padding: 16px 12px;
|
|
136
|
+
color: var(--muted-foreground);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.ridu-graphql__pane > .ridu-graphql__notice {
|
|
140
|
+
margin: 12px;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.ridu-graphql__announcement {
|
|
144
|
+
@include ui.visually-hidden;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.ridu-graphql-docs {
|
|
148
|
+
display: flex;
|
|
149
|
+
flex-direction: column;
|
|
150
|
+
gap: 8px;
|
|
151
|
+
min-block-size: 0;
|
|
152
|
+
padding: 8px 12px 12px;
|
|
153
|
+
border: 1px solid var(--border);
|
|
154
|
+
border-radius: var(--radius);
|
|
155
|
+
background: var(--background-surface, var(--card));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.ridu-graphql-docs__header {
|
|
159
|
+
display: flex;
|
|
160
|
+
align-items: center;
|
|
161
|
+
gap: 4px;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.ridu-graphql-docs__title {
|
|
165
|
+
flex: 1;
|
|
166
|
+
min-inline-size: 0;
|
|
167
|
+
margin: 0;
|
|
168
|
+
overflow: hidden;
|
|
169
|
+
font-size: 1rem;
|
|
170
|
+
text-overflow: ellipsis;
|
|
171
|
+
white-space: nowrap;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.ridu-graphql-docs__title:focus-visible {
|
|
175
|
+
outline: var(--focus-outline);
|
|
176
|
+
outline-offset: var(--focus-outline-offset);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.ridu-graphql-docs__body {
|
|
180
|
+
flex: 1;
|
|
181
|
+
min-block-size: 0;
|
|
182
|
+
overflow: auto;
|
|
183
|
+
font-size: 0.8125rem;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.ridu-graphql-docs__section {
|
|
187
|
+
margin: 12px 0 6px;
|
|
188
|
+
font-size: 0.75rem;
|
|
189
|
+
font-weight: 600;
|
|
190
|
+
letter-spacing: 0.04em;
|
|
191
|
+
text-transform: uppercase;
|
|
192
|
+
color: var(--muted-foreground);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.ridu-graphql-docs__list,
|
|
196
|
+
.ridu-graphql-docs__fields,
|
|
197
|
+
.ridu-graphql-docs__arguments ul {
|
|
198
|
+
display: grid;
|
|
199
|
+
gap: 8px;
|
|
200
|
+
margin: 0;
|
|
201
|
+
padding: 0;
|
|
202
|
+
list-style: none;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.ridu-graphql-docs__signature,
|
|
206
|
+
.ridu-graphql-docs__list li,
|
|
207
|
+
.ridu-graphql-docs__arguments li {
|
|
208
|
+
font-family: var(--font-mono);
|
|
209
|
+
overflow-wrap: anywhere;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.ridu-graphql-docs__name {
|
|
213
|
+
color: var(--code-property);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.ridu-graphql-docs__keyword {
|
|
217
|
+
color: var(--code-keyword);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.ridu-graphql-docs__type {
|
|
221
|
+
padding: 0;
|
|
222
|
+
border: 0;
|
|
223
|
+
background: none;
|
|
224
|
+
font: inherit;
|
|
225
|
+
color: var(--code-type);
|
|
226
|
+
cursor: pointer;
|
|
227
|
+
text-align: start;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.ridu-graphql-docs__type:hover {
|
|
231
|
+
text-decoration: underline;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.ridu-graphql-docs__type:focus-visible {
|
|
235
|
+
outline: var(--focus-outline);
|
|
236
|
+
outline-offset: var(--focus-outline-offset);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.ridu-graphql-docs__kind,
|
|
240
|
+
.ridu-graphql-docs__muted {
|
|
241
|
+
margin: 0;
|
|
242
|
+
color: var(--muted-foreground);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.ridu-graphql-docs__description {
|
|
246
|
+
margin: 2px 0 0;
|
|
247
|
+
font-family: var(--font-sans);
|
|
248
|
+
color: var(--muted-foreground);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.ridu-graphql-docs__warning {
|
|
252
|
+
margin: 2px 0 0;
|
|
253
|
+
color: var(--status-warning-foreground);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.ridu-graphql-docs__arguments {
|
|
257
|
+
margin-block-start: 4px;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.ridu-graphql-docs__arguments summary {
|
|
261
|
+
font-family: var(--font-sans);
|
|
262
|
+
color: var(--muted-foreground);
|
|
263
|
+
cursor: pointer;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.ridu-graphql-docs__arguments ul {
|
|
267
|
+
margin-block-start: 6px;
|
|
268
|
+
padding-inline-start: 12px;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
@media (max-width: 960px) {
|
|
272
|
+
.ridu-graphql {
|
|
273
|
+
block-size: auto;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.ridu-graphql__workspace,
|
|
277
|
+
.ridu-graphql__workspace[data-docs="open"] {
|
|
278
|
+
grid-template-columns: minmax(0, 1fr);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.ridu-graphql__request {
|
|
282
|
+
grid-template-rows: none;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.ridu-graphql__pane--query {
|
|
286
|
+
block-size: 360px;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.ridu-graphql__pane--variables {
|
|
290
|
+
block-size: 160px;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.ridu-graphql__pane--response {
|
|
294
|
+
min-block-size: 320px;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.ridu-graphql-docs {
|
|
298
|
+
max-block-size: 480px;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|