Skip to content
This repository was archived by the owner on Jul 9, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions Composer/packages/adaptive-form/src/components/CollapseField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,35 @@
/** @jsx jsx */
import { css, jsx } from '@emotion/core';
import { Fragment, useState, useEffect, useLayoutEffect, useRef } from 'react';
import { FontWeights } from 'office-ui-fabric-react/lib/Styling';
import { FontSizes, FontWeights } from 'office-ui-fabric-react/lib/Styling';
import { IconButton } from 'office-ui-fabric-react/lib/Button';
import { Label } from 'office-ui-fabric-react/lib/Label';
import { Separator } from 'office-ui-fabric-react/lib/Separator';
import { NeutralColors } from '@uifabric/fluent-theme';
import formatMessage from 'format-message';

const styles = {
description: css`
font-size: ${FontSizes.medium};
`,
transition: css`
transition: height 300ms cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
`,
header: css`
background-color: #eff6fc;
display: flex;
margin: 0 8px;
margin: 4px 0px;
align-items: center;
`,
};

interface CollapseField {
defaultExpanded?: boolean;
description?: string;
title?: string | boolean;
}

export const CollapseField: React.FC<CollapseField> = ({ children, defaultExpanded, title }) => {
export const CollapseField: React.FC<CollapseField> = ({ children, description, defaultExpanded, title }) => {
const [isOpen, setIsOpen] = useState(!!defaultExpanded);

return (
Expand All @@ -45,11 +49,15 @@ export const CollapseField: React.FC<CollapseField> = ({ children, defaultExpand
>
<IconButton
iconProps={{ iconName: isOpen ? 'ChevronDown' : 'ChevronRight' }}
styles={{ root: { color: NeutralColors.gray150 } }}
styles={{
root: { color: NeutralColors.gray150 },
rootHovered: { backgroundColor: 'transparent' },
rootFocused: { backgroundColor: 'transparent' },
}}
/>
{title && <Label styles={{ root: { fontWeight: FontWeights.semibold } }}>{title}</Label>}
{description && <span css={styles.description}>&nbsp;- {description}</span>}
</div>
<Separator styles={{ root: { height: 0 } }} />
<div>
<CollapseContent isOpen={isOpen}>{children}</CollapseContent>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ const Fieldsets: React.FC<FieldProps<object>> = (props) => {

return (
<React.Fragment>
{fieldsets.map(({ schema, uiOptions, title, defaultExpanded }, key) => (
{fieldsets.map(({ schema, uiOptions, description, title, defaultExpanded }, key) => (
<CollapseField
key={key}
defaultExpanded={defaultExpanded}
description={typeof description === 'function' ? description(value) : description}
title={typeof title === 'function' ? title(value) : title}
>
<ObjectField {...props} schema={schema} uiOptions={uiOptions} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@ import React from 'react';
import { FieldProps } from '@bfc/extension-client';
import { IPivotStyles, Pivot, PivotItem, PivotLinkSize } from 'office-ui-fabric-react/lib/components/Pivot';

import { getFieldsets } from '../../utils';
import { getFieldsets, resolveFieldWidget } from '../../utils';
import { useAdaptiveFormContext } from '../../AdaptiveFormContext';

import { ObjectField } from './ObjectField';

const styles: { tabs: Partial<IPivotStyles> } = {
tabs: {
root: {
Expand All @@ -19,6 +17,9 @@ const styles: { tabs: Partial<IPivotStyles> } = {
link: {
flex: 1,
},
itemContainer: {
paddingTop: '8px',
},
linkIsSelected: {
flex: 1,
},
Expand All @@ -39,11 +40,15 @@ const PivotFieldsets: React.FC<FieldProps<object>> = (props) => {
return (
<div>
<Pivot linkSize={PivotLinkSize.large} selectedKey={focusedTab} styles={styles.tabs} onLinkClick={handleTabChange}>
{fieldsets.map(({ schema, uiOptions, title, itemKey }) => (
<PivotItem key={itemKey} headerText={typeof title === 'function' ? title(value) : title} itemKey={itemKey}>
<ObjectField {...props} schema={schema} uiOptions={uiOptions} />
</PivotItem>
))}
{fieldsets.map(({ schema, uiOptions, title, itemKey }) => {
const Field = resolveFieldWidget(schema, uiOptions);

return (
<PivotItem key={itemKey} headerText={typeof title === 'function' ? title(value) : title} itemKey={itemKey}>
<Field {...props} schema={schema} uiOptions={uiOptions} />
</PivotItem>
);
})}
</Pivot>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,56 @@ describe('getFieldsets', () => {
]);
});

it('should updated uiOptions for nested field sets', () => {
const uiOptions: any = {
fieldsets: [
{
title: 'set1',
fields: [
{ title: 'set 1a', fields: ['one'] },
{ title: 'set 1b', fields: ['two'] },
],
},
{
title: 'set2',
fields: ['*'],
},
],
};

const result = getFieldsets(schema, uiOptions, {});

expect(result).toEqual([
expect.objectContaining({
title: 'set1',
schema: {
properties: {
one: { type: 'string' },
two: { type: 'string' },
},
},
uiOptions: expect.objectContaining({
fieldsets: [
{ title: 'set 1a', fields: ['one'] },
{ title: 'set 1b', fields: ['two'] },
],
}),
}),
expect.objectContaining({
title: 'set2',
schema: {
properties: {
three: { type: 'number' },
four: { type: 'object' },
five: { type: 'object' },
six: { type: 'object' },
seven: { type: 'boolean' },
},
},
}),
]);
});

it('should include additional fields', () => {
const uiOptions: any = {
fieldsets: [
Expand Down Expand Up @@ -174,4 +224,28 @@ describe('getFieldsets', () => {

expect(() => getFieldsets(schema, uiOptions, {})).toThrow('duplicate fields');
});

it('should throw an error for improper nested fields', () => {
const uiOptions = {
fieldsets: [
{ title: 'set1', fields: ['two', 'four', { title: 'improper' }] },
{ title: 'set2', fields: ['two', '*'] },
],
};

expect(() => getFieldsets(schema, uiOptions, {})).toThrow(
'fields must be either all strings or all fieldset objects'
);
});

it('should throw an error for multiple wildcards in nested fieldsets', () => {
const uiOptions = {
fieldsets: [
{ title: 'set1', fields: [{ title: 'improper' }] },
{ title: 'set2', fields: ['two', '*'] },
],
};

expect(() => getFieldsets(schema, uiOptions, {})).toThrow('multiple wildcards');
});
});
45 changes: 39 additions & 6 deletions Composer/packages/adaptive-form/src/utils/getFieldsets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { Fieldset, JSONSchema7, UIOptions } from '@bfc/extension-client';
import formatMessage from 'format-message';
import difference from 'lodash/difference';
import flatMap from 'lodash/flatMap';
import flatten from 'lodash/flatten';
import pick from 'lodash/pick';
import pickBy from 'lodash/pickBy';
Expand All @@ -16,14 +17,32 @@ interface FieldSetConfig extends Fieldset {
uiOptions: UIOptions;
}

const isFieldsetArray = (fields: string[] | Fieldset<string[]>[]): fields is Fieldset<string[]>[] => {
return fields.every((field) => typeof field === 'object');
};

export const getFieldsets = (baseSchema: JSONSchema7, baseUiOptions: UIOptions, value: any): FieldSetConfig[] => {
const { fieldsets: baseFieldsets = [] } = baseUiOptions;
const { properties } = baseSchema;

const schema = getSchemaWithAdditionalFields(baseSchema, baseUiOptions);
const orderedFields = flatten(getOrderedProperties(schema, baseUiOptions, value));

const fields: string[] = flatten(baseFieldsets.map(({ fields = ['*'] }) => fields));
if (
!baseFieldsets.every(
({ fields = ['*'] }) =>
fields.every((field) => typeof field === 'string') || fields.every((field) => typeof field === 'object')
)
) {
throw new Error(formatMessage('fields must be either all strings or all fieldset objects'));
}

const fields: string[] = flatMap(baseFieldsets, ({ fields = ['*'] }) => {
if (isFieldsetArray(fields)) {
return flatMap(fields, ({ fields: nestedFields = ['*'] }) => nestedFields);
}
return fields;
});
const restFields = difference(orderedFields, fields);

if (fields.filter((field) => field === '*').length > 1) {
Expand All @@ -35,16 +54,30 @@ export const getFieldsets = (baseSchema: JSONSchema7, baseUiOptions: UIOptions,
}

return baseFieldsets.map(({ fields = ['*'], ...rest }) => {
const restIdx = fields.indexOf('*');
const fieldsetArray = isFieldsetArray(fields);
const allFields = fieldsetArray
? flatMap(fields as Fieldset<string[]>[], ({ fields = ['*'] }) => fields)
: (fields as string[]);
const restIdx = allFields.indexOf('*');

if (restIdx > -1) {
fields.splice(restIdx, 1, ...restFields);
allFields.splice(restIdx, 1, ...restFields);
}

const uiOptions = pickBy({ ...baseUiOptions, order: fields, properties: pick(baseUiOptions.properties, fields) });
delete uiOptions.fieldsets;
const uiOptions = pickBy({
...baseUiOptions,
order: allFields,
properties: pick(baseUiOptions.properties, allFields),
});

if (fieldsetArray) {
uiOptions.fieldsets = fields;
delete uiOptions.pivotFieldsets;
} else {
delete uiOptions.fieldsets;
}

const schema = { ...baseSchema, properties: pick(properties, fields) } as JSONSchema7;
const schema = { ...baseSchema, properties: pick(properties, allFields) } as JSONSchema7;

return { ...rest, fields, uiOptions, schema };
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,10 @@ export function resolveFieldWidget(
if (schema.additionalProperties) {
return DefaultFields.OpenObjectField;
} else if (uiOptions?.fieldsets) {
return uiOptions.pivotFieldsets ? DefaultFields.PivotFieldsets : DefaultFields.Fieldsets;
return uiOptions.pivotFieldsets ||
uiOptions.fieldsets.some(({ fields = [] }) => fields.some((field) => typeof field !== 'string'))
? DefaultFields.PivotFieldsets
: DefaultFields.Fieldsets;
} else {
return DefaultFields.ObjectField;
}
Expand Down
5 changes: 3 additions & 2 deletions Composer/packages/extension-client/src/types/formSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ type UIOptionValue<R = string, D = any> = R | UIOptionFunc<R, D>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type UIOptionFunc<R, D> = (data: D) => R;

export interface Fieldset {
export interface Fieldset<F = string[] | Fieldset<string[]>[]> {
title: UIOptionValue<string>;
fields?: string[];
fields?: F;
defaultExpanded?: boolean;
description?: UIOptionValue<string>;
itemKey?: string;
}

Expand Down
16 changes: 9 additions & 7 deletions Composer/packages/ui-plugins/prompts/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,15 @@ const PROMPTS_ORDER = [
const createPromptFieldSet = (userAskFields: string[]) => [
{ title: PromptTabTitles[PromptTab.BOT_ASKS], itemKey: PromptTab.BOT_ASKS, fields: ['prompt'] },
{ title: PromptTabTitles[PromptTab.USER_INPUT], itemKey: PromptTab.USER_INPUT, fields: userAskFields },
{ title: PromptTabTitles[PromptTab.OTHER], itemKey: PromptTab.OTHER },
{
title: PromptTabTitles[PromptTab.OTHER],
itemKey: PromptTab.OTHER,
fields: [
{ title: () => formatMessage('Recognizers'), fields: ['recognizerOptions', 'unrecognizedPrompt'] },
{ title: () => formatMessage('Validation'), fields: ['validations', 'invalidPrompt'] },
{ title: () => formatMessage('Prompt Configurations'), fields: ['*'] },
],
},
];

const choiceSchema: UIOptions = {
Expand Down Expand Up @@ -125,7 +133,6 @@ const config: PluginConfig = {
fieldsets: createPromptFieldSet(['property', 'outputFormat', 'value']),
helpLink: 'https://aka.ms/bfc-ask-for-user-input',
order: PROMPTS_ORDER,
pivotFieldsets: true,
properties: {
prompt: {
label: () => formatMessage('Prompt for Attachment'),
Expand Down Expand Up @@ -153,7 +160,6 @@ const config: PluginConfig = {
]),
helpLink: 'https://aka.ms/bfc-ask-for-user-input',
order: PROMPTS_ORDER,
pivotFieldsets: true,
properties: {
prompt: {
label: () => formatMessage('Prompt with multi-choice'),
Expand Down Expand Up @@ -189,7 +195,6 @@ const config: PluginConfig = {
]),
helpLink: 'https://aka.ms/bfc-ask-for-user-input',
order: PROMPTS_ORDER,
pivotFieldsets: true,
properties: {
prompt: {
label: () => formatMessage('Prompt for confirmation'),
Expand All @@ -216,7 +221,6 @@ const config: PluginConfig = {
fieldsets: createPromptFieldSet(['property', 'outputFormat', 'value', 'expectedResponses']),
helpLink: 'https://aka.ms/bfc-ask-for-user-input',
order: PROMPTS_ORDER,
pivotFieldsets: true,
properties: {
prompt: {
label: () => formatMessage('Prompt for a date'),
Expand All @@ -239,7 +243,6 @@ const config: PluginConfig = {
fieldsets: createPromptFieldSet(['property', 'outputFormat', 'value', 'expectedResponses', 'defaultLocale']),
helpLink: 'https://aka.ms/bfc-ask-for-user-input',
order: PROMPTS_ORDER,
pivotFieldsets: true,
properties: {
prompt: {
label: () => formatMessage('Prompt for a number'),
Expand All @@ -262,7 +265,6 @@ const config: PluginConfig = {
fieldsets: createPromptFieldSet(['property', 'outputFormat', 'value', 'expectedResponses']),
helpLink: 'https://aka.ms/bfc-ask-for-user-input',
order: PROMPTS_ORDER,
pivotFieldsets: true,
properties: {
prompt: {
label: () => formatMessage('Prompt for text'),
Expand Down