Skip to content

Commit b7e4767

Browse files
authored
Merge pull request #3 from fcollonval/fix/12470-settings-editor-focus-lost
Better handling of derived props computation
2 parents 67d1d21 + 5396ce4 commit b7e4767

File tree

1 file changed

+139
-66
lines changed

1 file changed

+139
-66
lines changed

packages/settingeditor/src/SettingsFormEditor.tsx

Lines changed: 139 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import { showErrorMessage } from '@jupyterlab/apputils';
77
import { caretDownIcon, caretRightIcon } from '@jupyterlab/ui-components';
8-
import { Settings } from '@jupyterlab/settingregistry';
8+
import { ISettingRegistry, Settings } from '@jupyterlab/settingregistry';
99
import { ITranslator } from '@jupyterlab/translation';
1010
import { reduce } from '@lumino/algorithm';
1111
import { JSONExt, ReadonlyPartialJSONObject } from '@lumino/coreutils';
@@ -88,13 +88,40 @@ export namespace SettingsFormEditor {
8888
/**
8989
* The current form values being displayed in the editor.
9090
*/
91-
formData: any;
91+
// formData: any;
9292

9393
/**
9494
* Indicates whether the settings have been modified. Used for hiding
9595
* the "Restore to Default" button when there are no changes.
9696
*/
9797
isModified: boolean;
98+
99+
// The following are state derived from props for memoization to avoid
100+
// `Form` update that results in focus lost
101+
// A better fix (that will break the API) would be to move this as props
102+
// of the component
103+
/**
104+
* Form UI schema
105+
*/
106+
uiSchema: UiSchema;
107+
/**
108+
* Filtered schema
109+
*/
110+
filteredSchema: ISettingRegistry.ISchema;
111+
/**
112+
* Array Field template
113+
*/
114+
arrayFieldTemplate?: React.StatelessComponent<ArrayFieldTemplateProps<any>>;
115+
/**
116+
* Object Field template
117+
*/
118+
objectFieldTemplate?: React.StatelessComponent<
119+
ObjectFieldTemplateProps<any>
120+
>;
121+
/**
122+
* Form context
123+
*/
124+
formContext?: any;
98125
}
99126
}
100127

@@ -332,18 +359,43 @@ export class SettingsFormEditor extends React.Component<
332359
SettingsFormEditor.IProps,
333360
SettingsFormEditor.IState
334361
> {
335-
private formState: any;
336362
constructor(props: SettingsFormEditor.IProps) {
337363
super(props);
338364
const { settings } = props;
339-
this.formState = {
340-
formData: settings.composite,
341-
isModified: settings.isModified
365+
this._formData = settings.composite;
366+
this.state = {
367+
isModified: settings.isModified,
368+
uiSchema: {},
369+
filteredSchema: this.props.settings.schema,
370+
arrayFieldTemplate: CustomArrayTemplateFactory(this.props.translator),
371+
objectFieldTemplate: CustomObjectTemplateFactory(this.props.translator),
372+
formContext: { settings: this.props.settings }
342373
};
343374
this.handleChange = this.handleChange.bind(this);
344375
this._debouncer = new Debouncer(this.handleChange);
345376
}
346377

378+
componentDidMount(): void {
379+
this._setUiSchema();
380+
this._setFilteredSchema();
381+
}
382+
383+
componentDidUpdate(prevProps: SettingsFormEditor.IProps): void {
384+
this._setUiSchema(prevProps.renderers);
385+
this._setFilteredSchema(prevProps.filteredValues);
386+
387+
if (prevProps.translator !== this.props.translator) {
388+
this.setState({
389+
arrayFieldTemplate: CustomArrayTemplateFactory(this.props.translator),
390+
objectFieldTemplate: CustomObjectTemplateFactory(this.props.translator)
391+
});
392+
}
393+
394+
if (prevProps.settings !== this.props.settings) {
395+
this.setState({ formContext: { settings: this.props.settings } });
396+
}
397+
}
398+
347399
/**
348400
* Handler for edits made in the form editor.
349401
* @param data - Form data sent from the form editor
@@ -352,18 +404,16 @@ export class SettingsFormEditor extends React.Component<
352404
// Prevent unnecessary save when opening settings that haven't been modified.
353405
if (
354406
!this.props.settings.isModified &&
355-
this.props.settings.isDefault(this.formState.formData)
407+
this.props.settings.isDefault(this._formData)
356408
) {
357409
this.props.updateDirtyState(false);
358410
return;
359411
}
360412
this.props.settings
361-
.save(
362-
JSON.stringify(this.formState.formData, undefined, JSON_INDENTATION)
363-
)
413+
.save(JSON.stringify(this._formData, undefined, JSON_INDENTATION))
364414
.then(() => {
365415
this.props.updateDirtyState(false);
366-
this.formState = { isModified: this.props.settings.isModified };
416+
this.setState({ isModified: this.props.settings.isModified });
367417
})
368418
.catch((reason: string) => {
369419
this.props.updateDirtyState(false);
@@ -377,48 +427,17 @@ export class SettingsFormEditor extends React.Component<
377427
* modified settings then calls `setFormData` to restore the
378428
* values.
379429
*/
380-
reset = async (): Promise<void> => {
430+
reset = async (event: React.MouseEvent): Promise<void> => {
431+
event.stopPropagation();
381432
for (const field in this.props.settings.user) {
382433
await this.props.settings.remove(field);
383434
}
384-
this.formState = {
385-
formData: this.props.settings.composite,
386-
isModified: false
387-
};
435+
this._formData = this.props.settings.composite;
436+
this.setState({ isModified: false });
388437
};
389438

390439
render(): JSX.Element {
391440
const trans = this.props.translator.load('jupyterlab');
392-
393-
/**
394-
* Construct uiSchema to pass any custom renderers to the form editor.
395-
*/
396-
const uiSchema: UiSchema = {};
397-
for (const id in this.props.renderers) {
398-
if (
399-
Object.keys(this.props.settings.schema.properties ?? {}).includes(id)
400-
) {
401-
uiSchema[id] = {
402-
'ui:field': id
403-
};
404-
}
405-
}
406-
407-
/**
408-
* Only show fields that match search value.
409-
*/
410-
const filteredSchema = JSONExt.deepCopy(this.props.settings.schema);
411-
if (this.props.filteredValues?.length ?? 0 > 0) {
412-
for (const field in filteredSchema.properties) {
413-
if (
414-
!this.props.filteredValues?.includes(
415-
filteredSchema.properties[field].title ?? field
416-
)
417-
) {
418-
delete filteredSchema.properties[field];
419-
}
420-
}
421-
}
422441
const icon = this.props.isCollapsed ? caretRightIcon : caretDownIcon;
423442

424443
return (
@@ -441,48 +460,102 @@ export class SettingsFormEditor extends React.Component<
441460
{this.props.settings.schema.description}
442461
</div>
443462
</header>
444-
{this.formState.isModified && (
463+
{this.state.isModified && (
445464
<button className="jp-RestoreButton" onClick={this.reset}>
446465
{trans.__('Restore to Defaults')}
447466
</button>
448467
)}
449468
</div>
450469
{!this.props.isCollapsed && (
451470
<Form
452-
schema={filteredSchema as JSONSchema7}
453-
formData={this.formState.formData}
471+
schema={this.state.filteredSchema as JSONSchema7}
472+
formData={this._formData}
454473
FieldTemplate={CustomTemplate}
455-
ArrayFieldTemplate={CustomArrayTemplateFactory(
456-
this.props.translator
457-
)}
458-
ObjectFieldTemplate={CustomObjectTemplateFactory(
459-
this.props.translator
460-
)}
461-
uiSchema={uiSchema}
474+
ArrayFieldTemplate={this.state.arrayFieldTemplate}
475+
ObjectFieldTemplate={this.state.objectFieldTemplate}
476+
uiSchema={this.state.uiSchema}
462477
fields={this.props.renderers}
463-
formContext={{ settings: this.props.settings }}
478+
formContext={this.state.formContext}
464479
liveValidate
465480
idPrefix={`jp-SettingsEditor-${this.props.settings.id}`}
466-
onChange={(e: IChangeEvent<ReadonlyPartialJSONObject>) => {
467-
this.props.hasError(e.errors.length !== 0);
468-
this.formState = { formData: e.formData };
469-
if (e.errors.length === 0) {
470-
this.props.updateDirtyState(true);
471-
void this._debouncer.invoke();
472-
}
473-
this.props.onSelect(this.props.settings.id);
474-
}}
481+
onChange={this._onChange}
475482
/>
476483
)}
477484
</div>
478485
);
479486
}
480487

488+
/**
489+
* Callback on plugin selection
490+
* @param list Plugin list
491+
* @param id Plugin id
492+
*/
481493
protected onSelect = (list: PluginList, id: string): void => {
482494
if (id === this.props.settings.id) {
483495
this.props.onCollapseChange(false);
484496
}
485497
};
486498

499+
private _onChange = (e: IChangeEvent<ReadonlyPartialJSONObject>): void => {
500+
this.props.hasError(e.errors.length !== 0);
501+
this._formData = e.formData;
502+
if (e.errors.length === 0) {
503+
this.props.updateDirtyState(true);
504+
void this._debouncer.invoke();
505+
}
506+
this.props.onSelect(this.props.settings.id);
507+
};
508+
509+
private _setUiSchema(prevRenderers?: { [id: string]: Field }) {
510+
if (
511+
!prevRenderers ||
512+
!JSONExt.deepEqual(
513+
Object.keys(prevRenderers).sort(),
514+
Object.keys(this.props.renderers).sort()
515+
)
516+
) {
517+
/**
518+
* Construct uiSchema to pass any custom renderers to the form editor.
519+
*/
520+
const uiSchema: UiSchema = {};
521+
for (const id in this.props.renderers) {
522+
if (
523+
Object.keys(this.props.settings.schema.properties ?? {}).includes(id)
524+
) {
525+
uiSchema[id] = {
526+
'ui:field': id
527+
};
528+
}
529+
}
530+
this.setState({ uiSchema });
531+
}
532+
}
533+
534+
private _setFilteredSchema(prevFilteredValues?: string[] | null) {
535+
if (
536+
prevFilteredValues === undefined ||
537+
!JSONExt.deepEqual(prevFilteredValues, this.props.filteredValues)
538+
) {
539+
/**
540+
* Only show fields that match search value.
541+
*/
542+
const filteredSchema = JSONExt.deepCopy(this.props.settings.schema);
543+
if (this.props.filteredValues?.length ?? 0 > 0) {
544+
for (const field in filteredSchema.properties) {
545+
if (
546+
!this.props.filteredValues?.includes(
547+
filteredSchema.properties[field].title ?? field
548+
)
549+
) {
550+
delete filteredSchema.properties[field];
551+
}
552+
}
553+
}
554+
555+
this.setState({ filteredSchema });
556+
}
557+
}
558+
487559
private _debouncer: Debouncer<void, any>;
560+
private _formData: any;
488561
}

0 commit comments

Comments
 (0)