/* * Copyright (c) Jupyter Development Team. * Distributed under the terms of the Modified BSD License. */ import { ITranslator, nullTranslator } from '@jupyterlab/translation'; import { JSONExt, ReadonlyJSONObject } from '@lumino/coreutils'; import Form, { FormProps, IChangeEvent } from '@rjsf/core'; import { ADDITIONAL_PROPERTY_FLAG, ArrayFieldTemplateProps, canExpand, FieldTemplateProps, getTemplate, ObjectFieldTemplateProps, Registry, UiSchema } from '@rjsf/utils'; import React from 'react'; import { addIcon, caretDownIcon, caretUpIcon, closeIcon, LabIcon } from '../icon'; /** * Default `ui:options` for the UiSchema. */ export const DEFAULT_UI_OPTIONS = { /** * This prevents the submit button from being rendered, by default, as it is * almost never what is wanted. * * Provide any `uiSchema#/ui:options/submitButtonOptions` to override this. */ submitButtonOptions: { norender: true } }; /** * Form component namespace. */ export namespace FormComponent { export interface IButtonProps { /** * Button style. */ buttonStyle?: 'icons' | 'text'; /** * Translator for button text. */ translator?: ITranslator; } /** * Properties for React JSON schema form's container template (array and object). */ export interface ILabCustomizerProps extends IButtonProps { /** * Whether the container is in compact mode or not. * In compact mode the title and description are displayed more compactness. */ compact?: boolean; /** * Whether to display if the current value is not the default one. */ showModifiedFromDefault?: boolean; } /** * Properties of the button to move an item. */ export interface IMoveButtonProps extends IButtonProps { /** * Item index to move with this button. */ item: ArrayFieldTemplateProps['items'][number]; /** * Direction in which to move the item. */ direction: 'up' | 'down'; } /** * Properties of the button to drop an item. */ export interface IDropButtonProps extends IButtonProps { /** * Item index to drop with this button. */ item: ArrayFieldTemplateProps['items'][number]; } /** * Properties of the button to add an item. */ export interface IAddButtonProps extends IButtonProps { /** * Function to call to add an item. */ onAddClick: ArrayFieldTemplateProps['onAddClick']; } } /** * Button to move an item. * * @returns - the button as a react element. */ export const MoveButton = ( props: FormComponent.IMoveButtonProps ): JSX.Element => { const trans = (props.translator ?? nullTranslator).load('jupyterlab'); let buttonContent: JSX.Element | string; /** * Whether the button is disabled or not. */ const disabled = () => { if (props.direction === 'up') { return !props.item.hasMoveUp; } else { return !props.item.hasMoveDown; } }; if (props.buttonStyle === 'icons') { const iconProps: LabIcon.IReactProps = { tag: 'span', elementSize: 'xlarge', elementPosition: 'center' }; buttonContent = props.direction === 'up' ? ( ) : ( ); } else { buttonContent = props.direction === 'up' ? trans.__('Move up') : trans.__('Move down'); } const moveTo = props.direction === 'up' ? props.item.index - 1 : props.item.index + 1; return ( ); }; /** * Button to drop an item. * * @returns - the button as a react element. */ export const DropButton = ( props: FormComponent.IDropButtonProps ): JSX.Element => { const trans = (props.translator ?? nullTranslator).load('jupyterlab'); let buttonContent: JSX.Element | string; if (props.buttonStyle === 'icons') { buttonContent = ( ); } else { buttonContent = trans.__('Remove'); } return ( ); }; /** * Button to add an item. * * @returns - the button as a react element. */ export const AddButton = ( props: FormComponent.IAddButtonProps ): JSX.Element => { const trans = (props.translator ?? nullTranslator).load('jupyterlab'); let buttonContent: JSX.Element | string; if (props.buttonStyle === 'icons') { buttonContent = ( ); } else { buttonContent = trans.__('Add'); } return ( ); }; export interface ILabCustomizerOptions

extends FormComponent.ILabCustomizerProps { name?: string; component: React.FunctionComponent< P & Required >; } function customizeForLab

( options: ILabCustomizerOptions

): React.FunctionComponent

{ const { component, name, buttonStyle, compact, showModifiedFromDefault, translator } = options; const isCompact = compact ?? false; const button = buttonStyle ?? (isCompact ? 'icons' : 'text'); const factory = (props: P) => component({ ...props, buttonStyle: button, compact: isCompact, showModifiedFromDefault: showModifiedFromDefault ?? true, translator: translator ?? nullTranslator }); if (name) { factory.displayName = name; } return factory; } /** * Fetch field templates from RJSF. */ function getTemplates(registry: Registry, uiSchema: UiSchema | undefined) { const TitleField = getTemplate<'TitleFieldTemplate'>( 'TitleFieldTemplate', registry, uiSchema ); const DescriptionField = getTemplate<'DescriptionFieldTemplate'>( 'DescriptionFieldTemplate', registry, uiSchema ); return { TitleField, DescriptionField }; } /** * Template to allow for custom buttons to re-order/remove entries in an array. * Necessary to create accessible buttons. */ const CustomArrayTemplateFactory = ( options: FormComponent.ILabCustomizerProps ) => customizeForLab({ ...options, name: 'JupyterLabArrayTemplate', component: props => { const { schema, registry, uiSchema, required } = props; const commonProps = { schema, registry, uiSchema, required }; const { TitleField, DescriptionField } = getTemplates(registry, uiSchema); return (

{props.compact ? (
{props.title || ''}
{props.schema.description || ''}
) : ( <> {props.title && ( )} )} {props.items.map(item => { return (
{item.children}
); })} {props.canAdd && ( )}
); } }); /** * Template with custom add button, necessary for accessibility and internationalization. */ const CustomObjectTemplateFactory = ( options: FormComponent.ILabCustomizerProps ) => customizeForLab({ ...options, name: 'JupyterLabObjectTemplate', component: props => { const { schema, registry, uiSchema, required } = props; const commonProps = { schema, registry, uiSchema, required }; const { TitleField, DescriptionField } = getTemplates(registry, uiSchema); return (
{props.compact ? (
{props.title || ''}
{props.schema.description || ''}
) : ( <> {(props.title || (props.uiSchema || JSONExt.emptyObject)['ui:title']) && ( )} )} {props.properties.map(property => property.content)} {canExpand(props.schema, props.uiSchema, props.formData) && ( )}
); } }); /** * Renders the modified indicator and errors */ const CustomTemplateFactory = (options: FormComponent.ILabCustomizerProps) => customizeForLab({ ...options, name: 'JupyterLabFieldTemplate', component: props => { const trans = (props.translator ?? nullTranslator).load('jupyterlab'); let isModified = false; let defaultValue: any; const { formData, schema, label, displayLabel, id, formContext, errors, rawErrors, children, onKeyChange, onDropPropertyClick } = props; const { defaultFormData } = formContext; const schemaIds = id.split('_'); schemaIds.shift(); const schemaId = schemaIds.join('.'); const isRoot = schemaId === ''; const hasCustomField = schemaId === (props.uiSchema || JSONExt.emptyObject)['ui:field']; if (props.showModifiedFromDefault) { /** * Determine if the field has been modified. * Schema Id is formatted as 'root_.' * This logic parses out the field name to find the default value * before determining if the field has been modified. */ defaultValue = schemaIds.reduce( (acc, key) => acc?.[key], defaultFormData ); isModified = !isRoot && formData !== undefined && defaultValue !== undefined && !schema.properties && schema.type !== 'array' && !JSONExt.deepEqual(formData, defaultValue); } const needsDescription = !isRoot && schema.type != 'object' && id != 'jp-SettingsEditor-@jupyterlab/shortcuts-extension:shortcuts_shortcuts'; // While we can implement "remove" button for array items in array template, // object templates do not provide a way to do this instead we need to add // buttons here (and first check if the field can be removed = is additional). const isAdditional = schema.hasOwnProperty(ADDITIONAL_PROPERTY_FLAG); const isItem: boolean = !( schema.type === 'object' || schema.type === 'array' ); return (
{!hasCustomField && (rawErrors?.length ? ( // Shows a red indicator for fields that have validation errors
) : ( // Only show the modified indicator if there are no errors isModified &&
))}
{isItem && displayLabel && !isRoot && label && !isAdditional ? ( props.compact ? (
{label}
{isItem && schema.description && needsDescription && (
{schema.description}
)}
) : (

{label}

) ) : ( <> )} {isAdditional && ( onKeyChange(event.target.value)} defaultValue={label} /> )}
{children}
{isAdditional && ( )} {!props.compact && schema.description && needsDescription && (
{schema.description}
)} {isModified && defaultValue !== undefined && schema.type !== 'object' && (
{trans.__( 'Default: %1', defaultValue !== null ? defaultValue.toLocaleString() : 'null' )}
)}
{errors}
); } }); /** * FormComponent properties */ export interface IFormComponentProps extends FormProps, FormComponent.ILabCustomizerProps { /** * */ formData: T; /** * */ onChange: (e: IChangeEvent) => any; /** * */ formContext?: unknown; } /** * Generic rjsf form component for JupyterLab UI. */ export function FormComponent(props: IFormComponentProps): JSX.Element { const { buttonStyle, compact, showModifiedFromDefault, translator, formContext, ...others } = props; const uiSchema = { ...(others.uiSchema || JSONExt.emptyObject) } as UiSchema; uiSchema['ui:options'] = { ...DEFAULT_UI_OPTIONS, ...uiSchema['ui:options'] }; others.uiSchema = uiSchema; const { FieldTemplate, ArrayFieldTemplate, ObjectFieldTemplate } = props.templates || JSONExt.emptyObject; const customization = { buttonStyle, compact, showModifiedFromDefault, translator }; const fieldTemplate = React.useMemo( () => FieldTemplate ?? CustomTemplateFactory(customization), [FieldTemplate, buttonStyle, compact, showModifiedFromDefault, translator] ) as React.FunctionComponent; const arrayTemplate = React.useMemo( () => ArrayFieldTemplate ?? CustomArrayTemplateFactory(customization), [ ArrayFieldTemplate, buttonStyle, compact, showModifiedFromDefault, translator ] ) as React.FunctionComponent; const objectTemplate = React.useMemo( () => ObjectFieldTemplate ?? CustomObjectTemplateFactory(customization), [ ObjectFieldTemplate, buttonStyle, compact, showModifiedFromDefault, translator ] ) as React.FunctionComponent; const templates: Record = { FieldTemplate: fieldTemplate, ArrayFieldTemplate: arrayTemplate, ObjectFieldTemplate: objectTemplate }; return (
); }