UNPKG

18.9 kBTypeScriptView Raw
1/*
2 * Copyright (c) Jupyter Development Team.
3 * Distributed under the terms of the Modified BSD License.
4 */
5
6import { ITranslator, nullTranslator } from '@jupyterlab/translation';
7import { JSONExt, ReadonlyJSONObject } from '@lumino/coreutils';
8
9import Form, { FormProps, IChangeEvent } from '@rjsf/core';
10
11import {
12 ADDITIONAL_PROPERTY_FLAG,
13 ArrayFieldTemplateProps,
14 canExpand,
15 FieldTemplateProps,
16 getTemplate,
17 ObjectFieldTemplateProps,
18 Registry,
19 UiSchema
20} from '@rjsf/utils';
21
22import React from 'react';
23import {
24 addIcon,
25 caretDownIcon,
26 caretUpIcon,
27 closeIcon,
28 LabIcon
29} from '../icon';
30
31/**
32 * Default `ui:options` for the UiSchema.
33 */
34export const DEFAULT_UI_OPTIONS = {
35 /**
36 * This prevents the submit button from being rendered, by default, as it is
37 * almost never what is wanted.
38 *
39 * Provide any `uiSchema#/ui:options/submitButtonOptions` to override this.
40 */
41 submitButtonOptions: {
42 norender: true
43 }
44};
45
46/**
47 * Form component namespace.
48 */
49export namespace FormComponent {
50 export interface IButtonProps {
51 /**
52 * Button style.
53 */
54 buttonStyle?: 'icons' | 'text';
55 /**
56 * Translator for button text.
57 */
58 translator?: ITranslator;
59 }
60
61 /**
62 * Properties for React JSON schema form's container template (array and object).
63 */
64 export interface ILabCustomizerProps extends IButtonProps {
65 /**
66 * Whether the container is in compact mode or not.
67 * In compact mode the title and description are displayed more compactness.
68 */
69 compact?: boolean;
70 /**
71 * Whether to display if the current value is not the default one.
72 */
73 showModifiedFromDefault?: boolean;
74 }
75
76 /**
77 * Properties of the button to move an item.
78 */
79 export interface IMoveButtonProps extends IButtonProps {
80 /**
81 * Item index to move with this button.
82 */
83 item: ArrayFieldTemplateProps['items'][number];
84 /**
85 * Direction in which to move the item.
86 */
87 direction: 'up' | 'down';
88 }
89
90 /**
91 * Properties of the button to drop an item.
92 */
93 export interface IDropButtonProps extends IButtonProps {
94 /**
95 * Item index to drop with this button.
96 */
97 item: ArrayFieldTemplateProps['items'][number];
98 }
99
100 /**
101 * Properties of the button to add an item.
102 */
103 export interface IAddButtonProps extends IButtonProps {
104 /**
105 * Function to call to add an item.
106 */
107 onAddClick: ArrayFieldTemplateProps['onAddClick'];
108 }
109}
110/**
111 * Button to move an item.
112 *
113 * @returns - the button as a react element.
114 */
115export const MoveButton = (
116 props: FormComponent.IMoveButtonProps
117): JSX.Element => {
118 const trans = (props.translator ?? nullTranslator).load('jupyterlab');
119 let buttonContent: JSX.Element | string;
120
121 /**
122 * Whether the button is disabled or not.
123 */
124 const disabled = () => {
125 if (props.direction === 'up') {
126 return !props.item.hasMoveUp;
127 } else {
128 return !props.item.hasMoveDown;
129 }
130 };
131
132 if (props.buttonStyle === 'icons') {
133 const iconProps: LabIcon.IReactProps = {
134 tag: 'span',
135 elementSize: 'xlarge',
136 elementPosition: 'center'
137 };
138 buttonContent =
139 props.direction === 'up' ? (
140 <caretUpIcon.react {...iconProps}></caretUpIcon.react>
141 ) : (
142 <caretDownIcon.react {...iconProps}></caretDownIcon.react>
143 );
144 } else {
145 buttonContent =
146 props.direction === 'up' ? trans.__('Move up') : trans.__('Move down');
147 }
148
149 const moveTo =
150 props.direction === 'up' ? props.item.index - 1 : props.item.index + 1;
151
152 return (
153 <button
154 className="jp-mod-styled jp-mod-reject jp-ArrayOperationsButton"
155 onClick={props.item.onReorderClick(props.item.index, moveTo)}
156 disabled={disabled()}
157 >
158 {buttonContent}
159 </button>
160 );
161};
162
163/**
164 * Button to drop an item.
165 *
166 * @returns - the button as a react element.
167 */
168export const DropButton = (
169 props: FormComponent.IDropButtonProps
170): JSX.Element => {
171 const trans = (props.translator ?? nullTranslator).load('jupyterlab');
172 let buttonContent: JSX.Element | string;
173
174 if (props.buttonStyle === 'icons') {
175 buttonContent = (
176 <closeIcon.react
177 tag="span"
178 elementSize="xlarge"
179 elementPosition="center"
180 />
181 );
182 } else {
183 buttonContent = trans.__('Remove');
184 }
185
186 return (
187 <button
188 className="jp-mod-styled jp-mod-warn jp-ArrayOperationsButton"
189 onClick={props.item.onDropIndexClick(props.item.index)}
190 >
191 {buttonContent}
192 </button>
193 );
194};
195
196/**
197 * Button to add an item.
198 *
199 * @returns - the button as a react element.
200 */
201export const AddButton = (
202 props: FormComponent.IAddButtonProps
203): JSX.Element => {
204 const trans = (props.translator ?? nullTranslator).load('jupyterlab');
205 let buttonContent: JSX.Element | string;
206
207 if (props.buttonStyle === 'icons') {
208 buttonContent = (
209 <addIcon.react tag="span" elementSize="xlarge" elementPosition="center" />
210 );
211 } else {
212 buttonContent = trans.__('Add');
213 }
214
215 return (
216 <button
217 className="jp-mod-styled jp-mod-reject jp-ArrayOperationsButton"
218 onClick={props.onAddClick}
219 >
220 {buttonContent}
221 </button>
222 );
223};
224
225export interface ILabCustomizerOptions<P>
226 extends FormComponent.ILabCustomizerProps {
227 name?: string;
228 component: React.FunctionComponent<
229 P & Required<FormComponent.ILabCustomizerProps>
230 >;
231}
232
233function customizeForLab<P = any>(
234 options: ILabCustomizerOptions<P>
235): React.FunctionComponent<P> {
236 const {
237 component,
238 name,
239 buttonStyle,
240 compact,
241 showModifiedFromDefault,
242 translator
243 } = options;
244
245 const isCompact = compact ?? false;
246 const button = buttonStyle ?? (isCompact ? 'icons' : 'text');
247
248 const factory = (props: P) =>
249 component({
250 ...props,
251 buttonStyle: button,
252 compact: isCompact,
253 showModifiedFromDefault: showModifiedFromDefault ?? true,
254 translator: translator ?? nullTranslator
255 });
256 if (name) {
257 factory.displayName = name;
258 }
259 return factory;
260}
261
262/**
263 * Fetch field templates from RJSF.
264 */
265function getTemplates(registry: Registry, uiSchema: UiSchema | undefined) {
266 const TitleField = getTemplate<'TitleFieldTemplate'>(
267 'TitleFieldTemplate',
268 registry,
269 uiSchema
270 );
271
272 const DescriptionField = getTemplate<'DescriptionFieldTemplate'>(
273 'DescriptionFieldTemplate',
274 registry,
275 uiSchema
276 );
277
278 return { TitleField, DescriptionField };
279}
280
281/**
282 * Template to allow for custom buttons to re-order/remove entries in an array.
283 * Necessary to create accessible buttons.
284 */
285const CustomArrayTemplateFactory = (
286 options: FormComponent.ILabCustomizerProps
287) =>
288 customizeForLab<ArrayFieldTemplateProps>({
289 ...options,
290 name: 'JupyterLabArrayTemplate',
291 component: props => {
292 const { schema, registry, uiSchema, required } = props;
293 const commonProps = { schema, registry, uiSchema, required };
294 const { TitleField, DescriptionField } = getTemplates(registry, uiSchema);
295
296 return (
297 <div className={props.className}>
298 {props.compact ? (
299 <div className="jp-FormGroup-compactTitle">
300 <div
301 className="jp-FormGroup-fieldLabel jp-FormGroup-contentItem"
302 id={`${props.idSchema.$id}__title`}
303 >
304 {props.title || ''}
305 </div>
306 <div
307 className="jp-FormGroup-description"
308 id={`${props.idSchema.$id}-description`}
309 >
310 {props.schema.description || ''}
311 </div>
312 </div>
313 ) : (
314 <>
315 {props.title && (
316 <TitleField
317 {...commonProps}
318 title={props.title}
319 id={`${props.idSchema.$id}-title`}
320 />
321 )}
322 <DescriptionField
323 {...commonProps}
324 id={`${props.idSchema.$id}-description`}
325 description={props.schema.description ?? ''}
326 />
327 </>
328 )}
329 {props.items.map(item => {
330 return (
331 <div key={item.key} className={item.className}>
332 {item.children}
333 <div className="jp-ArrayOperations">
334 <MoveButton
335 buttonStyle={props.buttonStyle}
336 translator={props.translator}
337 item={item}
338 direction="up"
339 />
340 <MoveButton
341 buttonStyle={props.buttonStyle}
342 translator={props.translator}
343 item={item}
344 direction="down"
345 />
346 <DropButton
347 buttonStyle={props.buttonStyle}
348 translator={props.translator}
349 item={item}
350 />
351 </div>
352 </div>
353 );
354 })}
355 {props.canAdd && (
356 <AddButton
357 onAddClick={props.onAddClick}
358 buttonStyle={props.buttonStyle}
359 translator={props.translator}
360 />
361 )}
362 </div>
363 );
364 }
365 });
366
367/**
368 * Template with custom add button, necessary for accessibility and internationalization.
369 */
370const CustomObjectTemplateFactory = (
371 options: FormComponent.ILabCustomizerProps
372) =>
373 customizeForLab<ObjectFieldTemplateProps>({
374 ...options,
375 name: 'JupyterLabObjectTemplate',
376 component: props => {
377 const { schema, registry, uiSchema, required } = props;
378 const commonProps = { schema, registry, uiSchema, required };
379 const { TitleField, DescriptionField } = getTemplates(registry, uiSchema);
380
381 return (
382 <fieldset id={props.idSchema.$id}>
383 {props.compact ? (
384 <div className="jp-FormGroup-compactTitle">
385 <div
386 className="jp-FormGroup-fieldLabel jp-FormGroup-contentItem"
387 id={`${props.idSchema.$id}__title`}
388 >
389 {props.title || ''}
390 </div>
391 <div
392 className="jp-FormGroup-description"
393 id={`${props.idSchema.$id}__description`}
394 >
395 {props.schema.description || ''}
396 </div>
397 </div>
398 ) : (
399 <>
400 {(props.title ||
401 (props.uiSchema || JSONExt.emptyObject)['ui:title']) && (
402 <TitleField
403 {...commonProps}
404 id={`${props.idSchema.$id}__title`}
405 title={
406 props.title ||
407 `${(props.uiSchema || JSONExt.emptyObject)['ui:title']}` ||
408 ''
409 }
410 />
411 )}
412 <DescriptionField
413 {...commonProps}
414 id={`${props.idSchema.$id}__description`}
415 description={props.schema.description ?? ''}
416 />
417 </>
418 )}
419 {props.properties.map(property => property.content)}
420 {canExpand(props.schema, props.uiSchema, props.formData) && (
421 <AddButton
422 onAddClick={props.onAddClick(props.schema)}
423 buttonStyle={props.buttonStyle}
424 translator={props.translator}
425 />
426 )}
427 </fieldset>
428 );
429 }
430 });
431
432/**
433 * Renders the modified indicator and errors
434 */
435const CustomTemplateFactory = (options: FormComponent.ILabCustomizerProps) =>
436 customizeForLab<FieldTemplateProps>({
437 ...options,
438 name: 'JupyterLabFieldTemplate',
439 component: props => {
440 const trans = (props.translator ?? nullTranslator).load('jupyterlab');
441 let isModified = false;
442 let defaultValue: any;
443 const {
444 formData,
445 schema,
446 label,
447 displayLabel,
448 id,
449 formContext,
450 errors,
451 rawErrors,
452 children,
453 onKeyChange,
454 onDropPropertyClick
455 } = props;
456
457 const { defaultFormData } = formContext;
458 const schemaIds = id.split('_');
459 schemaIds.shift();
460 const schemaId = schemaIds.join('.');
461
462 const isRoot = schemaId === '';
463
464 const hasCustomField =
465 schemaId === (props.uiSchema || JSONExt.emptyObject)['ui:field'];
466
467 if (props.showModifiedFromDefault) {
468 /**
469 * Determine if the field has been modified.
470 * Schema Id is formatted as 'root_<field name>.<nested field name>'
471 * This logic parses out the field name to find the default value
472 * before determining if the field has been modified.
473 */
474
475 defaultValue = schemaIds.reduce(
476 (acc, key) => acc?.[key],
477 defaultFormData
478 );
479 isModified =
480 !isRoot &&
481 formData !== undefined &&
482 defaultValue !== undefined &&
483 !schema.properties &&
484 schema.type !== 'array' &&
485 !JSONExt.deepEqual(formData, defaultValue);
486 }
487
488 const needsDescription =
489 !isRoot &&
490 schema.type != 'object' &&
491 id !=
492 'jp-SettingsEditor-@jupyterlab/shortcuts-extension:shortcuts_shortcuts';
493
494 // While we can implement "remove" button for array items in array template,
495 // object templates do not provide a way to do this instead we need to add
496 // buttons here (and first check if the field can be removed = is additional).
497 const isAdditional = schema.hasOwnProperty(ADDITIONAL_PROPERTY_FLAG);
498
499 const isItem: boolean = !(
500 schema.type === 'object' || schema.type === 'array'
501 );
502
503 return (
504 <div
505 className={`form-group ${
506 displayLabel || schema.type === 'boolean' ? 'small-field' : ''
507 }`}
508 >
509 {!hasCustomField &&
510 (rawErrors ? (
511 // Shows a red indicator for fields that have validation errors
512 <div className="jp-modifiedIndicator jp-errorIndicator" />
513 ) : (
514 // Only show the modified indicator if there are no errors
515 isModified && <div className="jp-modifiedIndicator" />
516 ))}
517 <div
518 className={`jp-FormGroup-content ${
519 props.compact
520 ? 'jp-FormGroup-contentCompact'
521 : 'jp-FormGroup-contentNormal'
522 }`}
523 >
524 {isItem && displayLabel && !isRoot && label && !isAdditional ? (
525 props.compact ? (
526 <div className="jp-FormGroup-compactTitle">
527 <div className="jp-FormGroup-fieldLabel jp-FormGroup-contentItem">
528 {label}
529 </div>
530 {isItem && schema.description && needsDescription && (
531 <div className="jp-FormGroup-description">
532 {schema.description}
533 </div>
534 )}
535 </div>
536 ) : (
537 <h3 className="jp-FormGroup-fieldLabel jp-FormGroup-contentItem">
538 {label}
539 </h3>
540 )
541 ) : (
542 <></>
543 )}
544 {isAdditional && (
545 <input
546 className="jp-FormGroup-contentItem jp-mod-styled"
547 type="text"
548 onBlur={event => onKeyChange(event.target.value)}
549 defaultValue={label}
550 />
551 )}
552 <div
553 className={`${
554 isRoot
555 ? 'jp-root'
556 : schema.type === 'object'
557 ? 'jp-objectFieldWrapper'
558 : schema.type === 'array'
559 ? 'jp-arrayFieldWrapper'
560 : 'jp-inputFieldWrapper jp-FormGroup-contentItem'
561 }`}
562 >
563 {children}
564 </div>
565 {isAdditional && (
566 <button
567 className="jp-FormGroup-contentItem jp-mod-styled jp-mod-warn jp-FormGroup-removeButton"
568 onClick={onDropPropertyClick(label)}
569 >
570 {trans.__('Remove')}
571 </button>
572 )}
573 {!props.compact && schema.description && needsDescription && (
574 <div className="jp-FormGroup-description">
575 {schema.description}
576 </div>
577 )}
578 {isModified && defaultValue !== undefined && (
579 <div className="jp-FormGroup-default">
580 {trans.__(
581 'Default: %1',
582 defaultValue !== null ? defaultValue.toLocaleString() : 'null'
583 )}
584 </div>
585 )}
586 <div className="validationErrors">{errors}</div>
587 </div>
588 </div>
589 );
590 }
591 });
592
593/**
594 * FormComponent properties
595 */
596export interface IFormComponentProps<T = ReadonlyJSONObject>
597 extends FormProps<T>,
598 FormComponent.ILabCustomizerProps {
599 /**
600 *
601 */
602 formData: T;
603 /**
604 *
605 */
606 onChange: (e: IChangeEvent<T>) => any;
607 /**
608 *
609 */
610 formContext?: unknown;
611}
612
613/**
614 * Generic rjsf form component for JupyterLab UI.
615 */
616export function FormComponent(props: IFormComponentProps): JSX.Element {
617 const {
618 buttonStyle,
619 compact,
620 showModifiedFromDefault,
621 translator,
622 formContext,
623 ...others
624 } = props;
625
626 const uiSchema = { ...(others.uiSchema || JSONExt.emptyObject) } as UiSchema;
627
628 uiSchema['ui:options'] = { ...DEFAULT_UI_OPTIONS, ...uiSchema['ui:options'] };
629
630 others.uiSchema = uiSchema;
631
632 const { FieldTemplate, ArrayFieldTemplate, ObjectFieldTemplate } =
633 props.templates || JSONExt.emptyObject;
634
635 const customization = {
636 buttonStyle,
637 compact,
638 showModifiedFromDefault,
639 translator
640 };
641
642 const fieldTemplate = React.useMemo(
643 () => FieldTemplate ?? CustomTemplateFactory(customization),
644 [FieldTemplate, buttonStyle, compact, showModifiedFromDefault, translator]
645 ) as React.FunctionComponent;
646
647 const arrayTemplate = React.useMemo(
648 () => ArrayFieldTemplate ?? CustomArrayTemplateFactory(customization),
649 [
650 ArrayFieldTemplate,
651 buttonStyle,
652 compact,
653 showModifiedFromDefault,
654 translator
655 ]
656 ) as React.FunctionComponent;
657
658 const objectTemplate = React.useMemo(
659 () => ObjectFieldTemplate ?? CustomObjectTemplateFactory(customization),
660 [
661 ObjectFieldTemplate,
662 buttonStyle,
663 compact,
664 showModifiedFromDefault,
665 translator
666 ]
667 ) as React.FunctionComponent;
668
669 const templates: Record<string, React.FunctionComponent> = {
670 FieldTemplate: fieldTemplate,
671 ArrayFieldTemplate: arrayTemplate,
672 ObjectFieldTemplate: objectTemplate
673 };
674
675 return (
676 <Form templates={templates} formContext={formContext as any} {...others} />
677 );
678}