]> git.proxmox.com Git - proxmox-widget-toolkit.git/blob - src/api-viewer/APIViewer.js
parameter and return grid: allow text selection
[proxmox-widget-toolkit.git] / src / api-viewer / APIViewer.js
1 /*global apiSchema*/
2
3 Ext.onReady(function() {
4 Ext.define('pmx-param-schema', {
5 extend: 'Ext.data.Model',
6 fields: [
7 'name', 'type', 'typetext', 'description', 'verbose_description',
8 'enum', 'minimum', 'maximum', 'minLength', 'maxLength',
9 'pattern', 'title', 'requires', 'format', 'default',
10 'disallow', 'extends', 'links',
11 {
12 name: 'optional',
13 type: 'boolean',
14 },
15 ],
16 });
17
18 let store = Ext.define('pmx-updated-treestore', {
19 extend: 'Ext.data.TreeStore',
20 model: Ext.define('pmx-api-doc', {
21 extend: 'Ext.data.Model',
22 fields: [
23 'path', 'info', 'text',
24 ],
25 }),
26 proxy: {
27 type: 'memory',
28 data: apiSchema,
29 },
30 sorters: [{
31 property: 'leaf',
32 direction: 'ASC',
33 }, {
34 property: 'text',
35 direction: 'ASC',
36 }],
37 filterer: 'bottomup',
38 doFilter: function(node) {
39 this.filterNodes(node, this.getFilters().getFilterFn(), true);
40 },
41
42 filterNodes: function(node, filterFn, parentVisible) {
43 let me = this;
44
45 let match = filterFn(node) && (parentVisible || (node.isRoot() && !me.getRootVisible()));
46
47 if (node.childNodes && node.childNodes.length) {
48 let bottomUpFiltering = me.filterer === 'bottomup';
49 let childMatch;
50 for (const child of node.childNodes) {
51 childMatch = me.filterNodes(child, filterFn, match || bottomUpFiltering) || childMatch;
52 }
53 if (bottomUpFiltering) {
54 match = childMatch || match;
55 }
56 }
57
58 node.set("visible", match, me._silentOptions);
59 return match;
60 },
61
62 }).create();
63
64 let render_description = function(value, metaData, record) {
65 let pdef = record.data;
66
67 value = pdef.verbose_description || value;
68
69 // TODO: try to render asciidoc correctly
70
71 metaData.style = 'white-space:pre-wrap;';
72
73 return Ext.htmlEncode(value);
74 };
75
76 let render_type = function(value, metaData, record) {
77 let pdef = record.data;
78
79 return pdef.enum ? 'enum' : pdef.type || 'string';
80 };
81
82 const renderFormatString = function(obj) {
83 if (!Ext.isObject(obj)) {
84 return obj;
85 }
86 const mandatory = [];
87 const optional = [];
88 Object.entries(obj).forEach(function([name, param]) {
89 let list = param.optional ? optional : mandatory;
90 let str = param.default_key ? `[${name}=]` : `${name}=`;
91 if (param.alias) {
92 return;
93 } else if (param.enum) {
94 str += `(${param.enum?.join(' | ')})`;
95 } else {
96 str += `<${param.format_description || param.pattern || param.type}>`;
97 }
98 list.push(str);
99 });
100 return mandatory.join(", ") + ' ' + optional.map(each => `[,${each}]`).join(' ');
101 };
102
103 let render_simple_format = function(pdef, type_fallback) {
104 if (pdef.typetext) {
105 return pdef.typetext;
106 }
107 if (pdef.enum) {
108 return pdef.enum.join(' | ');
109 }
110 if (pdef.format) {
111 return renderFormatString(pdef.format);
112 }
113 if (pdef.pattern) {
114 return pdef.pattern;
115 }
116 if (pdef.type === 'boolean') {
117 return `<true|false>`;
118 }
119 if (type_fallback && pdef.type) {
120 return `<${pdef.type}>`;
121 }
122 if (pdef.minimum || pdef.maximum) {
123 return `${pdef.minimum || 'N'} - ${pdef.maximum || 'N'}`;
124 }
125 return '';
126 };
127
128 let render_format = function(value, metaData, record) {
129 let pdef = record.data;
130
131 metaData.style = 'white-space:normal;';
132
133 if (pdef.type === 'array' && pdef.items) {
134 let format = render_simple_format(pdef.items, true);
135 return `[${Ext.htmlEncode(format)}, ...]`;
136 }
137
138 return Ext.htmlEncode(render_simple_format(pdef));
139 };
140
141 let real_path = function(path) {
142 if (!path.match(/^[/]/)) {
143 path = `/${path}`;
144 }
145 return path.replace(/^.*\/_upgrade_(\/)?/, "/");
146 };
147
148 let permission_text = function(permission) {
149 let permhtml = "";
150
151 if (permission.user) {
152 if (!permission.description) {
153 if (permission.user === 'world') {
154 permhtml += "Accessible without any authentication.";
155 } else if (permission.user === 'all') {
156 permhtml += "Accessible by all authenticated users.";
157 } else {
158 permhtml += `Only accessible by user "${permission.user}"`;
159 }
160 }
161 } else if (permission.check) {
162 permhtml += `<pre>Check: ${Ext.htmlEncode(JSON.stringify(permission.check))}</pre>`;
163 } else if (permission.userParam) {
164 permhtml += `<div>Check if user matches parameter '${permission.userParam}'`;
165 } else if (permission.or) {
166 permhtml += "<div>Or<div style='padding-left: 10px;'>";
167 permhtml += permission.or.map(v => permission_text(v)).join('');
168 permhtml += "</div></div>";
169 } else if (permission.and) {
170 permhtml += "<div>And<div style='padding-left: 10px;'>";
171 permhtml += permission.and.map(v => permission_text(v)).join('');
172 permhtml += "</div></div>";
173 } else {
174 permhtml += "Unknown syntax!";
175 }
176
177 return permhtml;
178 };
179
180 let render_docu = function(data) {
181 let md = data.info;
182
183 let items = [];
184
185 Ext.Array.each(['GET', 'POST', 'PUT', 'DELETE'], function(method) {
186 let info = md[method];
187 if (info) {
188 let endpoint = real_path(data.path);
189 let usage = `<table><tr><td>HTTP:&nbsp;&nbsp;&nbsp;</td><td>`;
190 usage += `${method} /api2/json${endpoint}</td></tr>`;
191
192 if (typeof cliUsageRenderer === 'function') {
193 usage += cliUsageRenderer(method, endpoint); // eslint-disable-line no-undef
194 }
195
196 let sections = [
197 {
198 title: 'Description',
199 html: Ext.htmlEncode(info.description),
200 bodyPadding: 10,
201 },
202 {
203 title: 'Usage',
204 html: usage,
205 bodyPadding: 10,
206 },
207 ];
208
209 if (info.parameters && info.parameters.properties) {
210 let pstore = Ext.create('Ext.data.Store', {
211 model: 'pmx-param-schema',
212 proxy: {
213 type: 'memory',
214 },
215 groupField: 'optional',
216 sorters: [
217 {
218 property: 'name',
219 direction: 'ASC',
220 },
221 ],
222 });
223
224 Ext.Object.each(info.parameters.properties, function(name, pdef) {
225 pdef.name = name;
226 pstore.add(pdef);
227 });
228
229 pstore.sort();
230
231 let groupingFeature = Ext.create('Ext.grid.feature.Grouping', {
232 enableGroupingMenu: false,
233 groupHeaderTpl: '<tpl if="groupValue">Optional</tpl><tpl if="!groupValue">Required</tpl>',
234 });
235
236 sections.push({
237 xtype: 'gridpanel',
238 title: 'Parameters',
239 features: [groupingFeature],
240 store: pstore,
241 viewConfig: {
242 trackOver: false,
243 stripeRows: true,
244 enableTextSelection: true,
245 },
246 columns: [
247 {
248 header: 'Name',
249 dataIndex: 'name',
250 flex: 1,
251 },
252 {
253 header: 'Type',
254 dataIndex: 'type',
255 renderer: render_type,
256 flex: 1,
257 },
258 {
259 header: 'Default',
260 dataIndex: 'default',
261 flex: 1,
262 },
263 {
264 header: 'Format',
265 dataIndex: 'type',
266 renderer: render_format,
267 flex: 2,
268 },
269 {
270 header: 'Description',
271 dataIndex: 'description',
272 renderer: render_description,
273 flex: 6,
274 },
275 ],
276 });
277 }
278
279 if (info.returns) {
280 let retinf = info.returns;
281 let rtype = retinf.type;
282 if (!rtype && retinf.items) {rtype = 'array';}
283 if (!rtype) {rtype = 'object';}
284
285 let rpstore = Ext.create('Ext.data.Store', {
286 model: 'pmx-param-schema',
287 proxy: {
288 type: 'memory',
289 },
290 groupField: 'optional',
291 sorters: [
292 {
293 property: 'name',
294 direction: 'ASC',
295 },
296 ],
297 });
298
299 let properties;
300 if (rtype === 'array' && retinf.items.properties) {
301 properties = retinf.items.properties;
302 }
303
304 if (rtype === 'object' && retinf.properties) {
305 properties = retinf.properties;
306 }
307
308 Ext.Object.each(properties, function(name, pdef) {
309 pdef.name = name;
310 rpstore.add(pdef);
311 });
312
313 rpstore.sort();
314
315 let groupingFeature = Ext.create('Ext.grid.feature.Grouping', {
316 enableGroupingMenu: false,
317 groupHeaderTpl: '<tpl if="groupValue">Optional</tpl><tpl if="!groupValue">Obligatory</tpl>',
318 });
319 let returnhtml;
320 if (retinf.items) {
321 returnhtml = '<pre>items: ' + Ext.htmlEncode(JSON.stringify(retinf.items, null, 4)) + '</pre>';
322 }
323
324 if (retinf.properties) {
325 returnhtml = returnhtml || '';
326 returnhtml += '<pre>properties:' + Ext.htmlEncode(JSON.stringify(retinf.properties, null, 4)) + '</pre>';
327 }
328
329 let rawSection = Ext.create('Ext.panel.Panel', {
330 bodyPadding: '0px 10px 10px 10px',
331 html: returnhtml,
332 hidden: true,
333 });
334
335 sections.push({
336 xtype: 'gridpanel',
337 title: 'Returns: ' + rtype,
338 features: [groupingFeature],
339 store: rpstore,
340 viewConfig: {
341 trackOver: false,
342 stripeRows: true,
343 enableTextSelection: true,
344 },
345 columns: [
346 {
347 header: 'Name',
348 dataIndex: 'name',
349 flex: 1,
350 },
351 {
352 header: 'Type',
353 dataIndex: 'type',
354 renderer: render_type,
355 flex: 1,
356 },
357 {
358 header: 'Default',
359 dataIndex: 'default',
360 flex: 1,
361 },
362 {
363 header: 'Format',
364 dataIndex: 'type',
365 renderer: render_format,
366 flex: 2,
367 },
368 {
369 header: 'Description',
370 dataIndex: 'description',
371 renderer: render_description,
372 flex: 6,
373 },
374 ],
375 bbar: [
376 {
377 xtype: 'button',
378 text: 'Show RAW',
379 handler: function(btn) {
380 rawSection.setVisible(!rawSection.isVisible());
381 btn.setText(rawSection.isVisible() ? 'Hide RAW' : 'Show RAW');
382 },
383 },
384 ],
385 });
386
387 sections.push(rawSection);
388 }
389
390 if (!data.path.match(/\/_upgrade_/)) {
391 let permhtml = '';
392
393 if (!info.permissions) {
394 permhtml = "Root only.";
395 } else {
396 if (info.permissions.description) {
397 permhtml += "<div style='white-space:pre-wrap;padding-bottom:10px;'>" +
398 Ext.htmlEncode(info.permissions.description) + "</div>";
399 }
400 permhtml += permission_text(info.permissions);
401 }
402
403 if (info.allowtoken !== undefined && !info.allowtoken) {
404 permhtml += "<br />This API endpoint is not available for API tokens.";
405 }
406
407 sections.push({
408 title: 'Required permissions',
409 bodyPadding: 10,
410 html: permhtml,
411 });
412 }
413
414 items.push({
415 title: method,
416 autoScroll: true,
417 defaults: {
418 border: false,
419 },
420 items: sections,
421 });
422 }
423 });
424
425 let ct = Ext.getCmp('docview');
426 ct.setTitle("Path: " + real_path(data.path));
427 ct.removeAll(true);
428 ct.add(items);
429 ct.setActiveTab(0);
430 };
431
432 Ext.define('Ext.form.SearchField', {
433 extend: 'Ext.form.field.Text',
434 alias: 'widget.searchfield',
435
436 emptyText: 'Search...',
437
438 flex: 1,
439
440 inputType: 'search',
441 listeners: {
442 'change': function() {
443 let value = this.getValue();
444 if (!Ext.isEmpty(value)) {
445 store.filter({
446 property: 'path',
447 value: value,
448 anyMatch: true,
449 });
450 } else {
451 store.clearFilter();
452 }
453 },
454 },
455 });
456
457 let treePanel = Ext.create('Ext.tree.Panel', {
458 title: 'Resource Tree',
459 tbar: [
460 {
461 xtype: 'searchfield',
462 },
463 ],
464 tools: [
465 {
466 type: 'expand',
467 tooltip: 'Expand all',
468 tooltipType: 'title',
469 callback: tree => tree.expandAll(),
470 },
471 {
472 type: 'collapse',
473 tooltip: 'Collapse all',
474 tooltipType: 'title',
475 callback: tree => tree.collapseAll(),
476 },
477 ],
478 store: store,
479 width: 200,
480 region: 'west',
481 split: true,
482 margins: '5 0 5 5',
483 rootVisible: false,
484 listeners: {
485 selectionchange: function(v, selections) {
486 if (!selections[0]) {return;}
487 let rec = selections[0];
488 render_docu(rec.data);
489 location.hash = '#' + rec.data.path;
490 },
491 },
492 });
493
494 Ext.create('Ext.container.Viewport', {
495 layout: 'border',
496 renderTo: Ext.getBody(),
497 items: [
498 treePanel,
499 {
500 xtype: 'tabpanel',
501 title: 'Documentation',
502 id: 'docview',
503 region: 'center',
504 margins: '5 5 5 0',
505 layout: 'fit',
506 items: [],
507 },
508 ],
509 });
510
511 let deepLink = function() {
512 let path = window.location.hash.substring(1).replace(/\/\s*$/, '');
513 let endpoint = store.findNode('path', path);
514
515 if (endpoint) {
516 treePanel.getSelectionModel().select(endpoint);
517 treePanel.expandPath(endpoint.getPath());
518 render_docu(endpoint.data);
519 }
520 };
521 window.onhashchange = deepLink;
522
523 deepLink();
524 });