@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,252 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import "./playground.scss";
|
|
3
|
+
|
|
4
|
+
import type { EditorView } from "@codemirror/view";
|
|
5
|
+
import type { AdminExtensionProps, AdminLoaderProps } from "@riducms/plugin";
|
|
6
|
+
import { Button } from "@riducms/ui";
|
|
7
|
+
import { buildSchema, type GraphQLSchema } from "graphql";
|
|
8
|
+
import { untrack } from "svelte";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
configureEditor,
|
|
12
|
+
createJSONEditor,
|
|
13
|
+
createQueryEditor,
|
|
14
|
+
replaceDocument,
|
|
15
|
+
setQuerySchema,
|
|
16
|
+
} from "./editor";
|
|
17
|
+
import type { PlaygroundData } from "./loader";
|
|
18
|
+
import { PlaygroundController } from "./playground-controller.svelte";
|
|
19
|
+
import SchemaExplorer from "./schema-explorer.svelte";
|
|
20
|
+
import { starterQuery } from "./starter-query";
|
|
21
|
+
|
|
22
|
+
let { data, i18n }: AdminExtensionProps & AdminLoaderProps<PlaygroundData> = $props();
|
|
23
|
+
|
|
24
|
+
const schema: GraphQLSchema | undefined = $derived.by(() => {
|
|
25
|
+
try {
|
|
26
|
+
return buildSchema(data.schema);
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// The endpoint is fixed for the application's lifetime, so the controller keeps its first value.
|
|
33
|
+
// svelte-ignore state_referenced_locally
|
|
34
|
+
const controller = new PlaygroundController(data.endpoint, starterQuery(schema));
|
|
35
|
+
$effect(() => () => controller.dispose());
|
|
36
|
+
|
|
37
|
+
let queryView: EditorView | undefined;
|
|
38
|
+
let variablesView: EditorView | undefined;
|
|
39
|
+
let responseView: EditorView | undefined;
|
|
40
|
+
|
|
41
|
+
const status = $derived.by(() => {
|
|
42
|
+
const result = controller.result;
|
|
43
|
+
if (result.kind === "running") return i18n.t("plugin.graphql:running");
|
|
44
|
+
if (result.kind === "response")
|
|
45
|
+
return i18n.t("plugin.graphql:status", {
|
|
46
|
+
status: result.response.status,
|
|
47
|
+
duration: result.response.durationMs,
|
|
48
|
+
});
|
|
49
|
+
return "";
|
|
50
|
+
});
|
|
51
|
+
const announcement = $derived.by(() => {
|
|
52
|
+
const result = controller.result;
|
|
53
|
+
if (result.kind === "response")
|
|
54
|
+
return i18n.t("plugin.graphql:responseReady", { status: result.response.status });
|
|
55
|
+
if (result.kind === "failed")
|
|
56
|
+
return i18n.t("plugin.graphql:requestFailed", { message: result.message });
|
|
57
|
+
return "";
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
function responseText() {
|
|
61
|
+
const result = controller.result;
|
|
62
|
+
return result.kind === "response" ? result.response.body : "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Each attachment creates its editor once; later changes flow through the effects below.
|
|
66
|
+
function mountQuery(node: HTMLElement) {
|
|
67
|
+
const view = createQueryEditor(node, {
|
|
68
|
+
doc: controller.query,
|
|
69
|
+
schema: untrack(() => schema),
|
|
70
|
+
label: untrack(() => i18n.t("plugin.graphql:query")),
|
|
71
|
+
onChange: (value) => {
|
|
72
|
+
controller.query = value;
|
|
73
|
+
},
|
|
74
|
+
onRun: run,
|
|
75
|
+
onShowInDocs: (type) => controller.openDocs(type),
|
|
76
|
+
});
|
|
77
|
+
queryView = view;
|
|
78
|
+
return () => {
|
|
79
|
+
view.destroy();
|
|
80
|
+
queryView = undefined;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function mountVariables(node: HTMLElement) {
|
|
85
|
+
const view = createJSONEditor(node, {
|
|
86
|
+
doc: controller.variables,
|
|
87
|
+
readOnly: false,
|
|
88
|
+
label: untrack(() => i18n.t("plugin.graphql:variables")),
|
|
89
|
+
placeholder: untrack(() => i18n.t("plugin.graphql:variablesPlaceholder")),
|
|
90
|
+
onChange: (value) => {
|
|
91
|
+
controller.variables = value;
|
|
92
|
+
controller.variablesEdited();
|
|
93
|
+
},
|
|
94
|
+
onRun: run,
|
|
95
|
+
});
|
|
96
|
+
variablesView = view;
|
|
97
|
+
return () => {
|
|
98
|
+
view.destroy();
|
|
99
|
+
variablesView = undefined;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function mountResponse(node: HTMLElement) {
|
|
104
|
+
const view = createJSONEditor(node, {
|
|
105
|
+
doc: untrack(responseText),
|
|
106
|
+
readOnly: true,
|
|
107
|
+
label: untrack(() => i18n.t("plugin.graphql:response")),
|
|
108
|
+
onRun: run,
|
|
109
|
+
});
|
|
110
|
+
responseView = view;
|
|
111
|
+
return () => {
|
|
112
|
+
view.destroy();
|
|
113
|
+
responseView = undefined;
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The response and schema belong to different editors and change independently, so each external
|
|
118
|
+
// synchronization boundary remains explicit rather than coupling their update schedules.
|
|
119
|
+
$effect(() => {
|
|
120
|
+
const text = responseText();
|
|
121
|
+
if (responseView) replaceDocument(responseView, text);
|
|
122
|
+
});
|
|
123
|
+
$effect(() => {
|
|
124
|
+
const current = schema;
|
|
125
|
+
if (queryView) setQuerySchema(queryView, current);
|
|
126
|
+
});
|
|
127
|
+
$effect(() => {
|
|
128
|
+
const query = i18n.t("plugin.graphql:query");
|
|
129
|
+
const variables = i18n.t("plugin.graphql:variables");
|
|
130
|
+
const variablesPlaceholder = i18n.t("plugin.graphql:variablesPlaceholder");
|
|
131
|
+
const response = i18n.t("plugin.graphql:response");
|
|
132
|
+
if (queryView) configureEditor(queryView, { label: query });
|
|
133
|
+
if (variablesView)
|
|
134
|
+
configureEditor(variablesView, { label: variables, placeholder: variablesPlaceholder });
|
|
135
|
+
if (responseView) configureEditor(responseView, { label: response });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
function run() {
|
|
139
|
+
// The operation under the cursor runs when the document holds several.
|
|
140
|
+
return controller.run(queryView?.state.selection.main.head ?? 0);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function toggleDocs() {
|
|
144
|
+
if (controller.docs === undefined) controller.openDocs();
|
|
145
|
+
else controller.closeDocs();
|
|
146
|
+
}
|
|
147
|
+
</script>
|
|
148
|
+
|
|
149
|
+
<section class="ridu-graphql" aria-labelledby="ridu-graphql-title">
|
|
150
|
+
<header class="ridu-graphql__header">
|
|
151
|
+
<div class="ridu-graphql__intro">
|
|
152
|
+
<h1 id="ridu-graphql-title" class="ridu-graphql__title">{i18n.t("plugin.graphql:title")}</h1>
|
|
153
|
+
<p class="ridu-graphql__description">
|
|
154
|
+
<code dir="ltr">{data.endpoint}</code>
|
|
155
|
+
<span>{i18n.t("plugin.graphql:description")}</span>
|
|
156
|
+
</p>
|
|
157
|
+
</div>
|
|
158
|
+
<div class="ridu-graphql__actions">
|
|
159
|
+
{#if schema}
|
|
160
|
+
<Button
|
|
161
|
+
variant="outline"
|
|
162
|
+
aria-expanded={controller.docs !== undefined}
|
|
163
|
+
aria-controls="ridu-graphql-docs"
|
|
164
|
+
onclick={toggleDocs}
|
|
165
|
+
>
|
|
166
|
+
{i18n.t("plugin.graphql:docs")}
|
|
167
|
+
</Button>
|
|
168
|
+
{/if}
|
|
169
|
+
{#if controller.running}
|
|
170
|
+
<Button variant="secondary" onclick={() => controller.cancel()}>
|
|
171
|
+
{i18n.t("plugin.graphql:cancel")}
|
|
172
|
+
</Button>
|
|
173
|
+
{/if}
|
|
174
|
+
<Button onclick={run} disabled={controller.running}>
|
|
175
|
+
{i18n.t("plugin.graphql:run")}
|
|
176
|
+
<kbd class="ridu-graphql__shortcut">{i18n.t("plugin.graphql:runShortcut")}</kbd>
|
|
177
|
+
</Button>
|
|
178
|
+
</div>
|
|
179
|
+
</header>
|
|
180
|
+
|
|
181
|
+
{#if schema === undefined}
|
|
182
|
+
<p class="ridu-graphql__notice" role="status">{i18n.t("plugin.graphql:schemaUnavailable")}</p>
|
|
183
|
+
{/if}
|
|
184
|
+
{#if controller.variablesInvalid}
|
|
185
|
+
<p class="ridu-graphql__notice ridu-graphql__notice--error" role="alert">
|
|
186
|
+
{i18n.t("plugin.graphql:invalidVariables")}
|
|
187
|
+
</p>
|
|
188
|
+
{/if}
|
|
189
|
+
|
|
190
|
+
<div
|
|
191
|
+
class="ridu-graphql__workspace"
|
|
192
|
+
data-docs={controller.docs === undefined ? "closed" : "open"}
|
|
193
|
+
>
|
|
194
|
+
<div class="ridu-graphql__request">
|
|
195
|
+
<section
|
|
196
|
+
class="ridu-graphql__pane ridu-graphql__pane--query"
|
|
197
|
+
aria-labelledby="ridu-graphql-query"
|
|
198
|
+
>
|
|
199
|
+
<h2 id="ridu-graphql-query" class="ridu-graphql__pane-title">
|
|
200
|
+
{i18n.t("plugin.graphql:query")}
|
|
201
|
+
</h2>
|
|
202
|
+
<div class="ridu-graphql__editor" dir="ltr" {@attach mountQuery}></div>
|
|
203
|
+
</section>
|
|
204
|
+
<section
|
|
205
|
+
class="ridu-graphql__pane ridu-graphql__pane--variables"
|
|
206
|
+
aria-labelledby="ridu-graphql-variables"
|
|
207
|
+
>
|
|
208
|
+
<h2 id="ridu-graphql-variables" class="ridu-graphql__pane-title">
|
|
209
|
+
{i18n.t("plugin.graphql:variables")}
|
|
210
|
+
</h2>
|
|
211
|
+
<div class="ridu-graphql__editor" dir="ltr" {@attach mountVariables}></div>
|
|
212
|
+
</section>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<section
|
|
216
|
+
class="ridu-graphql__pane ridu-graphql__pane--response"
|
|
217
|
+
aria-labelledby="ridu-graphql-response"
|
|
218
|
+
aria-busy={controller.running}
|
|
219
|
+
>
|
|
220
|
+
<div class="ridu-graphql__pane-heading">
|
|
221
|
+
<h2 id="ridu-graphql-response" class="ridu-graphql__pane-title">
|
|
222
|
+
{i18n.t("plugin.graphql:response")}
|
|
223
|
+
</h2>
|
|
224
|
+
{#if status}<span class="ridu-graphql__status">{status}</span>{/if}
|
|
225
|
+
</div>
|
|
226
|
+
{#if controller.result.kind === "failed"}
|
|
227
|
+
<p class="ridu-graphql__notice ridu-graphql__notice--error">{announcement}</p>
|
|
228
|
+
{:else if controller.result.kind === "empty"}
|
|
229
|
+
<p class="ridu-graphql__empty">{i18n.t("plugin.graphql:noResponse")}</p>
|
|
230
|
+
{/if}
|
|
231
|
+
<div
|
|
232
|
+
class="ridu-graphql__editor"
|
|
233
|
+
dir="ltr"
|
|
234
|
+
hidden={controller.result.kind !== "response"}
|
|
235
|
+
{@attach mountResponse}
|
|
236
|
+
></div>
|
|
237
|
+
<p class="ridu-graphql__announcement" aria-live="polite">{announcement}</p>
|
|
238
|
+
</section>
|
|
239
|
+
|
|
240
|
+
{#if schema && controller.docs !== undefined}
|
|
241
|
+
<SchemaExplorer
|
|
242
|
+
id="ridu-graphql-docs"
|
|
243
|
+
{schema}
|
|
244
|
+
trail={controller.docs}
|
|
245
|
+
{i18n}
|
|
246
|
+
onOpen={(type) => controller.openDocs(type)}
|
|
247
|
+
onBack={() => controller.docsBack()}
|
|
248
|
+
onClose={() => controller.closeDocs()}
|
|
249
|
+
/>
|
|
250
|
+
{/if}
|
|
251
|
+
</div>
|
|
252
|
+
</section>
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isEnumType,
|
|
3
|
+
isInputObjectType,
|
|
4
|
+
isInterfaceType,
|
|
5
|
+
isListType,
|
|
6
|
+
isNonNullType,
|
|
7
|
+
isObjectType,
|
|
8
|
+
isUnionType,
|
|
9
|
+
astFromValue,
|
|
10
|
+
print,
|
|
11
|
+
type GraphQLArgument,
|
|
12
|
+
type GraphQLField,
|
|
13
|
+
type GraphQLInputField,
|
|
14
|
+
type GraphQLSchema,
|
|
15
|
+
type GraphQLType,
|
|
16
|
+
} from "graphql";
|
|
17
|
+
|
|
18
|
+
/** A type as written in SDL, such as `[Post!]!`, with the named type it links to. */
|
|
19
|
+
export interface TypeReference {
|
|
20
|
+
label: string;
|
|
21
|
+
name: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DocArgument {
|
|
25
|
+
name: string;
|
|
26
|
+
type: TypeReference;
|
|
27
|
+
description?: string;
|
|
28
|
+
defaultValue?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DocField {
|
|
32
|
+
name: string;
|
|
33
|
+
type: TypeReference;
|
|
34
|
+
description?: string;
|
|
35
|
+
deprecation?: string;
|
|
36
|
+
args: DocArgument[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type DocTypeKind = "object" | "interface" | "input" | "enum" | "union" | "scalar";
|
|
40
|
+
|
|
41
|
+
export interface DocType {
|
|
42
|
+
name: string;
|
|
43
|
+
kind: DocTypeKind;
|
|
44
|
+
description?: string;
|
|
45
|
+
fields: DocField[];
|
|
46
|
+
values: { name: string; description?: string }[];
|
|
47
|
+
members: TypeReference[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface DocRoot {
|
|
51
|
+
operation: "query" | "mutation" | "subscription";
|
|
52
|
+
type: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The entry points shown when the explorer opens. */
|
|
56
|
+
export function schemaRoots(schema: GraphQLSchema): DocRoot[] {
|
|
57
|
+
const roots: DocRoot[] = [];
|
|
58
|
+
const query = schema.getQueryType();
|
|
59
|
+
const mutation = schema.getMutationType();
|
|
60
|
+
const subscription = schema.getSubscriptionType();
|
|
61
|
+
if (query) roots.push({ operation: "query", type: query.name });
|
|
62
|
+
if (mutation) roots.push({ operation: "mutation", type: mutation.name });
|
|
63
|
+
if (subscription) roots.push({ operation: "subscription", type: subscription.name });
|
|
64
|
+
return roots;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function describeType(schema: GraphQLSchema, name: string): DocType | undefined {
|
|
68
|
+
const type = schema.getType(name);
|
|
69
|
+
if (type === undefined || type === null) return undefined;
|
|
70
|
+
const description = type.description ?? undefined;
|
|
71
|
+
const base = { name: type.name, ...(description ? { description } : {}) };
|
|
72
|
+
if (isObjectType(type) || isInterfaceType(type))
|
|
73
|
+
return {
|
|
74
|
+
...base,
|
|
75
|
+
kind: isObjectType(type) ? "object" : "interface",
|
|
76
|
+
fields: Object.values(type.getFields()).map(describeField),
|
|
77
|
+
values: [],
|
|
78
|
+
members: [],
|
|
79
|
+
};
|
|
80
|
+
if (isInputObjectType(type))
|
|
81
|
+
return {
|
|
82
|
+
...base,
|
|
83
|
+
kind: "input",
|
|
84
|
+
fields: Object.values(type.getFields()).map(describeInputField),
|
|
85
|
+
values: [],
|
|
86
|
+
members: [],
|
|
87
|
+
};
|
|
88
|
+
if (isEnumType(type))
|
|
89
|
+
return {
|
|
90
|
+
...base,
|
|
91
|
+
kind: "enum",
|
|
92
|
+
fields: [],
|
|
93
|
+
values: type.getValues().map((value) => ({
|
|
94
|
+
name: value.name,
|
|
95
|
+
...(value.description ? { description: value.description } : {}),
|
|
96
|
+
})),
|
|
97
|
+
members: [],
|
|
98
|
+
};
|
|
99
|
+
if (isUnionType(type))
|
|
100
|
+
return {
|
|
101
|
+
...base,
|
|
102
|
+
kind: "union",
|
|
103
|
+
fields: [],
|
|
104
|
+
values: [],
|
|
105
|
+
members: type.getTypes().map((member) => ({ label: member.name, name: member.name })),
|
|
106
|
+
};
|
|
107
|
+
return { ...base, kind: "scalar", fields: [], values: [], members: [] };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Types and fields whose name contains the search text, for the explorer's search box. */
|
|
111
|
+
export function searchSchema(schema: GraphQLSchema, text: string, limit = 50) {
|
|
112
|
+
const needle = text.trim().toLowerCase();
|
|
113
|
+
if (needle === "") return [];
|
|
114
|
+
const matches: { type: string; field?: string }[] = [];
|
|
115
|
+
for (const type of Object.values(schema.getTypeMap())) {
|
|
116
|
+
if (type.name.startsWith("__")) continue;
|
|
117
|
+
if (type.name.toLowerCase().includes(needle)) matches.push({ type: type.name });
|
|
118
|
+
if (isObjectType(type) || isInterfaceType(type) || isInputObjectType(type))
|
|
119
|
+
for (const field of Object.values(type.getFields()))
|
|
120
|
+
if (field.name.toLowerCase().includes(needle))
|
|
121
|
+
matches.push({ type: type.name, field: field.name });
|
|
122
|
+
if (matches.length >= limit) break;
|
|
123
|
+
}
|
|
124
|
+
return matches.slice(0, limit);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function typeReference(type: GraphQLType): TypeReference {
|
|
128
|
+
if (isNonNullType(type)) {
|
|
129
|
+
const inner = typeReference(type.ofType);
|
|
130
|
+
return { label: `${inner.label}!`, name: inner.name };
|
|
131
|
+
}
|
|
132
|
+
if (isListType(type)) {
|
|
133
|
+
const inner = typeReference(type.ofType);
|
|
134
|
+
return { label: `[${inner.label}]`, name: inner.name };
|
|
135
|
+
}
|
|
136
|
+
return { label: type.name, name: type.name };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function describeField(field: GraphQLField<unknown, unknown>): DocField {
|
|
140
|
+
return {
|
|
141
|
+
name: field.name,
|
|
142
|
+
type: typeReference(field.type),
|
|
143
|
+
...(field.description ? { description: field.description } : {}),
|
|
144
|
+
...(field.deprecationReason ? { deprecation: field.deprecationReason } : {}),
|
|
145
|
+
args: field.args.map(describeArgument),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function describeInputField(field: GraphQLInputField): DocField {
|
|
150
|
+
return {
|
|
151
|
+
name: field.name,
|
|
152
|
+
type: typeReference(field.type),
|
|
153
|
+
...(field.description ? { description: field.description } : {}),
|
|
154
|
+
args: [],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function describeArgument(argument: GraphQLArgument): DocArgument {
|
|
159
|
+
const defaultValue =
|
|
160
|
+
argument.defaultValue === undefined
|
|
161
|
+
? undefined
|
|
162
|
+
: astFromValue(argument.defaultValue, argument.type);
|
|
163
|
+
return {
|
|
164
|
+
name: argument.name,
|
|
165
|
+
type: typeReference(argument.type),
|
|
166
|
+
...(argument.description ? { description: argument.description } : {}),
|
|
167
|
+
...(defaultValue === undefined || defaultValue === null
|
|
168
|
+
? {}
|
|
169
|
+
: { defaultValue: print(defaultValue) }),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { AdminI18n } from "@riducms/plugin";
|
|
3
|
+
import { Button, Input } from "@riducms/ui";
|
|
4
|
+
import type { GraphQLSchema } from "graphql";
|
|
5
|
+
import { tick } from "svelte";
|
|
6
|
+
|
|
7
|
+
import { describeType, schemaRoots, searchSchema, type TypeReference } from "./schema-docs";
|
|
8
|
+
|
|
9
|
+
let {
|
|
10
|
+
id,
|
|
11
|
+
schema,
|
|
12
|
+
trail,
|
|
13
|
+
i18n,
|
|
14
|
+
onOpen,
|
|
15
|
+
onBack,
|
|
16
|
+
onClose,
|
|
17
|
+
}: {
|
|
18
|
+
id: string;
|
|
19
|
+
schema: GraphQLSchema;
|
|
20
|
+
/** Types opened so far; the last one is shown. Empty shows the root types. */
|
|
21
|
+
trail: readonly string[];
|
|
22
|
+
i18n: AdminI18n;
|
|
23
|
+
onOpen: (type: string) => void;
|
|
24
|
+
onBack: () => void;
|
|
25
|
+
onClose: () => void;
|
|
26
|
+
} = $props();
|
|
27
|
+
|
|
28
|
+
let search = $state("");
|
|
29
|
+
let heading: HTMLHeadingElement | undefined;
|
|
30
|
+
|
|
31
|
+
const current = $derived(trail.at(-1));
|
|
32
|
+
const type = $derived(current === undefined ? undefined : describeType(schema, current));
|
|
33
|
+
const matches = $derived(searchSchema(schema, search));
|
|
34
|
+
|
|
35
|
+
// Navigating inside the explorer moves focus to the new heading so keyboard users land on the
|
|
36
|
+
// content they chose. Opening docs from the editor leaves focus in the editor.
|
|
37
|
+
async function open(name: string) {
|
|
38
|
+
search = "";
|
|
39
|
+
onOpen(name);
|
|
40
|
+
await tick();
|
|
41
|
+
heading?.focus();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function back() {
|
|
45
|
+
onBack();
|
|
46
|
+
await tick();
|
|
47
|
+
heading?.focus();
|
|
48
|
+
}
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
{#snippet reference(target: TypeReference)}
|
|
52
|
+
<button type="button" class="ridu-graphql-docs__type" onclick={() => open(target.name)}>
|
|
53
|
+
{target.label}
|
|
54
|
+
</button>
|
|
55
|
+
{/snippet}
|
|
56
|
+
|
|
57
|
+
<aside {id} class="ridu-graphql-docs" aria-label={i18n.t("plugin.graphql:docs")}>
|
|
58
|
+
<header class="ridu-graphql-docs__header">
|
|
59
|
+
{#if trail.length > 0}
|
|
60
|
+
<Button variant="ghost" size="sm" onclick={back}>← {i18n.t("plugin.graphql:back")}</Button>
|
|
61
|
+
{/if}
|
|
62
|
+
<h2 class="ridu-graphql-docs__title" tabindex="-1" bind:this={heading}>
|
|
63
|
+
{current ?? i18n.t("plugin.graphql:docs")}
|
|
64
|
+
</h2>
|
|
65
|
+
<Button variant="ghost" size="sm" onclick={onClose}>
|
|
66
|
+
{i18n.t("plugin.graphql:closeDocs")}
|
|
67
|
+
</Button>
|
|
68
|
+
</header>
|
|
69
|
+
|
|
70
|
+
<Input
|
|
71
|
+
type="search"
|
|
72
|
+
class="ridu-graphql-docs__search"
|
|
73
|
+
placeholder={i18n.t("plugin.graphql:searchSchema")}
|
|
74
|
+
aria-label={i18n.t("plugin.graphql:searchSchema")}
|
|
75
|
+
bind:value={search}
|
|
76
|
+
/>
|
|
77
|
+
|
|
78
|
+
<div class="ridu-graphql-docs__body">
|
|
79
|
+
{#if search.trim() !== ""}
|
|
80
|
+
{#if matches.length === 0}
|
|
81
|
+
<p class="ridu-graphql-docs__muted">{i18n.t("plugin.graphql:noMatches")}</p>
|
|
82
|
+
{:else}
|
|
83
|
+
<ul class="ridu-graphql-docs__list">
|
|
84
|
+
{#each matches as match (`${match.type}.${match.field ?? ""}`)}
|
|
85
|
+
<li>
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
class="ridu-graphql-docs__type"
|
|
89
|
+
onclick={() => open(match.type)}
|
|
90
|
+
>
|
|
91
|
+
{match.field === undefined ? match.type : `${match.type}.${match.field}`}
|
|
92
|
+
</button>
|
|
93
|
+
</li>
|
|
94
|
+
{/each}
|
|
95
|
+
</ul>
|
|
96
|
+
{/if}
|
|
97
|
+
{:else if current === undefined}
|
|
98
|
+
<h3 class="ridu-graphql-docs__section">{i18n.t("plugin.graphql:rootTypes")}</h3>
|
|
99
|
+
<ul class="ridu-graphql-docs__list">
|
|
100
|
+
{#each schemaRoots(schema) as root (root.operation)}
|
|
101
|
+
<li>
|
|
102
|
+
<span class="ridu-graphql-docs__keyword">{root.operation}</span>
|
|
103
|
+
:
|
|
104
|
+
{@render reference({ label: root.type, name: root.type })}
|
|
105
|
+
</li>
|
|
106
|
+
{/each}
|
|
107
|
+
</ul>
|
|
108
|
+
{:else if type === undefined}
|
|
109
|
+
<p class="ridu-graphql-docs__muted">
|
|
110
|
+
{i18n.t("plugin.graphql:typeNotFound", { type: current })}
|
|
111
|
+
</p>
|
|
112
|
+
{:else}
|
|
113
|
+
<p class="ridu-graphql-docs__kind">{type.kind}</p>
|
|
114
|
+
{#if type.description}
|
|
115
|
+
<p class="ridu-graphql-docs__description">{type.description}</p>
|
|
116
|
+
{/if}
|
|
117
|
+
|
|
118
|
+
{#if type.fields.length > 0}
|
|
119
|
+
<h3 class="ridu-graphql-docs__section">{i18n.t("plugin.graphql:fields")}</h3>
|
|
120
|
+
<ul class="ridu-graphql-docs__fields">
|
|
121
|
+
{#each type.fields as field (field.name)}
|
|
122
|
+
<li class="ridu-graphql-docs__field">
|
|
123
|
+
<div class="ridu-graphql-docs__signature" dir="ltr">
|
|
124
|
+
<span class="ridu-graphql-docs__name">{field.name}</span>
|
|
125
|
+
:
|
|
126
|
+
{@render reference(field.type)}
|
|
127
|
+
</div>
|
|
128
|
+
{#if field.description}
|
|
129
|
+
<p class="ridu-graphql-docs__description">{field.description}</p>
|
|
130
|
+
{/if}
|
|
131
|
+
{#if field.deprecation}
|
|
132
|
+
<p class="ridu-graphql-docs__warning">
|
|
133
|
+
{i18n.t("plugin.graphql:deprecated", { reason: field.deprecation })}
|
|
134
|
+
</p>
|
|
135
|
+
{/if}
|
|
136
|
+
{#if field.args.length > 0}
|
|
137
|
+
<details class="ridu-graphql-docs__arguments">
|
|
138
|
+
<summary>{i18n.t("plugin.graphql:arguments")} ({field.args.length})</summary>
|
|
139
|
+
<ul>
|
|
140
|
+
{#each field.args as argument (argument.name)}
|
|
141
|
+
<li>
|
|
142
|
+
<span class="ridu-graphql-docs__name">{argument.name}</span>
|
|
143
|
+
:
|
|
144
|
+
{@render reference(argument.type)}
|
|
145
|
+
{#if argument.defaultValue !== undefined}
|
|
146
|
+
<span class="ridu-graphql-docs__muted">
|
|
147
|
+
{i18n.t("plugin.graphql:defaultValue", {
|
|
148
|
+
value: argument.defaultValue,
|
|
149
|
+
})}
|
|
150
|
+
</span>
|
|
151
|
+
{/if}
|
|
152
|
+
{#if argument.description}
|
|
153
|
+
<p class="ridu-graphql-docs__description">{argument.description}</p>
|
|
154
|
+
{/if}
|
|
155
|
+
</li>
|
|
156
|
+
{/each}
|
|
157
|
+
</ul>
|
|
158
|
+
</details>
|
|
159
|
+
{/if}
|
|
160
|
+
</li>
|
|
161
|
+
{/each}
|
|
162
|
+
</ul>
|
|
163
|
+
{/if}
|
|
164
|
+
|
|
165
|
+
{#if type.values.length > 0}
|
|
166
|
+
<h3 class="ridu-graphql-docs__section">{i18n.t("plugin.graphql:values")}</h3>
|
|
167
|
+
<ul class="ridu-graphql-docs__list">
|
|
168
|
+
{#each type.values as value (value.name)}
|
|
169
|
+
<li>
|
|
170
|
+
<span class="ridu-graphql-docs__name">{value.name}</span>
|
|
171
|
+
{#if value.description}
|
|
172
|
+
<p class="ridu-graphql-docs__description">{value.description}</p>
|
|
173
|
+
{/if}
|
|
174
|
+
</li>
|
|
175
|
+
{/each}
|
|
176
|
+
</ul>
|
|
177
|
+
{/if}
|
|
178
|
+
|
|
179
|
+
{#if type.members.length > 0}
|
|
180
|
+
<h3 class="ridu-graphql-docs__section">{i18n.t("plugin.graphql:members")}</h3>
|
|
181
|
+
<ul class="ridu-graphql-docs__list">
|
|
182
|
+
{#each type.members as member (member.name)}
|
|
183
|
+
<li>{@render reference(member)}</li>
|
|
184
|
+
{/each}
|
|
185
|
+
</ul>
|
|
186
|
+
{/if}
|
|
187
|
+
{/if}
|
|
188
|
+
</div>
|
|
189
|
+
</aside>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getNamedType,
|
|
3
|
+
isLeafType,
|
|
4
|
+
isListType,
|
|
5
|
+
isNonNullType,
|
|
6
|
+
isObjectType,
|
|
7
|
+
type GraphQLField,
|
|
8
|
+
type GraphQLObjectType,
|
|
9
|
+
type GraphQLSchema,
|
|
10
|
+
} from "graphql";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A first query that runs against this application's schema: the first collection list with a few
|
|
14
|
+
* of its scalar fields. It falls back to `__typename` when the schema has no list query.
|
|
15
|
+
*/
|
|
16
|
+
export function starterQuery(schema: GraphQLSchema | undefined) {
|
|
17
|
+
const header =
|
|
18
|
+
"# Press Ctrl+Enter (⌘+Enter on a Mac) to run.\n" + "# Press Ctrl+Space for suggestions.\n";
|
|
19
|
+
const list = schema === undefined ? undefined : firstListQuery(schema);
|
|
20
|
+
if (list === undefined) return `${header}query {\n __typename\n}\n`;
|
|
21
|
+
const fields = scalarFields(list.document).slice(0, 3);
|
|
22
|
+
const selection = fields.map((name) => ` ${name}\n`).join("");
|
|
23
|
+
return `${header}query {\n ${list.field.name}(limit: 10) {\n docs {\n${selection} }\n totalDocs\n }\n}\n`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function firstListQuery(schema: GraphQLSchema) {
|
|
27
|
+
const query = schema.getQueryType();
|
|
28
|
+
if (query === null || query === undefined) return undefined;
|
|
29
|
+
for (const field of Object.values(query.getFields())) {
|
|
30
|
+
const page = getNamedType(field.type);
|
|
31
|
+
if (!isObjectType(page) || field.args.some((argument) => isNonNullType(argument.type)))
|
|
32
|
+
continue;
|
|
33
|
+
const fields = page.getFields();
|
|
34
|
+
const docs = fields.docs;
|
|
35
|
+
if (docs === undefined || fields.totalDocs === undefined) continue;
|
|
36
|
+
const document = getNamedType(docs.type);
|
|
37
|
+
if (isObjectType(document) && isListType(unwrapNonNull(docs.type))) return { field, document };
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function scalarFields(type: GraphQLObjectType) {
|
|
43
|
+
return Object.values(type.getFields())
|
|
44
|
+
.filter(
|
|
45
|
+
(field: GraphQLField<unknown, unknown>) =>
|
|
46
|
+
field.args.length === 0 && isLeafType(unwrapNonNull(field.type))
|
|
47
|
+
)
|
|
48
|
+
.map((field) => field.name);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function unwrapNonNull<T>(type: T) {
|
|
52
|
+
return isNonNullType(type) ? type.ofType : type;
|
|
53
|
+
}
|