/*
* 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 (
{buttonContent}
);
};
/**
* 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 (
{buttonContent}
);
};
/**
* 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 (
{buttonContent}
);
};
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 (
);
})}
{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 && (
{trans.__('Remove')}
)}
{!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 (
);
}