XbyK - Grouping Content Type fields into distinct Categories
Back in K13, it was possible to create 'Category' groups for page type fields, which then grouped the fields together on the content form when editing, e.g:
Looks like this in the form:
Is achieving something like this possible in XbyX? I don't see the option to create a category in the content type editor.
I can see that an equivalent control exists within the solution as it's used when a content type implements a reusuable field schema type, e.g 'Page Base | Page Meta Info':
If something like this does not exist for users, perhaps it's possible to recreate it if we can find out what react component is used for the above screenshot?
Environment
- Xperience by Kentico version: [30.3.1]
- .NET version: [8|9]
Answers
We support categories for builder properties using UI Form Components and custom object type field definitions but not for content type field definitions (as of Xperience v30.3.0).
The reusable field schema example you shared might be using the "Text with Label" form component for the first schema field. The "Text with Label" component displays the field value as text (could be an empty string). Then you can display the label as HTML and design it however you want.
I also have some use cases where this could be valuable for content editors.
I do this with reusable field schemas, but this is not what reusable field schemas are intended for.
Having field categories can help to bundle fields or go to the categories while editing one by one without loosing focus on concerns.
Maybe you can submit this idea to the roadmap?
Thanks @Sean Wright. I had a look at the "Text with Label" component, but it didn't seem to be what I was after. Example of it rendered out:
I've submitted this to the roadmap as a feature request, but for now I've managed to achieve a similar effect with a custom form component.
Can be configured like so:
And looks like this rendered in the content tab:
Here's the relevant code files, in case it helps someone else.
FormFieldGroup.ts
export type FormFieldGroup = {
index: number;
element: HTMLElement;
}
FormFieldGrouping.scss
.cms-form-field-grouping {
.cms-form-field-grouping__fields {
overflow: hidden;
max-height: 1000px;
transition: max-height ease-in-out 0.3s;
& > div {
margin-top: var(--kxp-stack-component-spacing-top);
}
}
}
.cms-form-field-grouping--collapsed {
max-height: 0;
display: none;
}
FormFieldGroupingClientComponentProperties.cs
namespace Redacted.Admin.UIFormComponents.FormFieldGrouping;
using Kentico.Xperience.Admin.Base.Forms;
/// <summary>
/// CMS Help Text client properties, passed to react app.
/// </summary>
public class FormFieldGroupingClientComponentProperties : FormComponentClientProperties<string>
{
/// <summary>
/// Gets or sets the group name.
/// </summary>
public string GroupName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether the grouping should display as collapsed by default.
/// </summary>
public bool Collapsed { get; set; }
}
FormFieldGroupingFormComponent.cs
using Kentico.Xperience.Admin.Base.Forms;
using Redacted.Admin.UIFormComponents.FormFieldGrouping;
[assembly: RegisterFormComponent("Redacted.Admin.UIFormComponents.FormFieldGrouping.FormFieldGroupingFormComponent", typeof(FormFieldGroupingFormComponent), "Custom - Form Field Grouping")]
namespace Redacted.Admin.UIFormComponents.FormFieldGrouping;
/// <summary>
/// CMS Help Text Form Component.
/// </summary>
public class FormFieldGroupingFormComponent : FormComponent<FormFieldGroupingFormComponentProperties, FormFieldGroupingClientComponentProperties, string>
{
/// <summary>
/// Gets the form component client name.
/// </summary>
public override string ClientComponentName => "@redacted/admin/FormFieldGrouping";
/// <summary>
/// Configures form component client properties.
/// </summary>
/// <param name="clientProperties">Client properties.</param>
/// <returns>Task.</returns>
protected override async Task ConfigureClientProperties(FormFieldGroupingClientComponentProperties clientProperties)
{
// Pass props to client app
clientProperties.GroupName = this.Properties.GroupName;
clientProperties.Collapsed = this.Properties.Collapsed;
await base.ConfigureClientProperties(clientProperties);
}
}
FormFieldGroupingFormComponent.tsx
import React, { useEffect, useRef, useState } from 'react';
import './FormFieldGrouping.scss';
import { FormFieldGroup } from './FormFieldGroup';
import { FormFieldGroupingFormComponentProps } from './FormFieldGroupingFormComponentProps';
import { GroupToggle } from './group-toggle/GroupToggle';
export const FormFieldGroupingFormComponent: React.FC<FormFieldGroupingFormComponentProps> = (props) => {
const elementRef = useRef<HTMLDivElement | null>(null);
const [collapsed, setCollapsed] = useState<boolean>(props.collapsed);
const instanceSlug = `cms-form-grouping-${props.guid}`;
const handleGroupToggle = () => {
setCollapsed((prevCollapsed) => !prevCollapsed);
};
useEffect(() => {
if (elementRef.current !== null) {
setGroupVisibility(instanceSlug, collapsed, elementRef.current);
}
});
return (
<div ref={elementRef} className="cms-form-field-grouping">
<GroupToggle groupName={props.groupName} collapsed={collapsed} onClick={handleGroupToggle} />
<div className={`cms-form-field-grouping__fields ${collapsed ? 'collapsed' : ''}`} />
</div>
);
};
const setGroupVisibility = (instanceSlug: string, collapsed: boolean, rootElement?: HTMLElement): void => {
if (rootElement) {
const currentFieldContainer = rootElement.parentElement;
const rootListing = currentFieldContainer?.parentElement;
if (!currentFieldContainer || !rootListing) {
return;
}
currentFieldContainer.classList.add(instanceSlug);
const fields = rootListing.querySelectorAll(':scope > div');
if (!fields) {
return;
}
const currentIndex = getCurrentGroupIndex(currentFieldContainer, fields);
const formGroups = getFormGroups(currentIndex, fields);
let startElement: HTMLElement | undefined;
let endElement: HTMLElement | undefined;
if (formGroups.length === 2) {
startElement = formGroups[0].element;
endElement = formGroups[1].element;
} else if (formGroups.length === 1) {
startElement = formGroups[0].element;
}
setGroupedFieldsVisibility(collapsed, Array.from(fields) as HTMLElement[], startElement, endElement);
}
};
const getCurrentGroupIndex = (currentGroup: HTMLElement, allFields: NodeListOf<Element>): number => {
const fieldsArray = Array.from(allFields) as HTMLElement[];
return fieldsArray.indexOf(currentGroup);
};
const getFormGroups = (currentIndex: number, allFields: NodeListOf<Element>): Array<FormFieldGroup> => {
const groups = [] as Array<FormFieldGroup>;
for (let i = currentIndex; i < allFields.length; i++) {
let group = allFields[i] as HTMLElement;
let isGrouping = (group.querySelector('.cms-form-field-grouping') != null) as boolean;
if (isGrouping) {
groups.push({ index: i, element: group });
}
if (groups.length == 2) {
return groups;
}
}
return groups;
};
const setGroupedFieldsVisibility = (
collapsed: boolean,
childFields: HTMLElement[],
startElement: HTMLElement | undefined,
endElement?: HTMLElement | undefined
): void => {
let startIndex = -1;
let endIndex = childFields.length;
if (startElement) {
startIndex = childFields.indexOf(startElement);
}
if (endElement) {
endIndex = childFields.indexOf(endElement);
}
for (let i = startIndex + 1; i <= endIndex - 1; i++) {
const element = childFields[i];
if (!element.parentNode) {
continue;
}
if (collapsed) {
element.classList.add('cms-form-field-grouping--collapsed')
} else {
element.classList.remove('cms-form-field-grouping--collapsed')
}
}
};
FormFieldGroupingFormComponentProperties.cs
namespace Redacted.Admin.UIFormComponents.FormFieldGrouping;
using System.ComponentModel.DataAnnotations;
using Kentico.Xperience.Admin.Base.FormAnnotations;
using Kentico.Xperience.Admin.Base.Forms;
/// <summary>
/// Form Field Grouping backend form component properties.
/// </summary>
public class FormFieldGroupingFormComponentProperties : FormComponentProperties
{
/// <summary>
/// Gets or sets the group name.
/// </summary>
[Required]
[TextInputComponent(Label = "Group name", ExplanationText = "Give the group a relevant title.", Order = 1)]
public string GroupName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether the grouping should display as collapsed by default.
/// </summary>
[CheckBoxComponent(Label = "Collapsed", ExplanationText = "The group starts out in a collapsed state.", Order = 2)]
public bool Collapsed { get; set; }
}
FormFieldGroupingFormComponentProps.tsx
import { FormComponentProps } from '@kentico/xperience-admin-base';
export interface FormFieldGroupingFormComponentProps extends FormComponentProps {
groupName: string;
collapsed: boolean;
}
GroupToggle.scss
.cms-group-toggle {
display: flex;
column-gap: 8px;
& > h2 {
font-family: "GT Walsheim", sans-serif;
font-weight: 700;
font-size: 16px;
line-height: 24px;
color: var(--color-text-default-on-light);
margin: 0;
padding: 0;
}
.cms-group-toggle__btn-wrap {
display: inline-flex;
width: fit-content;
height: fit-content;
border-radius: 5000px;
cursor: pointer;
button {
cursor: pointer;
width: 24px;
height: 24px;
background: rgba(0, 0, 0, 0);
border: none;
padding: 0 4px;
border-radius: 4px;
font-family: "GT Walsheim", sans-serif;
font-weight: 400;
font-size: 12px;
line-height: 13.2px;
color: var(--color-text-default-on-light);
> div {
font-size: 16px;
}
&:hover {
background: var(--color-hover);
color: var(--color-text-default-on-light);
border: none;
}
}
}
}
GroupToggle.tsx
import React, { ReactElement } from 'react';
import './GroupToggle.scss';
import { GroupToggleProps } from './GroupToggleProps';
export const GroupToggle = (props: GroupToggleProps) => {
return (
<div className="cms-group-toggle">
<h2>{props.groupName}</h2>
<div className="cms-group-toggle__btn-wrap" onClick={props.onClick}>
<button
type="button"
aria-label="button"
data-testid="form-category-button-Page Base">
{renderChevron(props.collapsed)}
</button>
</div>
</div>
);
};
const renderChevron = (collapsed: boolean): ReactElement => {
if (collapsed) {
return <div data-testid="xp-chevron-down">
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" role="img">
<path fillRule="evenodd" clipRule="evenodd" d="M14.92 5.724a.504.504 0 0 1-.14.696l-6.5 4.996a.498.498 0 0 1-.554 0l-6.5-4.996a.504.504 0 0 1-.138-.696.499.499 0 0 1 .693-.14l6.222 4.81 6.223-4.81a.499.499 0 0 1 .693.14Z" fill="currentColor"></path>
</svg>
</div>;
}
return <div data-testid="xp-chevron-up">
<svg width="1em" height="1em" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" role="img">
<path fillRule="evenodd" clipRule="evenodd" d="M7.726 4.584a.5.5 0 0 1 .555 0l6.5 5a.5.5 0 0 1-.555.833L8.004 5.6 1.78 10.417a.5.5 0 1 1-.555-.832l6.5-5.001Z" fill="currentColor"></path>
</svg>
</div>;
}
GroupToggleProps.tsx
import { MouseEventHandler } from 'React';
export interface GroupToggleProps {
groupName: string;
collapsed: boolean;
onClick: MouseEventHandler<HTMLDivElement>;
}
To answer this question, you have to login first.