@jupyterlab/settingeditor 4.0.0-alpha.19 → 4.0.0-alpha.20

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,538 @@
1
+ /* -----------------------------------------------------------------------------
2
+ | Copyright (c) Jupyter Development Team.
3
+ | Distributed under the terms of the Modified BSD License.
4
+ |----------------------------------------------------------------------------*/
5
+
6
+ import { ReactWidget } from '@jupyterlab/apputils';
7
+ import { ISettingRegistry, Settings } from '@jupyterlab/settingregistry';
8
+ import { ITranslator, nullTranslator } from '@jupyterlab/translation';
9
+ import {
10
+ classes,
11
+ FilterBox,
12
+ IScore,
13
+ LabIcon,
14
+ settingsIcon,
15
+ updateFilterFunction
16
+ } from '@jupyterlab/ui-components';
17
+ import { StringExt } from '@lumino/algorithm';
18
+ import { PartialJSONObject } from '@lumino/coreutils';
19
+ import { Message } from '@lumino/messaging';
20
+ import { ISignal, Signal } from '@lumino/signaling';
21
+ import React from 'react';
22
+
23
+ /**
24
+ * The JupyterLab plugin schema key for the setting editor
25
+ * icon class of a plugin.
26
+ */
27
+ const ICON_KEY = 'jupyter.lab.setting-icon';
28
+
29
+ /**
30
+ * The JupyterLab plugin schema key for the setting editor
31
+ * icon class of a plugin.
32
+ */
33
+ const ICON_CLASS_KEY = 'jupyter.lab.setting-icon-class';
34
+
35
+ /**
36
+ * The JupyterLab plugin schema key for the setting editor
37
+ * icon label of a plugin.
38
+ */
39
+ const ICON_LABEL_KEY = 'jupyter.lab.setting-icon-label';
40
+
41
+ /**
42
+ * A list of plugins with editable settings.
43
+ */
44
+ export class PluginList extends ReactWidget {
45
+ /**
46
+ * Create a new plugin list.
47
+ */
48
+ constructor(options: PluginList.IOptions) {
49
+ super();
50
+ this.registry = options.registry;
51
+ this.translator = options.translator || nullTranslator;
52
+ this.addClass('jp-PluginList');
53
+ this._confirm = options.confirm;
54
+ this.registry.pluginChanged.connect(() => {
55
+ this.update();
56
+ }, this);
57
+ this.mapPlugins = this.mapPlugins.bind(this);
58
+ this.setFilter = this.setFilter.bind(this);
59
+ this.setFilter(updateFilterFunction(options.query ?? '', false, false));
60
+ this.setError = this.setError.bind(this);
61
+ this._evtMousedown = this._evtMousedown.bind(this);
62
+ this._query = options.query;
63
+
64
+ this._allPlugins = PluginList.sortPlugins(this.registry).filter(plugin => {
65
+ const { schema } = plugin;
66
+ const deprecated = schema['jupyter.lab.setting-deprecated'] === true;
67
+ const editable = Object.keys(schema.properties || {}).length > 0;
68
+ const extensible = schema.additionalProperties !== false;
69
+ // Filters out a couple of plugins that take too long to load in the new settings editor.
70
+ const correctEditor =
71
+ // If this is the json settings editor, anything is fine
72
+ this._confirm ||
73
+ // If this is the new settings editor, remove context menu / main menu settings.
74
+ (!this._confirm && !(options.toSkip ?? []).includes(plugin.id));
75
+
76
+ return !deprecated && correctEditor && (editable || extensible);
77
+ });
78
+
79
+ /**
80
+ * Loads all settings and stores them for easy access when displaying search results.
81
+ */
82
+ const loadSettings = async () => {
83
+ for (const plugin of this._allPlugins) {
84
+ const pluginSettings = (await this.registry.load(
85
+ plugin.id
86
+ )) as Settings;
87
+ this._settings[plugin.id] = pluginSettings;
88
+ }
89
+ this.update();
90
+ };
91
+ void loadSettings();
92
+
93
+ this._errors = {};
94
+ this.selection = this._allPlugins[0].id;
95
+ }
96
+
97
+ /**
98
+ * The setting registry.
99
+ */
100
+ readonly registry: ISettingRegistry;
101
+
102
+ /**
103
+ * A signal emitted when a list user interaction happens.
104
+ */
105
+ get changed(): ISignal<this, void> {
106
+ return this._changed;
107
+ }
108
+
109
+ /**
110
+ * The selection value of the plugin list.
111
+ */
112
+ get scrollTop(): number | undefined {
113
+ return this.node.querySelector('ul')?.scrollTop;
114
+ }
115
+
116
+ get hasErrors(): boolean {
117
+ for (const id in this._errors) {
118
+ if (this._errors[id]) {
119
+ return true;
120
+ }
121
+ }
122
+ return false;
123
+ }
124
+
125
+ get filter(): (item: ISettingRegistry.IPlugin) => string[] | null {
126
+ return this._filter;
127
+ }
128
+
129
+ /**
130
+ * The selection value of the plugin list.
131
+ */
132
+ get selection(): string {
133
+ return this._selection;
134
+ }
135
+ set selection(selection: string) {
136
+ this._selection = selection;
137
+ this.update();
138
+ }
139
+
140
+ /**
141
+ * Signal that fires when search filter is updated so that settings panel can filter results.
142
+ */
143
+ get updateFilterSignal(): ISignal<
144
+ this,
145
+ (plugin: ISettingRegistry.IPlugin) => string[] | null
146
+ > {
147
+ return this._updateFilterSignal;
148
+ }
149
+
150
+ get handleSelectSignal(): ISignal<this, string> {
151
+ return this._handleSelectSignal;
152
+ }
153
+
154
+ /**
155
+ * Handle `'update-request'` messages.
156
+ */
157
+ protected onUpdateRequest(msg: Message): void {
158
+ const ul = this.node.querySelector('ul');
159
+ if (ul && this._scrollTop !== undefined) {
160
+ ul.scrollTop = this._scrollTop;
161
+ }
162
+ super.onUpdateRequest(msg);
163
+ }
164
+
165
+ /**
166
+ * Handle the `'mousedown'` event for the plugin list.
167
+ *
168
+ * @param event - The DOM event sent to the widget
169
+ */
170
+ private _evtMousedown(event: React.MouseEvent<HTMLDivElement>): void {
171
+ const target = event.currentTarget;
172
+ const id = target.getAttribute('data-id');
173
+
174
+ if (!id) {
175
+ return;
176
+ }
177
+
178
+ if (this._confirm) {
179
+ this._confirm(id)
180
+ .then(() => {
181
+ this.selection = id!;
182
+ this._changed.emit(undefined);
183
+ this.update();
184
+ })
185
+ .catch(() => {
186
+ /* no op */
187
+ });
188
+ } else {
189
+ this._scrollTop = this.scrollTop;
190
+ this._selection = id!;
191
+ this._handleSelectSignal.emit(id!);
192
+ this._changed.emit(undefined);
193
+ this.update();
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Check the plugin for a rendering hint's value.
199
+ *
200
+ * #### Notes
201
+ * The order of priority for overridden hints is as follows, from most
202
+ * important to least:
203
+ * 1. Data set by the end user in a settings file.
204
+ * 2. Data set by the plugin author as a schema default.
205
+ * 3. Data set by the plugin author as a top-level key of the schema.
206
+ */
207
+ getHint(
208
+ key: string,
209
+ registry: ISettingRegistry,
210
+ plugin: ISettingRegistry.IPlugin
211
+ ): string {
212
+ // First, give priority to checking if the hint exists in the user data.
213
+ let hint = plugin.data.user[key];
214
+
215
+ // Second, check to see if the hint exists in composite data, which folds
216
+ // in default values from the schema.
217
+ if (!hint) {
218
+ hint = plugin.data.composite[key];
219
+ }
220
+
221
+ // Third, check to see if the plugin schema has defined the hint.
222
+ if (!hint) {
223
+ hint = plugin.schema[key];
224
+ }
225
+
226
+ // Finally, use the defaults from the registry schema.
227
+ if (!hint) {
228
+ const { properties } = registry.schema;
229
+
230
+ hint = properties && properties[key] && properties[key].default;
231
+ }
232
+
233
+ return typeof hint === 'string' ? hint : '';
234
+ }
235
+
236
+ /**
237
+ * Function to recursively filter properties that match search results.
238
+ * @param filter - Function to filter based on search results
239
+ * @param props - Schema properties being filtered
240
+ * @param definitions - Definitions to use for filling in references in properties
241
+ * @param ref - Reference to a definition
242
+ * @returns - String array of properties that match the search results.
243
+ */
244
+ getFilterString(
245
+ filter: (item: string) => Partial<IScore> | null,
246
+ props: ISettingRegistry.IProperty,
247
+ definitions?: any,
248
+ ref?: string
249
+ ): string[] {
250
+ // If properties given are references, populate properties
251
+ // with corresponding definition.
252
+ if (ref && definitions) {
253
+ ref = ref.replace('#/definitions/', '');
254
+ props = definitions[ref] ?? {};
255
+ }
256
+
257
+ // If given properties are an object, advance into the properties
258
+ // for that object instead.
259
+ if (props.properties) {
260
+ props = props.properties;
261
+ // If given properties are an array, advance into the properties
262
+ // for the items instead.
263
+ } else if (props.items) {
264
+ props = props.items as any;
265
+ // Otherwise, you've reached the base case and don't need to check for matching properties
266
+ } else {
267
+ return [];
268
+ }
269
+
270
+ // If reference found, recurse
271
+ if (props['$ref']) {
272
+ return this.getFilterString(
273
+ filter,
274
+ props,
275
+ definitions,
276
+ props['$ref'] as string
277
+ );
278
+ }
279
+
280
+ // Make sure props is non-empty before calling reduce
281
+ if (Object.keys(props).length === 0) {
282
+ return [];
283
+ }
284
+
285
+ // Iterate through the properties and check for titles / descriptions that match search.
286
+ return Object.keys(props).reduce((acc: string[], value: any) => {
287
+ // If this is the base case, check for matching title / description
288
+ const subProps = props[value] as PartialJSONObject;
289
+ if (!subProps) {
290
+ if (filter((props.title as string) ?? '')) {
291
+ return props.title;
292
+ }
293
+ if (filter(value)) {
294
+ return value;
295
+ }
296
+ }
297
+
298
+ // If there are properties in the object, check for title / description
299
+ if (filter((subProps.title as string) ?? '')) {
300
+ acc.push(subProps.title as string);
301
+ }
302
+ if (filter(value)) {
303
+ acc.push(value);
304
+ }
305
+
306
+ // Finally, recurse on the properties left.
307
+ acc.concat(
308
+ this.getFilterString(
309
+ filter,
310
+ subProps as ISettingRegistry.IProperty,
311
+ definitions,
312
+ subProps['$ref'] as string
313
+ )
314
+ );
315
+ return acc;
316
+ }, []);
317
+ }
318
+
319
+ /**
320
+ * Updates the filter when the search bar value changes.
321
+ * @param filter Filter function passed by search bar based on search value.
322
+ */
323
+ setFilter(
324
+ filter: (item: string) => Partial<IScore> | null,
325
+ query?: string
326
+ ): void {
327
+ this._filter = (plugin: ISettingRegistry.IPlugin): string[] | null => {
328
+ if (filter(plugin.schema.title ?? '')) {
329
+ return null;
330
+ }
331
+ const filtered = this.getFilterString(
332
+ filter,
333
+ plugin.schema ?? {},
334
+ plugin.schema.definitions
335
+ );
336
+ return filtered;
337
+ };
338
+ this._query = query;
339
+ this._updateFilterSignal.emit(this._filter);
340
+ this.update();
341
+ }
342
+
343
+ setError(id: string, error: boolean): void {
344
+ if (this._errors[id] !== error) {
345
+ this._errors[id] = error;
346
+ this.update();
347
+ } else {
348
+ this._errors[id] = error;
349
+ }
350
+ }
351
+
352
+ mapPlugins(plugin: ISettingRegistry.IPlugin): JSX.Element {
353
+ const { id, schema, version } = plugin;
354
+ const trans = this.translator.load('jupyterlab');
355
+ const title =
356
+ typeof schema.title === 'string' ? trans._p('schema', schema.title) : id;
357
+ const highlightedTitleIndices = StringExt.matchSumOfSquares(
358
+ title.toLocaleLowerCase(),
359
+ this._query?.toLocaleLowerCase() ?? ''
360
+ );
361
+ const hightlightedTitle = StringExt.highlight(
362
+ title,
363
+ highlightedTitleIndices?.indices ?? [],
364
+ chunk => {
365
+ return <mark>{chunk}</mark>;
366
+ }
367
+ );
368
+ const description =
369
+ typeof schema.description === 'string'
370
+ ? trans._p('schema', schema.description)
371
+ : '';
372
+ const itemTitle = `${description}\n${id}\n${version}`;
373
+ const icon = this.getHint(ICON_KEY, this.registry, plugin);
374
+ const iconClass = this.getHint(ICON_CLASS_KEY, this.registry, plugin);
375
+ const iconTitle = this.getHint(ICON_LABEL_KEY, this.registry, plugin);
376
+ const filteredProperties = this._filter(plugin)?.map(fieldValue => {
377
+ const highlightedIndices = StringExt.matchSumOfSquares(
378
+ fieldValue.toLocaleLowerCase(),
379
+ this._query?.toLocaleLowerCase() ?? ''
380
+ );
381
+ const highlighted = StringExt.highlight(
382
+ fieldValue,
383
+ highlightedIndices?.indices ?? [],
384
+ chunk => {
385
+ return <mark>{chunk}</mark>;
386
+ }
387
+ );
388
+ return <li key={`${id}-${fieldValue}`}> {highlighted} </li>;
389
+ });
390
+
391
+ return (
392
+ <div
393
+ onClick={this._evtMousedown}
394
+ className={`${
395
+ id === this.selection
396
+ ? 'jp-mod-selected jp-PluginList-entry'
397
+ : 'jp-PluginList-entry'
398
+ } ${this._errors[id] ? 'jp-ErrorPlugin' : ''}`}
399
+ data-id={id}
400
+ key={id}
401
+ title={itemTitle}
402
+ >
403
+ <div className="jp-PluginList-entry-label" role="tab">
404
+ <div className="jp-SelectedIndicator" />
405
+ <LabIcon.resolveReact
406
+ icon={icon || (iconClass ? undefined : settingsIcon)}
407
+ iconClass={classes(iconClass, 'jp-Icon')}
408
+ title={iconTitle}
409
+ tag="span"
410
+ stylesheet="settingsEditor"
411
+ />
412
+ <span className="jp-PluginList-entry-label-text">
413
+ {hightlightedTitle}
414
+ </span>
415
+ </div>
416
+ <ul>{filteredProperties}</ul>
417
+ </div>
418
+ );
419
+ }
420
+
421
+ render(): JSX.Element {
422
+ const trans = this.translator.load('jupyterlab');
423
+ // Filter all plugins based on search value before displaying list.
424
+ const allPlugins = this._allPlugins.filter(plugin => {
425
+ const filtered = this._filter(plugin);
426
+ return filtered === null || filtered.length > 0;
427
+ });
428
+
429
+ const modifiedPlugins = allPlugins.filter(plugin => {
430
+ return this._settings[plugin.id]?.isModified;
431
+ });
432
+ const modifiedItems = modifiedPlugins.map(this.mapPlugins);
433
+ const otherItems = allPlugins
434
+ .filter(plugin => {
435
+ return !modifiedPlugins.includes(plugin);
436
+ })
437
+ .map(this.mapPlugins);
438
+
439
+ return (
440
+ <div className="jp-PluginList-wrapper">
441
+ <FilterBox
442
+ updateFilter={this.setFilter}
443
+ useFuzzyFilter={false}
444
+ placeholder={trans.__('Search…')}
445
+ forceRefresh={false}
446
+ caseSensitive={false}
447
+ initialQuery={this._query}
448
+ />
449
+ {modifiedItems.length > 0 && (
450
+ <div>
451
+ <h1 className="jp-PluginList-header">{trans.__('Modified')}</h1>
452
+ <ul>{modifiedItems}</ul>
453
+ </div>
454
+ )}
455
+ {otherItems.length > 0 && (
456
+ <div>
457
+ <h1 className="jp-PluginList-header">{trans.__('Settings')}</h1>
458
+ <ul>{otherItems}</ul>
459
+ </div>
460
+ )}
461
+ {modifiedItems.length === 0 && otherItems.length === 0 && (
462
+ <p className="jp-PluginList-noResults">
463
+ {trans.__('No items match your search.')}
464
+ </p>
465
+ )}
466
+ </div>
467
+ );
468
+ }
469
+
470
+ protected translator: ITranslator;
471
+ private _changed = new Signal<this, void>(this);
472
+ private _errors: { [id: string]: boolean };
473
+ private _filter: (item: ISettingRegistry.IPlugin) => string[] | null;
474
+ private _query: string | undefined;
475
+ private _handleSelectSignal = new Signal<this, string>(this);
476
+ private _updateFilterSignal = new Signal<
477
+ this,
478
+ (plugin: ISettingRegistry.IPlugin) => string[] | null
479
+ >(this);
480
+ private _allPlugins: ISettingRegistry.IPlugin[] = [];
481
+ private _settings: { [id: string]: Settings } = {};
482
+ private _confirm?: (id: string) => Promise<void>;
483
+ private _scrollTop: number | undefined = 0;
484
+ private _selection = '';
485
+ }
486
+
487
+ /**
488
+ * A namespace for `PluginList` statics.
489
+ */
490
+ export namespace PluginList {
491
+ /**
492
+ * The instantiation options for a plugin list.
493
+ */
494
+ export interface IOptions {
495
+ /**
496
+ * A function that allows for asynchronously confirming a selection.
497
+ *
498
+ * #### Notes
499
+ * If the promise returned by the function resolves, then the selection will
500
+ * succeed and emit an event. If the promise rejects, the selection is not
501
+ * made.
502
+ */
503
+ confirm?: (id: string) => Promise<void>;
504
+
505
+ /**
506
+ * The setting registry for the plugin list.
507
+ */
508
+ registry: ISettingRegistry;
509
+
510
+ /**
511
+ * List of plugins to skip
512
+ */
513
+ toSkip?: string[];
514
+
515
+ /**
516
+ * The setting registry for the plugin list.
517
+ */
518
+ translator?: ITranslator;
519
+
520
+ /**
521
+ * An optional initial query so the plugin list can filter on start.
522
+ */
523
+ query?: string;
524
+ }
525
+
526
+ /**
527
+ * Sort a list of plugins by title and ID.
528
+ */
529
+ export function sortPlugins(
530
+ registry: ISettingRegistry
531
+ ): ISettingRegistry.IPlugin[] {
532
+ return Object.keys(registry.plugins)
533
+ .map(plugin => registry.plugins[plugin]!)
534
+ .sort((a, b) => {
535
+ return (a.schema.title || a.id).localeCompare(b.schema.title || b.id);
536
+ });
537
+ }
538
+ }