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
7 changes: 4 additions & 3 deletions Composer/packages/adaptive-form/src/components/AddButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,14 @@ export const actionButtonStyles: IButtonStyles = {
};

type Props = {
onClick: () => void;
disabled?: boolean;
onClick: React.MouseEventHandler<unknown>;
};

export const AddButton = ({ children, onClick }: React.PropsWithChildren<Props>) => {
export const AddButton = ({ children, onClick, disabled }: React.PropsWithChildren<Props>) => {
return (
<ButtonContainer>
<ActionButton styles={actionButtonStyles} onClick={onClick}>
<ActionButton data-testid="add-button" disabled={disabled} styles={actionButtonStyles} onClick={onClick}>
{children ?? formatMessage('Add new')}
</ActionButton>
</ButtonContainer>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
import { jsx } from '@emotion/core';
import React from 'react';
import { FieldProps } from '@bfc/extension-client';
import formatMessage from 'format-message';

import { getArrayItemProps, useArrayItems } from '../../utils';
import { getArrayItemProps, isItemValueEmpty, useArrayItems } from '../../utils';
import { FieldLabel } from '../FieldLabel';
import { AddButton } from '../AddButton';

Expand Down Expand Up @@ -38,15 +39,19 @@ const ArrayField: React.FC<FieldProps<unknown[]>> = (props) => {
return <UnsupportedField {...props} />;
}

const canAddMore = !(arrayItems.length && isItemValueEmpty(arrayItems[arrayItems.length - 1]?.value));

return (
<div className={className}>
<FieldLabel description={description} helpLink={uiOptions?.helpLink} id={id} label={label} required={required} />
{arrayItems.map((element, idx) => (
<ArrayFieldItem
{...rest}
key={element.id}
ariaLabel={formatMessage('{label}: row {index}', { label, index: idx + 1 })}
autofocus={!element.value && idx === arrayItems.length - 1}
error={rawErrors[idx]}
id={id}
id={`${id}.${idx}`}
label={false}
rawErrors={rawErrors[idx]}
schema={itemSchema}
Expand All @@ -55,7 +60,7 @@ const ArrayField: React.FC<FieldProps<unknown[]>> = (props) => {
{...getArrayItemProps(arrayItems, idx, handleChange)}
/>
))}
<AddButton onClick={onClick} />
<AddButton disabled={!canAddMore} onClick={onClick} />
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Licensed under the MIT License.
/** @jsx jsx */
import { jsx } from '@emotion/core';
import React from 'react';
import React, { useEffect, useRef } from 'react';
import { FieldProps } from '@bfc/extension-client';
import { NeutralColors } from '@uifabric/fluent-theme';
import { IconButton } from 'office-ui-fabric-react/lib/Button';
Expand All @@ -22,6 +22,8 @@ interface ArrayFieldItemProps extends FieldProps {
stackArrayItems?: boolean;
onReorder: (aIdx: number) => void;
onRemove: () => void;
ariaLabel?: string;
autofocus?: boolean;
}

const ArrayFieldItem: React.FC<ArrayFieldItemProps> = (props) => {
Expand All @@ -38,6 +40,8 @@ const ArrayFieldItem: React.FC<ArrayFieldItemProps> = (props) => {
value,
className,
rawErrors,
ariaLabel,
autofocus,
...rest
} = props;

Expand Down Expand Up @@ -71,18 +75,30 @@ const ArrayFieldItem: React.FC<ArrayFieldItemProps> = (props) => {
},
];

const handleBlur = () => {
if (!value || (typeof value === 'object' && !Object.values(value).some(Boolean))) {
onRemove();
const fieldRowRootRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (autofocus && fieldRowRootRef.current) {
fieldRowRootRef.current.focus();
}
}, [autofocus]);

const handleBlur = () => {
if (typeof onBlur === 'function') {
onBlur(rest.id, value);
}
};

return (
<div className={className} css={arrayItem.container} data-testid="ArrayFieldItem">
<div
ref={fieldRowRootRef}
aria-label={ariaLabel}
className={`${className} ${arrayItem.contaInerFocus}`}
css={arrayItem.container}
data-testid="ArrayFieldItem"
role="region"
tabIndex={-1}
>
<div css={arrayItem.field}>
<SchemaField
{...rest}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,13 @@ describe('<ArrayField />', () => {
fireEvent.click(button);
await findByTestId('string-field');
});

it('can add more items unless the previous item is empty', async () => {
const { getByTestId } = renderSubject();

const button = getByTestId('add-button');
expect(button).not.toBeDisabled();
fireEvent.click(button);
expect(getByTestId('add-button')).toBeDisabled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -69,32 +69,6 @@ describe('<ArrayFieldItem />', () => {
});
});

it('removes itself on blur if there is no value', () => {
const onRemove = jest.fn();
const { container } = renderSubject({
canRemove: true,
onRemove,
value: '',
});
const field = container.querySelector('input');
// @ts-ignore
fireEvent.blur(field);
expect(onRemove).toHaveBeenCalled();
});

it('removes itself on blur if there is no value', () => {
const onRemove = jest.fn();
const { container } = renderSubject({
canRemove: true,
onRemove,
value: { foo: '' },
});
const field = container.querySelector('input');
// @ts-ignore
fireEvent.blur(field);
expect(onRemove).toHaveBeenCalled();
});

it('shows a label if the items are stacked', () => {
const { getByLabelText } = renderSubject({
schema: { type: 'object', properties: { foo: { title: 'Foo Title' } } },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { css } from '@emotion/core';
import { NeutralColors } from '@uifabric/fluent-theme';
import { FontSizes } from '@uifabric/styling';
import { getTheme, mergeStyles, getFocusStyle } from 'office-ui-fabric-react/lib/Styling';

export const arrayItem = {
container: css`
Expand All @@ -14,6 +15,12 @@ export const arrayItem = {
label: ArrayFieldItemContainer;
`,

contaInerFocus: mergeStyles(
getFocusStyle(getTheme(), {
inset: -3,
})
),

field: css`
display: flex;
flex: 1 1 0%;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { renderHook, act } from '@botframework-composer/test-utils/lib/hooks';

import { isItemValueEmpty } from '..';
import { ArrayItem, getArrayItemProps, useArrayItems } from '../arrayUtils';

describe('useArrayItems', () => {
Expand Down Expand Up @@ -121,4 +122,34 @@ describe('getArrayItemProps', () => {
expect(onChange.mock.calls[0][0]).not.toBe(value);
});
});

describe('isItemValueEmpty', () => {
it('treats falsy values as an empty value', () => {
expect(isItemValueEmpty(NaN)).toBeTruthy();
expect(isItemValueEmpty(undefined)).toBeTruthy();
expect(isItemValueEmpty(false)).toBeTruthy();
expect(isItemValueEmpty('')).toBeTruthy();
});

it('treats empty object as an empty value', () => {
expect(isItemValueEmpty({})).toBeTruthy();
});

it('treats any value as a non-empty value', () => {
expect(isItemValueEmpty('test')).toBeFalsy();
expect(isItemValueEmpty(42)).toBeFalsy();
});

it('treats any object with only falsy values as an empty value', () => {
expect(isItemValueEmpty({ foo: false, bar: undefined })).toBeTruthy();
expect(isItemValueEmpty({ foo: NaN, bar: NaN })).toBeTruthy();
expect(isItemValueEmpty({ foo: undefined })).toBeTruthy();
});

it('treats any object with at least one value as a non-empty value', () => {
expect(isItemValueEmpty({ foo: 'test', bar: undefined })).toBeFalsy();
expect(isItemValueEmpty({ foo: 42, bar: NaN })).toBeFalsy();
expect(isItemValueEmpty({ foo: 42 })).toBeFalsy();
});
});
});
7 changes: 6 additions & 1 deletion Composer/packages/adaptive-form/src/utils/arrayUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ const createArrayItem = <ItemType = unknown>(value: ItemType): ArrayItem<ItemTyp
};
};

export const isItemValueEmpty = (value?: any) =>
!value || (typeof value === 'object' && !Object.values(value).some((val) => !!val));

export const getArrayItemProps = <ItemType = unknown>(
items: ArrayItem<ItemType>[],
index: number,
Expand Down Expand Up @@ -95,7 +98,9 @@ export function useArrayItems<ItemType = unknown>(
};

const addItem = (newItem: ItemType) => {
handleChange(cache.concat(createArrayItem(newItem)));
// Allow only the last value in the cache to be empty
const newCache = isItemValueEmpty(newItem) ? cache.filter(({ value }) => !isItemValueEmpty(value)) : cache;
handleChange(newCache.concat(createArrayItem(newItem)));
};

const handleResetCache = (items: ItemType[]) => {
Expand Down