1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 | import { ITranslator, nullTranslator } from '@jupyterlab/translation';
|
7 | import { JSONExt, ReadonlyJSONObject } from '@lumino/coreutils';
|
8 |
|
9 | import Form, { FormProps, IChangeEvent } from '@rjsf/core';
|
10 |
|
11 | import {
|
12 | ADDITIONAL_PROPERTY_FLAG,
|
13 | ArrayFieldTemplateProps,
|
14 | canExpand,
|
15 | FieldTemplateProps,
|
16 | getTemplate,
|
17 | ObjectFieldTemplateProps,
|
18 | Registry,
|
19 | UiSchema
|
20 | } from '@rjsf/utils';
|
21 |
|
22 | import React from 'react';
|
23 | import {
|
24 | addIcon,
|
25 | caretDownIcon,
|
26 | caretUpIcon,
|
27 | closeIcon,
|
28 | LabIcon
|
29 | } from '../icon';
|
30 |
|
31 |
|
32 |
|
33 |
|
34 | export const DEFAULT_UI_OPTIONS = {
|
35 | |
36 |
|
37 |
|
38 |
|
39 |
|
40 |
|
41 | submitButtonOptions: {
|
42 | norender: true
|
43 | }
|
44 | };
|
45 |
|
46 |
|
47 |
|
48 |
|
49 | export namespace FormComponent {
|
50 | export interface IButtonProps {
|
51 | |
52 |
|
53 |
|
54 | buttonStyle?: 'icons' | 'text';
|
55 | |
56 |
|
57 |
|
58 | translator?: ITranslator;
|
59 | }
|
60 |
|
61 | |
62 |
|
63 |
|
64 | export interface ILabCustomizerProps extends IButtonProps {
|
65 | |
66 |
|
67 |
|
68 |
|
69 | compact?: boolean;
|
70 | |
71 |
|
72 |
|
73 | showModifiedFromDefault?: boolean;
|
74 | }
|
75 |
|
76 | |
77 |
|
78 |
|
79 | export interface IMoveButtonProps extends IButtonProps {
|
80 | |
81 |
|
82 |
|
83 | item: ArrayFieldTemplateProps['items'][number];
|
84 | |
85 |
|
86 |
|
87 | direction: 'up' | 'down';
|
88 | }
|
89 |
|
90 | |
91 |
|
92 |
|
93 | export interface IDropButtonProps extends IButtonProps {
|
94 | |
95 |
|
96 |
|
97 | item: ArrayFieldTemplateProps['items'][number];
|
98 | }
|
99 |
|
100 | |
101 |
|
102 |
|
103 | export interface IAddButtonProps extends IButtonProps {
|
104 | |
105 |
|
106 |
|
107 | onAddClick: ArrayFieldTemplateProps['onAddClick'];
|
108 | }
|
109 | }
|
110 |
|
111 |
|
112 |
|
113 |
|
114 |
|
115 | export 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 |
|
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 |
|
165 |
|
166 |
|
167 |
|
168 | export 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 |
|
198 |
|
199 |
|
200 |
|
201 | export 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 |
|
225 | export interface ILabCustomizerOptions<P>
|
226 | extends FormComponent.ILabCustomizerProps {
|
227 | name?: string;
|
228 | component: React.FunctionComponent<
|
229 | P & Required<FormComponent.ILabCustomizerProps>
|
230 | >;
|
231 | }
|
232 |
|
233 | function 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 |
|
264 |
|
265 | function 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 |
|
283 |
|
284 |
|
285 | const 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 |
|
369 |
|
370 | const 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 |
|
434 |
|
435 | const 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 |
|
470 |
|
471 |
|
472 |
|
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 |
|
495 |
|
496 |
|
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 |
|
512 | <div className="jp-modifiedIndicator jp-errorIndicator" />
|
513 | ) : (
|
514 |
|
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 |
|
595 |
|
596 | export 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 |
|
615 |
|
616 | export 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 | }
|