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

feat: Added skill manifest editor wizard #2815

Merged
merged 14 commits into from
Apr 29, 2020
Merged
6 changes: 6 additions & 0 deletions Composer/packages/client/src/components/ToolBar/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function ToolBar(props) {
openNewTriggerModal,
onCreateDialogComplete,
onboardingAddCoachMarkRef,
showSkillManifestModal,
...rest
} = props;
let left = [];
Expand Down Expand Up @@ -99,6 +100,11 @@ export function ToolBar(props) {
text: formatMessage('Export assets to .zip'),
onClick: () => actions.exportToZip({ projectId }),
},
{
key: 'exportAsSkill',
text: formatMessage('Export as skill'),
onClick: showSkillManifestModal,
},
],
}}
/>
Expand Down
3 changes: 3 additions & 0 deletions Composer/packages/client/src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ export enum ActionTypes {
SET_APP_UPDATE_PROGRESS = 'SET_APP_UPDATE_PROGRESS',
SET_APP_UPDATE_SHOWING = 'SET_APP_UPDATE_SHOWING',
SET_APP_UPDATE_STATUS = 'SET_APP_UPDATE_STATUS',
CREATE_SKILL_MANIFEST = 'CREATE_SKILL_MANIFEST',
UPDATE_SKILL_MANIFEST = 'UPDATE_SKILL_MANIFEST',
REMOVE_SKILL_MANIFEST = 'REMOVE_SKILL_MANIFEST',
}

export const Tips = {
Expand Down
105 changes: 101 additions & 4 deletions Composer/packages/client/src/pages/design/PropertyEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@

/** @jsx jsx */
import { jsx } from '@emotion/core';
import React from 'react';
import AdaptiveForm from '@bfc/adaptive-form';
import Extension from '@bfc/extension';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import AdaptiveForm, { resolveBaseSchema, getUISchema, mergePluginConfigs } from '@bfc/adaptive-form';
import Extension, { FormErrors } from '@bfc/extension';
import formatMessage from 'format-message';
import isEqual from 'lodash/isEqual';
import debounce from 'lodash/debounce';
import { Resizable, ResizeCallback } from 're-resizable';
import { MicrosoftAdaptiveDialog } from '@bfc/shared';

import { useShell } from '../../shell';
import plugins from '../../plugins';
Expand All @@ -16,12 +19,99 @@ import { formEditor } from './styles';

const PropertyEditor: React.FC = () => {
const { api: shellApi, data: shellData } = useShell('PropertyEditor');
const { currentDialog, data: formData, focusPath, focusedSteps, schemas } = shellData;

const currentWidth = shellData?.userSettings?.propertyEditorWidth || 400;

const handleResize: ResizeCallback = (_e, _dir, _ref, d) => {
shellApi.updateUserSettings({ propertyEditorWidth: currentWidth + d.width });
};

const [localData, setLocalData] = useState(formData as MicrosoftAdaptiveDialog);

const syncData = useRef(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
debounce((shellData: any, localData: any) => {
if (!isEqual(shellData, localData)) {
setLocalData(shellData);
}
}, 300)
).current;

useEffect(() => {
syncData(formData, localData);

return () => {
syncData.cancel();
};
}, [formData]);

const $schema = useMemo(() => {
if (schemas?.sdk?.content && localData) {
return resolveBaseSchema(schemas.sdk.content, localData);
}
}, [schemas?.sdk?.content, localData]);
tdurnford marked this conversation as resolved.
Show resolved Hide resolved

const pluginConfig = useMemo(() => {
return mergePluginConfigs(...plugins);
}, []);

const $uiSchema = useMemo(() => {
return getUISchema($schema, pluginConfig.formSchema);
}, [$schema, pluginConfig]);

const errors = useMemo(() => {
const diagnostics = currentDialog?.diagnostics;
if (diagnostics) {
const currentPath = focusPath.replace('#', '');

return diagnostics.reduce((errors, d) => {
const [dPath, dType, dProp] = d.path?.split('#') || [];
const dParts = dProp ? dProp.split(/[[\].]+/).filter(Boolean) : [];

if (dPath === currentPath && dType === localData?.$kind) {
const propErr = dParts.reverse().reduce((err, prop, idx) => {
if (idx === 0) {
return { [prop]: d.message };
} else {
return { [prop]: err };
}
}, {});

return {
...errors,
...propErr,
};
}

return errors;
}, {} as FormErrors);
}

return {};
}, [currentDialog, focusPath, localData]);

useEffect(() => {
const id = setTimeout(() => {
if (!isEqual(formData, localData)) {
shellApi.saveData(localData, focusedSteps[0]);
}
}, 300);

return () => {
clearTimeout(id);
};
}, [localData]);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleDataChange = (newData?: any) => {
setLocalData(newData);

if (!isEqual(formData, newData)) {
shellApi.saveData(newData, focusedSteps[0]);
}
};

return (
<Resizable
size={{ width: currentWidth, height: 'auto' }}
Expand All @@ -39,7 +129,14 @@ const PropertyEditor: React.FC = () => {
key={shellData.focusPath}
>
<Extension shell={shellApi} shellData={shellData} plugins={plugins}>
<AdaptiveForm formData={shellData.data} schema={shellData.schemas?.sdk?.content} />
<AdaptiveForm
errors={errors}
formData={shellData.data}
pluginConfig={pluginConfig}
schema={$schema}
uiOptions={$uiSchema}
onChange={handleDataChange}
/>
</Extension>
</div>
</Resizable>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import formatMessage from 'format-message';
import { JSONSchema7 } from '@bfc/extension';
import startCase from 'lodash/startCase';

import { Description, FetchManifestSchema, ReviewManifest, SelectManifest } from './content';

export interface SkillManifest {
content: any;
id: string;
path?: string;
lastModified?: string;
}

export interface ContentProps {
completeStep: () => void;
errors: { [key: string]: any };
setErrors: (erros: { [key: string]: any }) => void;
setSchema: (_: JSONSchema7) => void;
setSkillManifest: (name: string) => void;
schema: JSONSchema7;
skillManifests: SkillManifest[];
value: { [key: string]: any };
onChange: (_: any) => void;
}

interface Button {
disabled?: ((_: any) => boolean) | boolean;
primary?: boolean;
text: string;
onClick: (_: any) => () => void;
}

interface EditorStep {
buttons?: Button[];
editJson?: boolean;
helpLink?: string;
title: string;
subText?: any;
content: React.FC<ContentProps>;
validate?: (value: any, schema: any) => { [key: string]: any };
}

export enum ManifestEditorSteps {
FETCH_MANIFEST_SCHEMA = 'FETCH_MANIFEST_SCHEMA',
MANIFEST_DESCRIPTION = 'MANIFEST_DESCRIPTION',
MANIFEST_REVIEW = 'MANIFEST_REVIEW',
SELECT_MANIFEST = 'SELECT_MANIFEST',
}

export const order: ManifestEditorSteps[] = [
ManifestEditorSteps.SELECT_MANIFEST,
ManifestEditorSteps.FETCH_MANIFEST_SCHEMA,
ManifestEditorSteps.MANIFEST_DESCRIPTION,
ManifestEditorSteps.MANIFEST_REVIEW,
];

const cancelButton: Button = {
text: formatMessage('Cancel'),
onClick: ({ onDismiss }) => onDismiss,
};

const nextButton: Button = {
primary: true,
text: formatMessage('Next'),
onClick: ({ onNext }) => onNext,
};

export const editorSteps: { [key in ManifestEditorSteps]: EditorStep } = {
[ManifestEditorSteps.SELECT_MANIFEST]: {
buttons: [
cancelButton,
{
disabled: ({ manifest }) => !manifest,
primary: true,
text: formatMessage('Edit'),
onClick: ({ onNext }) => onNext,
},
],
content: SelectManifest,
editJson: false,
subText: formatMessage('Create a new skill manifest or select which one you want to edit'),
title: formatMessage('Create or edit skill manifest'),
},
[ManifestEditorSteps.FETCH_MANIFEST_SCHEMA]: {
content: FetchManifestSchema,
editJson: false,
title: formatMessage('Select manifest version'),
},
[ManifestEditorSteps.MANIFEST_DESCRIPTION]: {
buttons: [cancelButton, nextButton],
content: Description,
editJson: true,
title: formatMessage('Describe your skill'),
subText: formatMessage('To make your bot available for others as a skill, we need to generate a manifest.'),
validate: (value, schema) => {
const required = schema?.required || [];

return required
.filter(key => {
const property = schema?.properties?.[key];
return property && !['array', 'object'].includes(property.type);
})
.reduce((acc, key) => {
if (!value?.[key]) {
return { ...acc, [key]: formatMessage('Please enter a value for {key}', { key: startCase(key) }) };
}
return acc;
}, {});
},
},
[ManifestEditorSteps.MANIFEST_REVIEW]: {
buttons: [
cancelButton,
{
primary: true,
text: formatMessage('Done'),
onClick: ({ onDismiss }) => onDismiss,
},
],
content: ReviewManifest,
subText: formatMessage('The manifest can be edited and refined manually if and where needed.'),
title: formatMessage('Review and generate'),
},
};
tdurnford marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

/** @jsx jsx */
import { css, jsx } from '@emotion/core';
import React, { useContext, useMemo, useEffect } from 'react';
import AdaptiveForm, { FieldLabel } from '@bfc/adaptive-form';
import { FieldProps, JSONSchema7, UIOptions } from '@bfc/extension';
import { TextField } from 'office-ui-fabric-react/lib/TextField';

import { StoreContext } from '../../../../store';

const styles = {
row: css`
display: flex;
justify-content: space-between;
width: 75%;
margin: -7px 0;
`,
field: css`
flex-basis: 350px;
`,
};

const InlineLabelField: React.FC<FieldProps> = props => {
const { id, placeholder, rawErrors, value = '', onChange } = props;

const handleChange = (e: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>, newValue?: string) => {
onChange(newValue);
};

return (
<div css={styles.row}>
<div>
<FieldLabel {...props} />
</div>
<div css={styles.field}>
<TextField
errorMessage={rawErrors as string}
id={id}
placeholder={placeholder}
value={value}
onChange={handleChange}
/>
</div>
</div>
);
};

export const Description = ({ errors, value, schema, onChange }) => {
const { state } = useContext(StoreContext);
const { botName } = state;

const { hidden, properties } = useMemo(
() =>
Object.entries(schema?.properties as JSONSchema7).reduce(
({ hidden, properties }, [key, property]) => {
if (property.type === 'object' || (property.type === 'array' && property?.items?.type !== 'string')) {
return { hidden: [...hidden, key], properties };
}

const itemSchema = property?.items as JSONSchema7;
const serializer =
itemSchema && itemSchema?.type === 'string'
? {
get: value => (Array.isArray(value) ? value.join(', ') : value),
set: value => (typeof value === 'string' ? value.split(', ') : value),
}
: null;

return {
hidden,
properties: { ...properties, [key]: { field: InlineLabelField, hideError: true, serializer } },
};
},
{ hidden: [], properties: {} } as any
),
[]
);

useEffect(() => {
onChange({ $id: botName, name: botName, ...value });
}, []);

const required = schema?.required || [];

const uiOptions: UIOptions = {
hidden,
label: false,
order: [...required, '*'],
properties,
};

return <AdaptiveForm formData={value} errors={errors} schema={schema} onChange={onChange} uiOptions={uiOptions} />;
};
Loading