Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom UI Layout #3503

Open
1 task done
dcantu96 opened this issue Mar 14, 2023 · 7 comments
Open
1 task done

Custom UI Layout #3503

dcantu96 opened this issue Mar 14, 2023 · 7 comments
Assignees
Labels
feature Is a feature request

Comments

@dcantu96
Copy link

dcantu96 commented Mar 14, 2023

Prerequisites

What theme are you using?

other

Is your feature request related to a problem? Please describe.

Hello everyone, I'm opening this discussion because I had trouble figuring out a way to customize my form layouts. I wanted to demonstrate how I achieved this in code.

Describe the solution you'd like

this is my ui schema

const uiSchema: UiSchema<typeof schema> = {
  'ui:layout': {
    type: 'grid',
    cols: 2,
  },
  personalData: {
    'ui:layout': 'flex-col',
  },
}

this is my json schema

const schema: RJSFSchema = {
  type: 'object',
  title: 'Columns',
  properties: {
    name: {
      type: 'string',
      minLength: 3,
      description: 'Please enter your name',
    },
    vegetarian: {
      type: 'boolean',
    },
    birthDate: {
      type: 'string',
      format: 'date',
    },
    nationality: {
      type: 'string',
      enum: ['DE', 'IT', 'JP', 'US', 'RU', 'Other'],
    },
    personalData: {
      type: 'object',
      properties: {
        age: {
          type: 'integer',
          description: 'Please enter your age.',
        },
        height: {
          type: 'number',
        },
        drivingSkill: {
          type: 'number',
          maximum: 10,
          minimum: 1,
          default: 7,
        },
      },
      required: ['age', 'height'],
    },
    occupation: {
      type: 'string',
    },
    postalCode: {
      type: 'string',
      maxLength: 5,
    },
  },
  required: ['occupation', 'nationality'],
}

notice the "ui:layout"property on the uiSchema object. I used that property to map my ObjectFieldTemplate. So in the example, the first object will recieve the grid + 2 cols. and the personalData will recieve the flex-col. This way I conditionally render inside my custom ObjectFieldTemplate :)

kinda like this

export const ObjectFieldTemplate = (props: ObjectFieldTemplateProps) => {
  const maybeOptions = props.uiSchema?.['ui:layout'] as unknown
  if (maybeOptions === undefined || maybeOptions === null) return <DefaultFlexObjectFieldTemplate {...props} />
  else if (typeof maybeOptions === 'string') {
    const options = maybeOptions.split(' ')
    if (options.length > 1) {
      console.warn('ui:layout can only have one option, defaulting to flex-row')
      return <DefaultFlexObjectFieldTemplate {...props} />
    }

    switch (maybeOptions) {
      case 'grid':
        return <DefaultGridObjectFieldTemplate {...props} />
      case 'flex-col':
        return <DefaultFlexColObjectFieldTemplate {...props} />
      case 'flex-row':
      default:
        return <DefaultFlexObjectFieldTemplate {...props} />
    }
  } else if (typeof maybeOptions === 'object') {
    const { type, cols } = maybeOptions as { type?: unknown; cols?: unknown }
    if (type === 'grid') {
      if (typeof cols !== 'number') return <DefaultGridObjectFieldTemplate {...props} />
      else {
        if (cols > 5 || cols < 1) {
          console.warn('cols must be between 1 and 5, defaulting to 5')
        }
        const validCols = Math.min(Math.max(cols, 1), 5)
        return <DefaultGridObjectFieldTemplate {...props} cols={validCols} />
      }
    }
  }
  return <DefaultFlexObjectFieldTemplate {...props} />
}

Describe alternatives you've considered

I had to do this because I did not find a way to achieve this natively. I know the ui:className property is also there but I wanted to render my own custom components so that wasnt enough.

@dcantu96 dcantu96 added feature Is a feature request needs triage Initial label given, to be assigned correct labels and assigned labels Mar 14, 2023
@dcantu96 dcantu96 changed the title Setup UI Layout Custom UI Layout Mar 14, 2023
@heath-freenome
Copy link
Member

Ah @dcantu96 what you really want is something I've built into my local project, a GridFormLayout. Unfortunately, I'll need to build some underlying support for it first since my version is hard-coded to Material-UI 5 and I need to make it work for all themes. It is on the radar though, so stay tuned.

@heath-freenome heath-freenome removed the needs triage Initial label given, to be assigned correct labels and assigned label Mar 16, 2023
@heath-freenome heath-freenome self-assigned this Mar 16, 2023
@aliyss
Copy link

aliyss commented Aug 15, 2023

@dcantu96 Do you by chance have an implementation for DefaultFlexObjectFieldTemplate and DefaultGridObjectFieldTemplate?

I'm not quite sure how you are changing it so it still works... unless you are manually implementing ObjectFieldTemplate everytime.

@brampurnot
Copy link

Do you have the full code of this? I'm interested too in seeing how you did this.

@aliyss
Copy link

aliyss commented Sep 24, 2023

@brampurnot This may be a start. It is by far not complete. And I'm using tailwind here to get results, but it may help you.

I've since moved on to create my own json-schema package. Since I'm anyways using qwik. And didn't like the idea of qwik loading react components, which had me always hacking my way through stuff and in the end loading it client:only. Which made it less reactive.

Here is how I started though:

function CustomizedObjectFieldTemplate<
  T = any,
  S extends StrictRJSFSchema = RJSFSchema,
  F extends FormContextType = any,
>(props: ObjectFieldTemplateProps<T, S, F> & { cols?: number }) {
  const {
    description,
    disabled,
    formData,
    idSchema,
    onAddClick,
    properties,
    readonly,
    registry,
    required,
    schema,
    title,
    uiSchema,
    cols,
  } = props;
  const options = getUiOptions<T, S, F>(uiSchema);
  const TitleFieldTemplate = getTemplate<"TitleFieldTemplate", T, S, F>(
    "TitleFieldTemplate",
    registry,
    options,
  );
  const DescriptionFieldTemplate = getTemplate<
    "DescriptionFieldTemplate",
    T,
    S,
    F
  >("DescriptionFieldTemplate", registry, options);
  // Button templates are not overridden in the uiSchema
  const {
    ButtonTemplates: { AddButton },
  } = registry.templates;

  const colClassName = () => {
    switch (cols) {
      case 1:
        return "md:grid-cols-1";
      case 2:
        return "md:grid-cols-2";
      case 3:
        return "md:grid-cols-3";
      case 4:
        return "md:grid-cols-4";
      case 5:
        return "md:grid-cols-5";
      default:
        return "md:grid-cols-1";
    }
  };

  return (
    <fieldset
      id={idSchema.$id}
      className={`md:grid ${colClassName()} gap-1 gap-x-4`}
    >
      {title && (
        <TitleFieldTemplate
          id={titleId<T>(idSchema)}
          title={title}
          required={required}
          schema={schema}
          uiSchema={uiSchema}
          registry={registry}
        />
      )}
      {description && (
        <DescriptionFieldTemplate
          id={descriptionId<T>(idSchema)}
          description={description}
          schema={schema}
          uiSchema={uiSchema}
          registry={registry}
        />
      )}
      {properties.map((prop: ObjectFieldTemplatePropertyType) => prop.content)}
      {canExpand<T, S, F>(schema, uiSchema, formData) && (
        <AddButton
          className="object-property-expand"
          onClick={onAddClick(schema)}
          disabled={disabled || readonly}
          uiSchema={uiSchema}
          registry={registry}
        />
      )}
    </fieldset>
  );
}

export function DefaultFlexObjectFieldTemplate(
  props: ObjectFieldTemplateProps,
) {
  return <CustomizedObjectFieldTemplate {...props} />;
}

export function DefaultFlexColObjectFieldTemplate(
  props: ObjectFieldTemplateProps,
) {
  return <CustomizedObjectFieldTemplate {...props} />;
}

export function DefaultGridObjectFieldTemplate(
  props: ObjectFieldTemplateProps & { cols?: number },
) {
  return <CustomizedObjectFieldTemplate {...props} />;
}

export const CustomObjectFieldTemplate = (props: ObjectFieldTemplateProps) => {
  const maybeOptions = props.uiSchema?.["ui:layout"] as unknown;
  if (maybeOptions === undefined || maybeOptions === null)
    return <DefaultFlexObjectFieldTemplate {...props} />;
  else if (typeof maybeOptions === "string") {
    const options = maybeOptions.split(" ");
    if (options.length > 1) {
      console.warn(
        "ui:layout can only have one option, defaulting to flex-row",
      );
      return <DefaultFlexObjectFieldTemplate {...props} />;
    }

    switch (maybeOptions) {
      case "grid":
        return <DefaultGridObjectFieldTemplate {...props} />;
      case "flex-col":
        return <DefaultFlexColObjectFieldTemplate {...props} />;
      case "flex-row":
      default:
        return <DefaultFlexObjectFieldTemplate {...props} />;
    }
  } else if (typeof maybeOptions === "object") {
    const { type, cols } = maybeOptions as { type?: unknown; cols?: unknown };
    if (type === "grid") {
      if (typeof cols !== "number")
        return <DefaultGridObjectFieldTemplate {...props} />;
      else {
        if (cols > 5 || cols < 1) {
          console.warn("cols must be between 1 and 5, defaulting to 5");
        }
        const validCols = Math.min(Math.max(cols, 1), 5);
        return <DefaultGridObjectFieldTemplate {...props} cols={validCols} />;
      }
    }
  }
  return <DefaultFlexObjectFieldTemplate {...props} />;
};

@aularon
Copy link
Contributor

aularon commented Sep 25, 2023

You can use rjsf-layout, it allows you to have free (as in freedom), custom, layouts, among other features. It was developed it because I have a few projects where there is a need to have very customized and interactive forms. It is basically a wrapper around the basic RJSF <Form that adds the aforementioned functionality without sacrificing any of the good things.

Here's how your form would, for example, look:
rendered form layout
given the following setup:

"use client";

import Form, { Field, type JSONSchemaObject } from "rjsf-layout";
import validator from "@rjsf/validator-ajv8";
import { Theme as theme } from "@rjsf/mui";

const UserProfile = () => (
  <Form {...{ schema, validator, theme }}>
    <div style={{ display: "flex", gap: 9 }}>
      <div style={{ flex: 1 }}>
        <Field name="name" />
      </div>
      <Field name="birthDate" />
      <div style={{ minWidth: "120px" }}>
        <Field name="nationality" />
      </div>
      <Field name="vegetarian" />
    </div>
    <hr />
    <Field name="personalData">
      {/* Nested fields, also flexed horizontally */}
      <div style={{ display: "flex", gap: 9 }}>
        <Field name="age" />
        <Field name="height" />
        <Field name="drivingSkill" />
      </div>
    </Field>
    <hr />
    <div style={{ display: "flex", gap: 9 }}>
      <div style={{ flex: 1 }}>
        <Field name="occupation" />
      </div>
      <Field name="postalCode" />
    </div>
  </Form>
);

export default UserProfile;

const schema = {
  type: "object",
  title: "Columns",
  properties: {
    name: {
      type: "string",
      minLength: 3,
      description: "Please enter your name",
    },
    vegetarian: {
      type: "boolean",
    },
    birthDate: {
      type: "string",
      format: "date",
    },
    nationality: {
      type: "string",
      enum: ["DE", "IT", "JP", "US", "RU", "Other"],
    },
    personalData: {
      type: "object",
      properties: {
        age: {
          type: "integer",
          description: "Please enter your age.",
        },
        height: {
          type: "number",
        },
        drivingSkill: {
          type: "number",
          maximum: 10,
          minimum: 1,
          default: 7,
        },
      },
      required: ["age", "height"],
    },
    occupation: {
      type: "string",
    },
    postalCode: {
      type: "string",
      maxLength: 5,
    },
  },
  required: ["occupation", "nationality"],
} as const satisfies JSONSchemaObject;

@aliyss
Copy link

aliyss commented Sep 26, 2023

@aularon Seems like we had the same thoughts about making this stuff. Nice repo.

@nagaozen
Copy link

I've built my own custom layout template component:

import { useMemo } from 'react'
import { canExpand, descriptionId, getTemplate, getUiOptions, titleId } from '@rjsf/utils'

/** The LayoutFieldTemplate` is the template to use to render all the inner properties of an object along with the
 * title and description if available. If the object is expandable, then an `AddButton` is also rendered after all
 * the properties.
 *
 * @param props - The `LayoutFieldTemplateProps` for this component
 */
export default function LayoutFieldTemplate (props) {
  const {
    description,
    disabled,
    formData,
    idSchema,
    onAddClick,
    properties,
    readonly,
    registry,
    required,
    schema,
    title,
    uiSchema
  } = props
  const options = getUiOptions(uiSchema)
  const TitleFieldTemplate = getTemplate('TitleFieldTemplate', registry, options)
  const DescriptionFieldTemplate = getTemplate('DescriptionFieldTemplate', registry, options)
  // Button templates are not overridden in the uiSchema
  const {
    ButtonTemplates: { AddButton }
  } = registry.templates

  const layout = uiSchema['ui:layout']
  const map = useMemo(() => properties.reduce((o, x) => ({ ...o, [x.name]: x }), {}), [properties])

  return (
    <fieldset id={idSchema.$id}>
      {title && (
        <TitleFieldTemplate
          id={titleId(idSchema)}
          title={title}
          required={required}
          schema={schema}
          uiSchema={uiSchema}
          registry={registry}
        />
      )}
      {description && (
        <DescriptionFieldTemplate
          id={descriptionId(idSchema)}
          description={description}
          schema={schema}
          uiSchema={uiSchema}
          registry={registry}
        />
      )}
      {layout.map((row, i) => (
        <div key={`L${i}j`} className='row'>
          {Object.keys(row).map((name, j) => (
            <div key={`L${i}${j}`} className={row[name].classNames}>
              {map[name].content}
            </div>
          ))}
        </div>
      ))}
      {canExpand(schema, uiSchema, formData) && (
        <AddButton
          className='object-property-expand'
          onClick={onAddClick(schema)}
          disabled={disabled || readonly}
          uiSchema={uiSchema}
          registry={registry}
        />
      )}
    </fieldset>
  )
}

Because it's a template, I made it using bootstrap 3 semantics (to be used with core).

Feel free to use it as:

uiSchema = {
  'ui:ObjectFieldTemplate': LayoutFieldTemplate
}

It's built to be compatible with the rest of library.

I've also made a PR #3881 to make it possible to call whitelisted custom templates using pure JSON.

Working demo: https://codesandbox.io/s/nagaozen-react-jsonschema-form-playground-forked-29mz4d?file=/src/App.js

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature Is a feature request
Projects
None yet
Development

No branches or pull requests

6 participants