Skip to content

Commit

Permalink
feat: Add dynamic choices to Choice Prompt (#1777)
Browse files Browse the repository at this point in the history
* Update validation and choice array ui

* Updated string array

* Updated regex pattern

* Updated array styles

* Style cleanup

* Fixed string array test

* Fixed ObjectArray tests

* Updated request header object

* Small change to custom object field

* Fixed styling

* Updated dialogs

* Fix margin issue

* Updated callback dependencies

* Added error message

* Updated callback dependencies

* Fixed margin issues

* Fixed styles

* Fixed transparent border issue

* Created Dyncamic and Static Choices components

* Added Static Choices

* Added ExpressionWidget to Dynamic Choice and updated IChoice type
  • Loading branch information
tdurnford authored and a-b-r-o-w-n committed Dec 17, 2019
1 parent 7594a26 commit ce75811
Show file tree
Hide file tree
Showing 9 changed files with 363 additions and 244 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,239 +3,80 @@

/** @jsx jsx */
import { jsx } from '@emotion/core';
import React, { useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { Dropdown, ResponsiveMode } from 'office-ui-fabric-react/lib/Dropdown';
import { JSONSchema6 } from 'json-schema';
import formatMessage from 'format-message';
import { TextField } from 'office-ui-fabric-react/lib/TextField';
import { IconButton } from 'office-ui-fabric-react/lib/Button';
import { IContextualMenuItem } from 'office-ui-fabric-react/lib/ContextualMenu';
import { NeutralColors, FontSizes, SharedColors } from '@uifabric/fluent-theme';
import { IChoice } from '@bfc/shared';
import formatMessage from 'format-message';

import {
field,
choiceField,
choiceItem,
choiceItemContainer,
choiceItemLabel,
choiceItemValue,
choiceItemValueLabel,
} from '../styles';
import { swap, remove } from '../../../utils';
import { FormContext } from '../../../types';
import { WidgetLabel } from '../../../widgets/WidgetLabel';
import { EditableField } from '../../EditableField';

interface ChoiceItemProps {
index: number;
choice: IChoice;
hasMoveUp: boolean;
hasMoveDown: boolean;
onReorder: (a: number, b: number) => void;
onDelete: (idx: number) => void;
onEdit: (idx: number, value?: IChoice) => void;
}
import { StaticChoices } from './StaticChoices';
import { DynamicChoices } from './DynamicChoices';

interface ChoicesProps {
id: string;
schema: JSONSchema6;
formData?: IChoice[];
formContext: FormContext;
formData?: IChoice;
label: string;
onChange: (data: IChoice[]) => void;
onChange: (data: IChoice) => void;
}

const ChoiceItem: React.FC<ChoiceItemProps> = props => {
const { choice, index, onEdit, hasMoveUp, hasMoveDown, onReorder, onDelete } = props;
const [key, setKey] = useState(`${choice.value}:${choice.synonyms ? choice.synonyms.join() : ''}`);

const contextItems: IContextualMenuItem[] = [
{
key: 'moveUp',
text: 'Move Up',
iconProps: { iconName: 'CaretSolidUp' },
disabled: !hasMoveUp,
onClick: () => onReorder(index, index - 1),
},
{
key: 'moveDown',
text: 'Move Down',
iconProps: { iconName: 'CaretSolidDown' },
disabled: !hasMoveDown,
onClick: () => onReorder(index, index + 1),
},
{
key: 'remove',
text: 'Remove',
iconProps: { iconName: 'Cancel' },
onClick: () => onDelete(index),
export const Choices: React.FC<ChoicesProps> = props => {
const {
id,
label,
formContext,
formData,
onChange,
schema: { oneOf = [] },
} = props;
const [dynamicSchema] = oneOf;

const options = useMemo(() => oneOf.map(({ title = '' }: any) => ({ key: title.toLowerCase(), text: title })), [
oneOf,
]);
const [choiceType, setChoiceType] = useState(Array.isArray(formData || []) ? 'static' : 'dynamic');
const handleChange = useCallback(
(_, { key }) => {
onChange(choiceType !== 'static' ? [] : '');
setChoiceType(key);
},
];

const handleEdit = (field: 'value' | 'synonyms') => (_e: any, val?: string) => {
if (field === 'synonyms') {
onEdit(index, { ...choice, synonyms: val ? val.split(', ') : [] });
} else {
onEdit(index, { ...choice, value: val });
}
};

const handleBlur = () => {
setKey(`${choice.value}:${choice.synonyms ? choice.synonyms.join() : ''}`);
if (!choice.value && (!choice.synonyms || !choice.synonyms.length)) {
onDelete(index);
}
};
[choiceType, onchange, setChoiceType]
);

return (
<div css={[choiceItemContainer(), choiceItem]} key={key}>
<div css={choiceItemValue}>
<EditableField
onChange={handleEdit('value')}
value={choice.value}
styles={{
root: { margin: '7px 0 7px 0' },
}}
onBlur={handleBlur}
<React.Fragment>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '5px 0' }}>
<WidgetLabel
label={label}
description={
formatMessage('A list of options to present to the user.') +
(choiceType === 'static'
? formatMessage(" Synonyms can be used to allow for variation in a user's response.")
: '')
}
id={id}
/>
</div>
<div css={choiceItemValue}>
<EditableField
onChange={handleEdit('synonyms')}
value={choice.synonyms && choice.synonyms.join(', ')}
placeholder={formatMessage('Add multiple comma-separated synonyms')}
<Dropdown
styles={{
root: { margin: '7px 0 7px 0' },
caretDownWrapper: { height: '24px', lineHeight: '24px' },
root: { padding: '7px 0', width: '100px' },
title: { height: '24px', lineHeight: '20px' },
}}
options={{ transparentBorder: true }}
onBlur={handleBlur}
/>
</div>
<div>
<IconButton
menuProps={{ items: contextItems }}
menuIconProps={{ iconName: 'MoreVertical' }}
ariaLabel={formatMessage('Item Actions')}
styles={{ menuIcon: { color: NeutralColors.black, fontSize: FontSizes.size16 } }}
onChange={handleChange}
options={options}
selectedKey={choiceType}
responsiveMode={ResponsiveMode.large}
/>
</div>
</div>
);
};

export const Choices: React.FC<ChoicesProps> = props => {
const { onChange, formData = [], id, label } = props;
const [newChoice, setNewChoice] = useState<IChoice | null>(null);
const [errorMsg, setErrorMsg] = useState<string>('');

const handleReorder = (aIdx: number, bIdx: number) => {
onChange(swap(formData, aIdx, bIdx));
};

const handleDelete = (idx: number) => {
onChange(remove(formData, idx));
};

const handleEdit = (idx, val) => {
const choices = [...(formData || [])];
choices[idx] = val;
onChange(choices);
};

const handleNewChoiceEdit = (field: 'value' | 'synonyms') => (_e: any, data?: string) => {
if (field === 'synonyms') {
setNewChoice({ ...newChoice, synonyms: data ? data.split(', ') : [] });
} else {
setNewChoice({ ...newChoice, value: data });
}
};

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key.toLowerCase() === 'enter') {
e.preventDefault();

if (newChoice) {
if (newChoice.value) {
onChange([...formData, newChoice]);
setNewChoice(null);
setErrorMsg('');
} else {
setErrorMsg(formatMessage('value required'));
}
}
}
};

return (
<React.Fragment>
<WidgetLabel
label={label}
description={formatMessage(
"A list of options to present to the user. Synonyms can be used to allow for variation in a user's response."
)}
id={id}
/>
<div css={[choiceItemContainer('flex-start'), choiceItemLabel]}>
<div css={[choiceItemValue, choiceItemValueLabel]}>{formatMessage('Choice Name')}</div>
<div css={[choiceItemValue, choiceItemValueLabel]}>{formatMessage('Synonyms (Optional)')}</div>
</div>
<div css={choiceField}>
{formData &&
formData.map((c, i) => (
<ChoiceItem
key={`${i}-${formData.length}`}
choice={c}
index={i}
onEdit={handleEdit}
onReorder={handleReorder}
onDelete={handleDelete}
hasMoveDown={i !== formData.length - 1}
hasMoveUp={i !== 0}
/>
))}
</div>
<div css={field}>
<div css={choiceItemContainer('flex-start')} onKeyDown={handleKeyDown}>
<div css={choiceItemValue}>
<TextField
id={id}
value={newChoice ? newChoice.value : ''}
onChange={handleNewChoiceEdit('value')}
placeholder={formatMessage('Add new option here')}
autoComplete="off"
errorMessage={errorMsg}
/>
</div>
<div css={choiceItemValue}>
<TextField
id={`${id}-synonyms`}
value={newChoice ? (newChoice.synonyms || []).join(', ') : ''}
onChange={handleNewChoiceEdit('synonyms')}
placeholder={formatMessage('Add multiple comma-separated synonyms ')}
autoComplete="off"
iconProps={{
iconName: 'ReturnKey',
style: { color: SharedColors.cyanBlue10, opacity: 0.6 },
}}
/>
</div>
<div>
<IconButton
disabled={true}
menuIconProps={{ iconName: 'MoreVertical' }}
ariaLabel={formatMessage('Item Actions')}
styles={{
menuIcon: {
backgroundColor: NeutralColors.white,
color: NeutralColors.gray130,
fontSize: FontSizes.size16,
},
rootDisabled: {
backgroundColor: NeutralColors.white,
},
}}
/>
</div>
</div>
</div>
{choiceType === 'static' ? (
<StaticChoices {...props} />
) : (
<DynamicChoices {...props} formContext={formContext} schema={dynamicSchema as JSONSchema6} />
)}
</React.Fragment>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

/** @jsx jsx */
import { jsx } from '@emotion/core';
import React from 'react';
import { IChoice } from '@bfc/shared';
import { JSONSchema6 } from 'json-schema';

import { ExpressionWidget } from '../../../widgets/ExpressionWidget';
import { FormContext } from '../../../types';

interface DynamicChoicesProps {
formContext: FormContext;
formData?: any;
onChange?: (value: IChoice) => void;
schema: JSONSchema6;
}

export const DynamicChoices: React.FC<DynamicChoicesProps> = props => {
const {
formContext,
formData = '',
onChange,
schema,
schema: { description },
} = props;
return (
<ExpressionWidget
formContext={formContext}
onChange={(_, value = '') => onChange && onChange(value)}
options={{ hideLabel: true }}
placeholder={description}
value={formData}
schema={schema}
/>
);
};
Loading

0 comments on commit ce75811

Please sign in to comment.