diff --git a/Composer/cypress/integration/LGPage.spec.ts b/Composer/cypress/integration/LGPage.spec.ts index 86dfc94b0e..7f935451ea 100644 --- a/Composer/cypress/integration/LGPage.spec.ts +++ b/Composer/cypress/integration/LGPage.spec.ts @@ -13,7 +13,7 @@ context('LG Page', () => { cy.contains('TodoSample'); cy.contains('All'); - cy.get('.toggleEditMode button').as('switchButton'); + cy.findByTestId('showcode').as('switchButton'); // by default is table view cy.findByTestId('LGPage').findByTestId('table-view').should('exist'); diff --git a/Composer/cypress/integration/LUPage.spec.ts b/Composer/cypress/integration/LUPage.spec.ts index 1b05322669..05704f40a1 100644 --- a/Composer/cypress/integration/LUPage.spec.ts +++ b/Composer/cypress/integration/LUPage.spec.ts @@ -13,7 +13,7 @@ context('LU Page', () => { cy.contains('__TestToDoBotWithLuisSample'); cy.contains('All'); - cy.get('.toggleEditMode button').should('not.exist'); + cy.findByTestId('showcode').should('not.exist'); // by default it goes to table view cy.findByTestId('LUPage').findByTestId('table-view').should('exist'); @@ -25,7 +25,7 @@ context('LU Page', () => { cy.findByTestId('ProjectTree').within(() => { cy.findByText('__TestToDoBotWithLuisSample').click('left'); }); - cy.get('.toggleEditMode button').as('switchButton'); + cy.findByTestId('showcode').as('switchButton'); // goto edit-mode cy.get('@switchButton').click(); cy.findByTestId('LUPage').get('.monaco-editor').should('exist'); diff --git a/Composer/cypress/integration/NotificationPage.spec.ts b/Composer/cypress/integration/NotificationPage.spec.ts index e1c31b72c7..a2c467fc5b 100644 --- a/Composer/cypress/integration/NotificationPage.spec.ts +++ b/Composer/cypress/integration/NotificationPage.spec.ts @@ -9,8 +9,7 @@ context('Notification Page', () => { it('can show lg syntax error ', () => { cy.visitPage('Bot Responses'); - cy.get('.toggleEditMode button').as('switchButton'); - cy.get('@switchButton').click(); + cy.findByTestId('showcode').click(); cy.get('textarea').type('#', { delay: 200 }); cy.findByTestId('LeftNav-CommandBarButtonNotifications').click(); @@ -29,7 +28,7 @@ context('Notification Page', () => { cy.findByText('__TestToDoBotWithLuisSample').click(); }); - cy.get('.toggleEditMode button').click(); + cy.findByTestId('showcode').click(); cy.get('textarea').type('t', { delay: 200 }); cy.findByTestId('LeftNav-CommandBarButtonNotifications').click(); diff --git a/Composer/packages/client/__tests__/components/createQnAModal.test.tsx b/Composer/packages/client/__tests__/components/createQnAModal.test.tsx new file mode 100644 index 0000000000..702515fc5b --- /dev/null +++ b/Composer/packages/client/__tests__/components/createQnAModal.test.tsx @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React from 'react'; +import { fireEvent } from '@bfc/test-utils'; + +import { renderWithRecoil } from '../testUtils/renderWithRecoil'; +import CreateQnAFromUrlModal from '../../src/components/QnA/CreateQnAFromUrlModal'; +import { showCreateQnAFromUrlDialogState, showCreateQnAFromUrlDialogWithScratchState } from '../../src/recoilModel'; + +describe('', () => { + const onDismiss = jest.fn(() => {}); + const onSubmit = jest.fn(() => {}); + const projectId = 'test-create-qna'; + + it('renders and create from scratch', () => { + const container = renderWithRecoil( + , + ({ set }) => { + set(showCreateQnAFromUrlDialogState(projectId), true); + set(showCreateQnAFromUrlDialogWithScratchState(projectId), true); + } + ); + + const { getByTestId } = container; + const createFromScratchButton = getByTestId('createKnowledgeBaseFromScratch'); + expect(createFromScratchButton).not.toBeNull(); + fireEvent.click(createFromScratchButton); + // actions tobe called + }); + + it('create with name/url and validate the value', () => { + const container = renderWithRecoil( + , + () => {} + ); + + const { findByText, getByTestId } = container; + const inputName = getByTestId('knowledgeLocationTextField-name') as HTMLInputElement; + fireEvent.change(inputName, { target: { value: 'test' } }); + + const inputUrl = getByTestId('knowledgeLocationTextField-url') as HTMLInputElement; + fireEvent.change(inputUrl, { target: { value: 'test' } }); + + expect(inputUrl.value).toBe('test'); + expect(findByText(/A valid url should start with/)).not.toBeNull(); + fireEvent.change(inputUrl, { target: { value: 'http://test' } }); + + const createKnowledgeButton = getByTestId('createKnowledgeBase'); + expect(createKnowledgeButton).not.toBeNull(); + fireEvent.click(createKnowledgeButton); + expect(onSubmit).toBeCalled(); + expect(onSubmit).toBeCalledWith({ url: 'http://test', name: 'test', multiTurn: false }); + }); +}); diff --git a/Composer/packages/client/__tests__/pages/knowledge-base/QnAPage.test.tsx b/Composer/packages/client/__tests__/pages/knowledge-base/QnAPage.test.tsx index fcb4d7d0b2..62946c4246 100644 --- a/Composer/packages/client/__tests__/pages/knowledge-base/QnAPage.test.tsx +++ b/Composer/packages/client/__tests__/pages/knowledge-base/QnAPage.test.tsx @@ -3,6 +3,7 @@ /* eslint-disable react-hooks/rules-of-hooks */ import React from 'react'; +import QnAPage from '../../../src/pages/knowledge-base/QnAPage'; import TableView from '../../../src/pages/knowledge-base/table-view'; import CodeEditor from '../../../src/pages/knowledge-base/code-editor'; import { renderWithRecoil } from '../../testUtils'; @@ -26,12 +27,16 @@ answer const state = { projectId: 'test', - dialogs: [{ id: '1' }, { id: '2' }], + dialogs: [ + { id: '1', content: '', skills: [] }, + { id: '2', content: '', skills: [] }, + ], locale: 'en-us', qnaFiles: [ { id: 'a.en-us', content: initialContent, + imports: [], qnaSections: [ { Questions: [{ content: 'question', id: 1 }], @@ -63,16 +68,20 @@ const initRecoilState = ({ set }) => { describe('QnA page all up view', () => { it('should render QnA page table view', () => { - const { getByText, getByTestId } = renderWithRecoil( + const { getByTestId, getByText } = renderWithRecoil( , initRecoilState ); getByTestId('table-view'); - getByText('question (1)'); - getByText('answer'); + getByText('Question'); }); it('should render QnA page code editor', () => { renderWithRecoil(, initRecoilState); }); + + it('should render QnA page', () => { + const { getByTestId } = renderWithRecoil(, initRecoilState); + getByTestId('QnAPage'); + }); }); diff --git a/Composer/packages/client/__tests__/pages/language-generation/LGPage.test.tsx b/Composer/packages/client/__tests__/pages/language-generation/LGPage.test.tsx index 7ee7ac4363..81a976f4d7 100644 --- a/Composer/packages/client/__tests__/pages/language-generation/LGPage.test.tsx +++ b/Composer/packages/client/__tests__/pages/language-generation/LGPage.test.tsx @@ -4,11 +4,11 @@ import React from 'react'; import { renderWithRecoil } from '../../testUtils'; +import LGPage from '../../../src/pages/language-generation/LGPage'; import TableView from '../../../src/pages/language-generation/table-view'; import CodeEditor from '../../../src/pages/language-generation/code-editor'; import { localeState, - luFilesState, lgFilesState, settingsState, schemasState, @@ -31,16 +31,16 @@ const initialTemplates = [ const state = { projectId: 'test', - dialogs: [{ id: '1' }, { id: '2' }], + dialogs: [ + { id: '1', content: '', diagnostics: [], skills: [] }, + { id: '2', content: '', diagnostics: [], skills: [] }, + ], locale: 'en-us', lgFiles: [ - { id: 'a.en-us', content: initialContent, templates: initialTemplates }, - { id: 'a.fr-fr', content: initialContent, templates: initialTemplates }, - ], - luFiles: [ - { id: 'a.en-us', content: initialContent, templates: initialTemplates }, - { id: 'a.fr-fr', content: initialContent, templates: initialTemplates }, + { id: 'a.en-us', content: initialContent, templates: initialTemplates, diagnostics: [] }, + { id: 'a.fr-fr', content: initialContent, templates: initialTemplates, diagnostics: [] }, ], + settings: { defaultLanguage: 'en-us', languages: ['en-us', 'fr-fr'], @@ -51,7 +51,6 @@ const initRecoilState = ({ set }) => { set(currentProjectIdState, state.projectId); set(localeState(state.projectId), state.locale); set(dialogsState(state.projectId), state.dialogs); - set(luFilesState(state.projectId), state.luFiles); set(lgFilesState(state.projectId), state.lgFiles); set(settingsState(state.projectId), state.settings); set(schemasState(state.projectId), mockProjectResponse.schemas); @@ -70,4 +69,9 @@ describe('LG page all up view', () => { it('should render lg page code editor', () => { renderWithRecoil(, initRecoilState); }); + + it('should render lg page', () => { + const { getByTestId } = renderWithRecoil(, initRecoilState); + getByTestId('LGPage'); + }); }); diff --git a/Composer/packages/client/__tests__/pages/language-understanding/LUPage.test.tsx b/Composer/packages/client/__tests__/pages/language-understanding/LUPage.test.tsx index bd0b29be46..8c7927ddac 100644 --- a/Composer/packages/client/__tests__/pages/language-understanding/LUPage.test.tsx +++ b/Composer/packages/client/__tests__/pages/language-understanding/LUPage.test.tsx @@ -3,6 +3,7 @@ import React from 'react'; +import LUPage from '../../../src/pages/language-understanding/LUPage'; import TableView from '../../../src/pages/language-understanding/table-view'; import CodeEditor from '../../../src/pages/language-understanding/code-editor'; import { renderWithRecoil } from '../../testUtils'; @@ -10,7 +11,6 @@ import { localeState, dialogsState, luFilesState, - lgFilesState, settingsState, schemasState, currentProjectIdState, @@ -22,17 +22,23 @@ const initialContent = ` - hello `; +const initialIntents = [ + { + Name: 'Greeting', + Body: '- hello', + }, +]; + const state = { projectId: 'test', - dialogs: [{ id: '1' }, { id: '2' }], - locale: 'en-us', - lgFiles: [ - { id: 'a.en-us', content: initialContent }, - { id: 'a.fr-fr', content: initialContent }, + dialogs: [ + { id: '1', content: '', skills: [] }, + { id: '2', content: '', skills: [] }, ], + locale: 'en-us', luFiles: [ - { id: 'a.en-us', content: initialContent }, - { id: 'a.fr-fr', content: initialContent }, + { id: 'a.en-us', content: initialContent, templates: initialIntents, diagnostics: [] }, + { id: 'a.fr-fr', content: initialContent, templates: initialIntents, diagnostics: [] }, ], settings: { defaultLanguage: 'en-us', @@ -45,7 +51,6 @@ const initRecoilState = ({ set }) => { set(localeState(state.projectId), state.locale); set(dialogsState(state.projectId), state.dialogs); set(luFilesState(state.projectId), state.luFiles); - set(lgFilesState(state.projectId), state.lgFiles); set(settingsState(state.projectId), state.settings); set(schemasState(state.projectId), mockProjectResponse.schemas); }; @@ -63,4 +68,8 @@ describe('LU page all up view', () => { it('should render lu page code editor', () => { renderWithRecoil(, initRecoilState); }); + + it('should render lu page', () => { + renderWithRecoil(, initRecoilState); + }); }); diff --git a/Composer/packages/client/__tests__/utils/qnaUtil.test.ts b/Composer/packages/client/__tests__/utils/qnaUtil.test.ts deleted file mode 100644 index db964cc45b..0000000000 --- a/Composer/packages/client/__tests__/utils/qnaUtil.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { qnaIndexer } from '@bfc/indexers/src/qnaIndexer'; - -import { - addSection, - updateSection, - removeSection, - insertSection, - addQuestion, - updateQuestion, - updateAnswer, -} from '../../src/utils/qnaUtil'; - -describe('qna utils', () => { - const qnaPair1 = ` -# ?Question1 -\`\`\` -Answer1 -\`\`\``; - const qnaPair2 = ` -# ?Question2 -\`\`\` -Answer2 -\`\`\``; - - it('should add QnA Section', () => { - const newContent = addSection(qnaPair1, qnaPair2); - const res = qnaIndexer.parse(newContent); - const qnaSections = res.qnaSections; - expect(qnaSections.length).toBe(2); - }); - - it('should update QnA Section', () => { - const newContent = updateSection(0, qnaPair1, qnaPair2); - const res = qnaIndexer.parse(newContent); - const qnaSections = res.qnaSections; - expect(qnaSections.length).toBe(1); - expect(qnaSections[0].Questions[0].content).toBe('Question2'); - expect(qnaSections[0].Answer).toBe('Answer2'); - }); - - it('should remove QnA Section', () => { - const content = qnaPair1 + '\n' + qnaPair2; - const newContent = removeSection(0, content); - const res = qnaIndexer.parse(newContent); - const qnaSections = res.qnaSections; - expect(qnaSections.length).toBe(1); - expect(qnaSections[0].Questions[0].content).toBe('Question2'); - expect(qnaSections[0].Answer).toBe('Answer2'); - }); - - it('should insert QnA Section at the beginning', () => { - const newContent = insertSection(0, qnaPair1, qnaPair2); - const res = qnaIndexer.parse(newContent); - const qnaSections = res.qnaSections; - expect(qnaSections.length).toBe(2); - expect(qnaSections[0].Questions[0].content).toBe('Question2'); - expect(qnaSections[0].Answer).toBe('Answer2'); - }); - - it('should add a new Question to QnA Sectionn', () => { - const qnaSections = qnaIndexer.parse(qnaPair1).qnaSections; - const newContent = addQuestion('Question2', qnaSections, 0); - const newQnaSections = qnaIndexer.parse(newContent).qnaSections; - expect(newQnaSections.length).toBe(1); - expect(newQnaSections[0].Questions[0].content).toBe('Question1'); - expect(newQnaSections[0].Questions[1].content).toBe('Question2'); - expect(newQnaSections[0].Answer).toBe('Answer1'); - }); - - it('should update Question to QnA Sectionn', () => { - const qnaSections = qnaIndexer.parse(qnaPair1).qnaSections; - const newContent = updateQuestion('Question2', 0, qnaSections, 0); - const newQnaSections = qnaIndexer.parse(newContent).qnaSections; - expect(newQnaSections.length).toBe(1); - expect(newQnaSections[0].Questions.length).toBe(1); - expect(newQnaSections[0].Questions[0].content).toBe('Question2'); - expect(newQnaSections[0].Answer).toBe('Answer1'); - }); - - it('should update Answer to QnA Sectionn', () => { - const qnaSections = qnaIndexer.parse(qnaPair1).qnaSections; - const newContent = updateAnswer('Answer2', qnaSections, 0); - const newQnaSections = qnaIndexer.parse(newContent).qnaSections; - expect(newQnaSections.length).toBe(1); - expect(newQnaSections[0].Questions.length).toBe(1); - expect(newQnaSections[0].Questions[0].content).toBe('Question1'); - expect(newQnaSections[0].Answer).toBe('Answer2'); - }); -}); diff --git a/Composer/packages/client/src/components/CreationFlow/CreationFlow.tsx b/Composer/packages/client/src/components/CreationFlow/CreationFlow.tsx index fcb7a4f6ac..7a8bba7b38 100644 --- a/Composer/packages/client/src/components/CreationFlow/CreationFlow.tsx +++ b/Composer/packages/client/src/components/CreationFlow/CreationFlow.tsx @@ -21,8 +21,6 @@ import { userSettingsState, } from '../../recoilModel'; import Home from '../../pages/home/Home'; -import ImportQnAFromUrlModal from '../../pages/knowledge-base/ImportQnAFromUrlModal'; -import { QnABotTemplateId } from '../../constants'; import { useProjectIdCache } from '../../utils/hooks'; import { useShell } from '../../shell'; import plugins from '../../plugins'; @@ -130,12 +128,6 @@ const CreationFlow: React.FC = () => { saveProjectAs(projectId, formData.name, formData.description, formData.location); }; - const handleCreateQnA = async (urls: string[]) => { - saveTemplateId(QnABotTemplateId); - handleDismiss(); - handleCreateNew(formData, QnABotTemplateId, urls); - }; - const handleDefineConversationSubmit = async (formData, templateId: string) => { // If selected template is qnaSample then route to QNA import modal if (templateId === 'QnASample') { @@ -207,12 +199,6 @@ const CreationFlow: React.FC = () => { onDismiss={handleDismiss} onOpen={openBot} /> - css` + display: flex; + width: 100%; + outline: ${hasErrors + ? `2px solid ${SharedColors.red10}` + : hasFocus + ? `2px solid ${SharedColors.cyanBlue10}` + : undefined}; + background: ${hasFocus || hasErrors ? NeutralColors.white : 'inherit'}; + margin-top: 2px; + :hover .ms-Button-icon { + visibility: visible; + } + .ms-TextField-field { + cursor: pointer; + padding-left: ${hasFocus || hasErrors ? '8px' : '0px'}; + :focus { + cursor: inherit; + } + } +`; + +// turncat to show two line. +const maxCharacterNumbers = 120; + +//------------------------ +type IconProps = { + iconStyles?: Partial; + iconName: string; + onClick?: () => void; +}; interface EditableFieldProps extends Omit { + expanded?: boolean; + componentFocusOnmount?: boolean; fontSize?: string; styles?: Partial; transparentBorder?: boolean; ariaLabel?: string; error?: string | JSX.Element; - + extraContent?: string; + containerStyles?: SerializedStyles; className?: string; depth: number; description?: string; disabled?: boolean; + resizable?: boolean; + requiredMessage?: string; id: string; - name: string; + name?: string; placeholder?: string; readonly?: boolean; required?: boolean; value?: string; - - onChange: (newValue?: string) => void; - onFocus?: (id: string, value?: string) => void; + iconProps?: IconProps; + enableIcon?: boolean; onBlur?: (id: string, value?: string) => void; + onChange: (newValue?: string) => void; + onFocus?: () => void; } const EditableField: React.FC = (props) => { const { + componentFocusOnmount = false, + containerStyles, depth, + required, + requiredMessage, + extraContent = '', styles = {}, + iconProps, placeholder, fontSize, - multiline = false, + expanded = false, onChange, + onFocus, onBlur, value, id, @@ -44,20 +94,56 @@ const EditableField: React.FC = (props) => { className, transparentBorder, ariaLabel, + enableIcon = false, } = props; const [editing, setEditing] = useState(false); const [hasFocus, setHasFocus] = useState(false); - const [localValue, setLocalValue] = useState(value); const [hasBeenEdited, setHasBeenEdited] = useState(false); + const [multiline, setMultiline] = useState(true); + const formConfig: FieldConfig<{ value: string }> = { + value: { + required: required, + defaultValue: value, + }, + }; + const { formData, updateField, hasErrors, formErrors } = useForm(formConfig); + + const fieldRef = useRef(null); + useEffect(() => { + if (componentFocusOnmount) { + fieldRef.current?.focus(); + } + }, []); useEffect(() => { - if (!hasBeenEdited || value !== localValue) { - setLocalValue(value); + if (!hasBeenEdited || value !== formData.value) { + updateField('value', value); } }, [value]); + useEffect(() => { + if (formData.value.length > maxCharacterNumbers) { + setMultiline(true); + return; + } + + if (expanded || hasFocus) { + if (formData.value.length > maxCharacterNumbers) { + setMultiline(true); + } + } + setMultiline(false); + }, [expanded, hasFocus]); + + const resetValue = () => { + updateField('value', ''); + setHasBeenEdited(true); + fieldRef.current?.focus(); + }; + const handleChange = (_e: any, newValue?: string) => { - setLocalValue(newValue); + if (newValue && newValue?.length > maxCharacterNumbers) setMultiline(true); + updateField('value', newValue); setHasBeenEdited(true); onChange(newValue); }; @@ -65,58 +151,131 @@ const EditableField: React.FC = (props) => { const handleCommit = () => { setHasFocus(false); setEditing(false); - onBlur && onBlur(id, localValue); + + // update view after resetValue + if (!formData.value) { + updateField('value', value); + } + onBlur && onBlur(id, formData.value); + }; + + const handleOnFocus = () => { + setHasFocus(true); + onFocus && onFocus(); + }; + + const cancel = () => { + setHasFocus(false); + setEditing(false); + updateField('value', value); + fieldRef.current?.blur(); + }; + + const handleOnKeyDown = (e) => { + if (e.key === 'Enter' && expanded) { + handleCommit(); + } + if (e.key === 'Escape') { + cancel(); + } }; let borderColor: string | undefined = undefined; if (!editing && !error) { - borderColor = localValue || transparentBorder || depth > 1 ? 'transparent' : NeutralColors.gray30; + borderColor = formData.value || transparentBorder || depth > 1 ? 'transparent' : NeutralColors.gray30; } + const hasEditingErrors = hasErrors && hasBeenEdited; + return ( -
setEditing(true)} - onMouseLeave={() => !hasFocus && setEditing(false)} - > - +
+ + } + value={ + hasFocus || !extraContent || expanded + ? formData.value + : `${ + formData.value.length > maxCharacterNumbers + ? formData.value.substring(0, maxCharacterNumbers) + '...' + : formData.value + } ${extraContent}` + } + onBlur={handleCommit} + onChange={handleChange} + onFocus={handleOnFocus} + onKeyDown={handleOnKeyDown} + onMouseEnter={() => setEditing(true)} + onMouseLeave={() => !hasFocus && setEditing(false)} + /> + {enableIcon && ( + - } - value={localValue} - onBlur={handleCommit} - onChange={handleChange} - onFocus={() => setHasFocus(true)} - /> -
+ }} + onClick={iconProps?.onClick || resetValue} + /> + )} +
+ {hasErrors && hasBeenEdited && ( + {requiredMessage || formErrors.value} + )} + {error && {error}} + ); }; diff --git a/Composer/packages/client/src/components/NavTree.tsx b/Composer/packages/client/src/components/NavTree.tsx index 6bf12de255..26b16ab413 100644 --- a/Composer/packages/client/src/components/NavTree.tsx +++ b/Composer/packages/client/src/components/NavTree.tsx @@ -3,11 +3,14 @@ /** @jsx jsx */ import { jsx, css } from '@emotion/core'; -import { DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { DefaultButton, CommandBarButton, IButtonStyles } from 'office-ui-fabric-react/lib/Button'; import { FontWeights, FontSizes } from 'office-ui-fabric-react/lib/Styling'; -import { NeutralColors } from '@uifabric/fluent-theme'; -import { IButtonStyles } from 'office-ui-fabric-react/lib/Button'; +import { IContextualMenuItem } from 'office-ui-fabric-react/lib/ContextualMenu'; +import { OverflowSet, IOverflowSetItemProps } from 'office-ui-fabric-react/lib/OverflowSet'; import { mergeStyleSets } from 'office-ui-fabric-react/lib/Styling'; +import { NeutralColors, SharedColors } from '@uifabric/fluent-theme'; +import { IIconProps } from 'office-ui-fabric-react/lib/Icon'; +import formatMessage from 'format-message'; import { navigateTo } from '../utils/navigation'; @@ -22,6 +25,18 @@ const root = css` .ms-List-cell { min-height: 36px; } + .ProjectTreeItem { + display: flex; + .ms-Icon { + color: ${SharedColors.blue10}; + } + &:hover .ms-Button { + background: ${NeutralColors.gray20}; + .ms-Icon { + visibility: inherit; + } + } + } `; const itemBase: IButtonStyles = { @@ -61,6 +76,8 @@ export interface INavTreeItem { name: string; ariaLabel?: string; url: string; + menuItems?: IContextualMenuItem[]; + menuIconProps?: IIconProps; disabled?: boolean; } @@ -72,23 +89,63 @@ interface INavTreeProps { const NavTree: React.FC = (props) => { const { navLinks, regionName } = props; + const onRenderOverflowButton = (isSelected: boolean, item) => ( + menuItems: IOverflowSetItemProps[] | undefined + ): JSX.Element => { + const buttonStyles: Partial = { + root: { + minWidth: 0, + padding: '0 4px', + alignSelf: 'stretch', + height: 'auto', + background: isSelected ? NeutralColors.gray20 : NeutralColors.white, + selectors: { + '.ms-Icon': { + visibility: isSelected ? 'inherit' : 'hidden', + }, + }, + }, + }; + return ( + + ); + }; + return (
{navLinks.map((item) => { const isSelected = location.pathname.includes(item.url); return ( - { - e.preventDefault(); - navigateTo(item.url); - }} - /> +
+ { + e.preventDefault(); + navigateTo(item.url); + }} + /> + {item.menuItems && !item.disabled && ( + undefined} + onRenderOverflowButton={onRenderOverflowButton(isSelected, item)} + /> + )} +
); })}
diff --git a/Composer/packages/client/src/components/QnA/CreateQnAFrom.tsx b/Composer/packages/client/src/components/QnA/CreateQnAFrom.tsx new file mode 100644 index 0000000000..42498906fd --- /dev/null +++ b/Composer/packages/client/src/components/QnA/CreateQnAFrom.tsx @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React from 'react'; +import { useRecoilValue } from 'recoil'; + +import { showCreateQnAFromScratchDialogState, showCreateQnAFromUrlDialogState } from '../../recoilModel'; + +import CreateQnAFromScratchModal from './CreateQnAFromScratchModal'; +import CreateQnAFromUrlModal from './CreateQnAFromUrlModal'; +import { CreateQnAFromModalProps } from './constants'; + +export const CreateQnAModal: React.FC = (props) => { + const { projectId } = props; + const showCreateQnAFromScratchDialog = useRecoilValue(showCreateQnAFromScratchDialogState(projectId)); + const showCreateQnAFromUrlDialog = useRecoilValue(showCreateQnAFromUrlDialogState(projectId)); + + if (showCreateQnAFromScratchDialog) { + return ; + } else if (showCreateQnAFromUrlDialog) { + return ; + } else { + return null; + } +}; + +export default CreateQnAModal; diff --git a/Composer/packages/client/src/components/QnA/CreateQnAFromScratchModal.tsx b/Composer/packages/client/src/components/QnA/CreateQnAFromScratchModal.tsx new file mode 100644 index 0000000000..0885febe59 --- /dev/null +++ b/Composer/packages/client/src/components/QnA/CreateQnAFromScratchModal.tsx @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React from 'react'; +import { useRecoilValue } from 'recoil'; +import formatMessage from 'format-message'; +import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; + +import { FieldConfig, useForm } from '../../hooks/useForm'; +import { dispatcherState, showCreateQnAFromUrlDialogState } from '../../recoilModel'; + +import { validateName, CreateQnAFromModalProps, CreateQnAFromScratchFormData } from './constants'; +import { subText, styles, dialogWindowMini, textField } from './styles'; + +const formConfig: FieldConfig = { + name: { + required: true, + defaultValue: '', + }, +}; + +const DialogTitle = () => { + return ( +
+ {formatMessage('Create new knowledge base from scratch')} +

+ {formatMessage('Manually add question and answer pairs to create a KB')} +

+
+ ); +}; + +export const CreateQnAFromScratchModal: React.FC = (props) => { + const { onDismiss, onSubmit, qnaFiles, projectId } = props; + const actions = useRecoilValue(dispatcherState); + const showCreateQnAFromUrlDialog = useRecoilValue(showCreateQnAFromUrlDialogState(projectId)); + + formConfig.name.validate = validateName(qnaFiles); + const { formData, updateField, hasErrors, formErrors } = useForm(formConfig); + const disabled = hasErrors; + + return ( + , + styles: styles.dialog, + }} + hidden={false} + modalProps={{ + isBlocking: false, + styles: styles.modal, + }} + onDismiss={onDismiss} + > +
+ + updateField('name', name)} + /> + +
+ + {showCreateQnAFromUrlDialog && ( + { + actions.createQnAFromScratchDialogCancel({ projectId }); + }} + /> + )} + { + actions.createQnAFromScratchDialogCancel({ projectId }); + onDismiss && onDismiss(); + }} + /> + { + if (hasErrors) { + return; + } + onSubmit(formData); + }} + /> + +
+ ); +}; + +export default CreateQnAFromScratchModal; diff --git a/Composer/packages/client/src/components/QnA/CreateQnAFromUrlModal.tsx b/Composer/packages/client/src/components/QnA/CreateQnAFromUrlModal.tsx new file mode 100644 index 0000000000..9d2104b43b --- /dev/null +++ b/Composer/packages/client/src/components/QnA/CreateQnAFromUrlModal.tsx @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React from 'react'; +import { useRecoilValue } from 'recoil'; +import formatMessage from 'format-message'; +import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { Checkbox } from 'office-ui-fabric-react/lib/Checkbox'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { Link } from 'office-ui-fabric-react/lib/Link'; + +import { FieldConfig, useForm } from '../../hooks/useForm'; +import { + dispatcherState, + onCreateQnAFromUrlDialogCompleteState, + showCreateQnAFromUrlDialogWithScratchState, +} from '../../recoilModel'; + +import { + knowledgeBaseSourceUrl, + validateUrl, + validateName, + CreateQnAFromModalProps, + CreateQnAFromUrlFormData, +} from './constants'; +import { subText, styles, dialogWindow, textField, warning } from './styles'; + +const formConfig: FieldConfig = { + url: { + required: true, + defaultValue: '', + validate: validateUrl, + }, + name: { + required: true, + defaultValue: '', + }, + multiTurn: { + required: false, + defaultValue: false, + }, +}; + +const DialogTitle = () => { + return ( +
+ {formatMessage('Create new knowledge base')} +

+ + {formatMessage( + 'Extract question-and-answer pairs from an online FAQ, product manuals, or other files. Supported formats are .tsv, .pdf, .doc, .docx, .xlsx, containing questions and answers in sequence. ' + )} + + {formatMessage('Learn more about knowledge base sources. ')} + + +

+
+ ); +}; + +export const CreateQnAFromUrlModal: React.FC = (props) => { + const { onDismiss, onSubmit, dialogId, projectId, qnaFiles } = props; + const actions = useRecoilValue(dispatcherState); + const onComplete = useRecoilValue(onCreateQnAFromUrlDialogCompleteState(projectId)); + const showWithScratch = useRecoilValue(showCreateQnAFromUrlDialogWithScratchState(projectId)); + + formConfig.name.validate = validateName(qnaFiles); + const { formData, updateField, hasErrors, formErrors } = useForm(formConfig); + const isQnAFileselected = !(dialogId === 'all'); + const disabled = hasErrors || !formData.url || !formData.name; + + return ( + , + styles: styles.dialog, + }} + hidden={false} + modalProps={{ + isBlocking: false, + styles: styles.modal, + }} + onDismiss={onDismiss} + > +
+ + updateField('name', name)} + /> + + + updateField('url', url)} + /> + + {!isQnAFileselected && ( +
{formatMessage('Please select a specific qna file to import QnA')}
+ )} +
+ + updateField('multiTurn', val)} + /> + +
+ + {showWithScratch && ( + { + // switch to create from scratch flow, pass onComplete callback. + actions.createQnAFromScratchDialogBegin({ projectId, onComplete: onComplete?.func }); + }} + /> + )} + { + actions.createQnAFromUrlDialogCancel({ projectId }); + onDismiss && onDismiss(); + }} + /> + { + if (hasErrors) { + return; + } + onSubmit(formData); + }} + /> + +
+ ); +}; + +export default CreateQnAFromUrlModal; diff --git a/Composer/packages/client/src/components/QnA/EditQnAFrom.tsx b/Composer/packages/client/src/components/QnA/EditQnAFrom.tsx new file mode 100644 index 0000000000..613215b79f --- /dev/null +++ b/Composer/packages/client/src/components/QnA/EditQnAFrom.tsx @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React from 'react'; +import { QnAFile } from '@bfc/shared'; + +import { isQnAFileCreatedFromUrl } from '../../utils/qnaUtil'; + +import EditQnAFromScratchModal, { EditQnAFromScratchFormData } from './EditQnAFromScratchModal'; +import EditQnAFromUrlModal, { EditQnAFromUrlFormData } from './EditQnAFromUrlModal'; + +type EditQnAModalProps = { + qnaFiles: QnAFile[]; + qnaFile: QnAFile; + onDismiss: () => void; + onSubmit: (formData: EditQnAFromScratchFormData | EditQnAFromUrlFormData) => void; +}; + +export const EditQnAModal: React.FC = (props) => { + if (isQnAFileCreatedFromUrl(props.qnaFile)) { + return ; + } else { + return ; + } +}; + +export default EditQnAModal; diff --git a/Composer/packages/client/src/components/QnA/EditQnAFromScratchModal.tsx b/Composer/packages/client/src/components/QnA/EditQnAFromScratchModal.tsx new file mode 100644 index 0000000000..5e92ca2dfb --- /dev/null +++ b/Composer/packages/client/src/components/QnA/EditQnAFromScratchModal.tsx @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React from 'react'; +import formatMessage from 'format-message'; +import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { QnAFile } from '@bfc/shared'; + +import { FieldConfig, useForm } from '../../hooks/useForm'; +import { getBaseName } from '../../utils/fileUtil'; + +import { validateName } from './constants'; +import { styles, dialogWindow, textField } from './styles'; + +type EditQnAFromScratchModalProps = { + qnaFiles: QnAFile[]; + qnaFile: QnAFile; + onDismiss: () => void; + onSubmit: (formData: EditQnAFromScratchFormData) => void; +}; + +export type EditQnAFromScratchFormData = { + name: string; +}; + +const formConfig: FieldConfig = { + name: { + required: true, + defaultValue: '', + }, +}; + +const DialogTitle = () => { + return
{formatMessage('Edit KB name')}
; +}; + +export const EditQnAFromScratchModal: React.FC = (props) => { + const { onDismiss, onSubmit, qnaFiles, qnaFile } = props; + + formConfig.name.validate = validateName(qnaFiles.filter(({ id }) => qnaFile.id !== id)); + formConfig.name.defaultValue = getBaseName(qnaFile.id); + const { formData, updateField, hasErrors, formErrors } = useForm(formConfig); + const disabled = hasErrors; + + const updateName = (name = '') => { + updateField('name', name); + }; + + return ( + , + styles: styles.dialog, + }} + hidden={false} + modalProps={{ + isBlocking: false, + styles: styles.modal, + }} + onDismiss={onDismiss} + > +
+ + updateName(name)} + /> + +
+ + + { + if (hasErrors) { + return; + } + onSubmit(formData); + }} + /> + +
+ ); +}; + +export default EditQnAFromScratchModal; diff --git a/Composer/packages/client/src/components/QnA/EditQnAFromUrlModal.tsx b/Composer/packages/client/src/components/QnA/EditQnAFromUrlModal.tsx new file mode 100644 index 0000000000..4a65c34de3 --- /dev/null +++ b/Composer/packages/client/src/components/QnA/EditQnAFromUrlModal.tsx @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React from 'react'; +import formatMessage from 'format-message'; +import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; +import { Stack } from 'office-ui-fabric-react/lib/Stack'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { QnAFile } from '@bfc/shared'; + +import { FieldConfig, useForm } from '../../hooks/useForm'; +import { getBaseName } from '../../utils/fileUtil'; +import { getQnAFileUrlOption } from '../../utils/qnaUtil'; + +import { validateName, validateUrl } from './constants'; +import { styles, dialogWindow, textField } from './styles'; + +type EditQnAFromUrlModalProps = { + qnaFiles: QnAFile[]; + qnaFile: QnAFile; + onDismiss: () => void; + onSubmit: (formData: EditQnAFromUrlFormData) => void; +}; + +export type EditQnAFromUrlFormData = { + name: string; + url: string; +}; + +const formConfig: FieldConfig = { + name: { + required: true, + defaultValue: '', + }, + url: { + required: true, + defaultValue: '', + }, +}; + +const DialogTitle = () => { + return
{formatMessage('Edit KB name')}
; +}; + +export const EditQnAFromUrlModal: React.FC = (props) => { + const { onDismiss, onSubmit, qnaFiles, qnaFile } = props; + + formConfig.name.validate = validateName(qnaFiles.filter(({ id }) => qnaFile.id !== id)); + formConfig.name.defaultValue = getBaseName(qnaFile.id); + formConfig.url.validate = validateUrl; + formConfig.url.defaultValue = getQnAFileUrlOption(qnaFile); + + const { formData, updateField, hasErrors, formErrors } = useForm(formConfig); + const disabled = hasErrors; + + const updateName = (name = '') => { + updateField('name', name); + }; + const updateUrl = (url = '') => { + updateField('url', url); + }; + + return ( + , + styles: styles.dialog, + }} + hidden={false} + modalProps={{ + isBlocking: false, + styles: styles.modal, + }} + onDismiss={onDismiss} + > +
+ + updateName(name)} + /> + + + updateUrl(url)} + /> + +
+ + + { + if (hasErrors) { + return; + } + onSubmit(formData); + }} + /> + +
+ ); +}; + +export default EditQnAFromUrlModal; diff --git a/Composer/packages/client/src/components/QnA/constants.ts b/Composer/packages/client/src/components/QnA/constants.ts new file mode 100644 index 0000000000..a8049e66d0 --- /dev/null +++ b/Composer/packages/client/src/components/QnA/constants.ts @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { QnAFile } from '@bfc/shared'; +import formatMessage from 'format-message'; + +import { FieldValidator } from '../../hooks/useForm'; + +export type CreateQnAFromScratchFormData = { + name: string; +}; +export type CreateQnAFromUrlFormData = { + url: string; + name: string; + multiTurn: boolean; +}; + +export type CreateQnAFormData = { + url?: string; + name: string; + multiTurn?: boolean; +}; + +export type CreateQnAFromModalProps = { + projectId: string; + dialogId: string; + qnaFiles: QnAFile[]; + subscriptionKey?: string; + onDismiss?: () => void; + onSubmit: (formData: CreateQnAFormData) => void; +}; + +export const validateUrl: FieldValidator = (url: string): string => { + let error = ''; + + if (url && !url.startsWith('http://') && !url.startsWith('https://')) { + error = formatMessage('A valid url should start with http:// or https://'); + } + + return error; +}; + +export const FileNameRegex = /^[a-zA-Z0-9-_]+$/; + +export const validateName = (sources: QnAFile[]): FieldValidator => { + return (name: string) => { + let currentError = ''; + if (name) { + if (!FileNameRegex.test(name)) { + currentError = formatMessage('KB name cannot contain speacial characters.'); + } + + const duplicatedItemIndex = sources.findIndex((item) => item.id.toLowerCase() === `${name.toLowerCase()}.source`); + if (duplicatedItemIndex > -1) { + currentError = formatMessage('You already have a KB with that name.Choose another name and try again.'); + } + } + return currentError; + }; +}; + +export const knowledgeBaseSourceUrl = + 'https://docs.microsoft.com/en-us/azure/cognitive-services/qnamaker/concepts/content-types'; + +export const QnAMakerLearningUrl = 'https://azure.microsoft.com/en-us/pricing/details/cognitive-services/qna-maker/'; diff --git a/Composer/packages/client/src/components/QnA/index.ts b/Composer/packages/client/src/components/QnA/index.ts new file mode 100644 index 0000000000..90507f314d --- /dev/null +++ b/Composer/packages/client/src/components/QnA/index.ts @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export * from './CreateQnAFromScratchModal'; +export * from './CreateQnAFromUrlModal'; +export * from './CreateQnAFrom'; +export * from './EditQnAFrom'; +export * from './constants'; diff --git a/Composer/packages/client/src/components/QnA/styles.ts b/Composer/packages/client/src/components/QnA/styles.ts new file mode 100644 index 0000000000..522338398d --- /dev/null +++ b/Composer/packages/client/src/components/QnA/styles.ts @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import { css } from '@emotion/core'; +import { FontWeights } from '@uifabric/styling'; +import { FontSizes, SharedColors, NeutralColors } from '@uifabric/fluent-theme'; + +export const styles = { + dialog: { + title: { + fontWeight: FontWeights.bold, + fontSize: FontSizes.size20, + paddingTop: '14px', + paddingBottom: '11px', + }, + subText: { + fontSize: FontSizes.size14, + }, + }, + modal: { + main: { + maxWidth: '800px !important', + }, + }, +}; + +export const dialogWindow = css` + display: flex; + flex-direction: column; + width: 400px; + min-height: 200px; +`; + +export const dialogWindowMini = css` + display: flex; + flex-direction: column; + width: 400px; + min-height: 100px; +`; + +export const textField = { + root: { + width: '400px', + paddingBottom: '20px', + }, +}; + +export const warning = { + color: SharedColors.red10, + fontSize: FontSizes.size10, +}; + +export const subText = css` + color: ${NeutralColors.gray130}; + font-size: 14px; + font-weight: 400; +`; diff --git a/Composer/packages/client/src/constants.ts b/Composer/packages/client/src/constants.ts index 2f21477913..7a0f8b3855 100644 --- a/Composer/packages/client/src/constants.ts +++ b/Composer/packages/client/src/constants.ts @@ -213,8 +213,3 @@ export const nameRegex = /^[a-zA-Z0-9-_]+$/; export const triggerNotSupportedWarning = formatMessage( 'This trigger type is not supported by the RegEx recognizer. To ensure this trigger is fired, change the recognizer type.' ); - -export const knowledgeBaseSourceUrl = - 'https://docs.microsoft.com/en-us/azure/cognitive-services/qnamaker/concepts/content-types'; - -export const QnAMakerLearningUrl = 'https://azure.microsoft.com/en-us/pricing/details/cognitive-services/qna-maker/'; diff --git a/Composer/packages/client/src/images/emptyQnAIcon.svg b/Composer/packages/client/src/images/emptyQnAIcon.svg new file mode 100644 index 0000000000..d70057b412 --- /dev/null +++ b/Composer/packages/client/src/images/emptyQnAIcon.svg @@ -0,0 +1,284 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Composer/packages/client/src/pages/design/DesignPage.tsx b/Composer/packages/client/src/pages/design/DesignPage.tsx index 82bdef201f..7823a001bc 100644 --- a/Composer/packages/client/src/pages/design/DesignPage.tsx +++ b/Composer/packages/client/src/pages/design/DesignPage.tsx @@ -8,7 +8,7 @@ import { Breadcrumb, IBreadcrumbItem } from 'office-ui-fabric-react/lib/Breadcru import formatMessage from 'format-message'; import { globalHistory, RouteComponentProps } from '@reach/router'; import get from 'lodash/get'; -import { DialogInfo, PromptTab, getEditorAPI, registerEditorAPI, FieldNames } from '@bfc/shared'; +import { DialogInfo, PromptTab, getEditorAPI, registerEditorAPI } from '@bfc/shared'; import { ActionButton } from 'office-ui-fabric-react/lib/Button'; import { JsonEditor } from '@bfc/code-editor'; import { EditorExtension, useTriggerApi, PluginConfig } from '@bfc/extension-client'; @@ -48,8 +48,9 @@ import { showCreateDialogModalState, showAddSkillDialogModalState, localeState, + qnaFilesState, } from '../../recoilModel'; -import ImportQnAFromUrlModal from '../knowledge-base/ImportQnAFromUrlModal'; +import { CreateQnAModal } from '../../components/QnA'; import { triggerNotSupported } from '../../utils/dialogValidator'; import { undoFunctionState, undoVersionState } from '../../recoilModel/undo/history'; import { decodeDesignerPathToArrayPath } from '../../utils/convertUtils/designerPathEncoder'; @@ -111,6 +112,7 @@ const DesignPage: React.FC(dialogs[0]); const [exportSkillModalVisible, setExportSkillModalVisible] = useState(false); const [warningIsVisible, setWarningIsVisible] = useState(true); @@ -160,6 +163,15 @@ const DesignPage: React.FC { const currentDialog = dialogs.find(({ id }) => id === dialogId); if (currentDialog) { @@ -233,10 +245,6 @@ const DesignPage: React.FC { - setImportQnAModalVisibility(true); - }; - const onTriggerCreationDismiss = () => { setTriggerModalVisibility(false); }; @@ -315,7 +323,10 @@ const DesignPage: React.FC { - openImportQnAModal(); + createQnAFromUrlDialogBegin({ + projectId, + showFromScratch: true, + }); }, }, ], @@ -548,33 +559,15 @@ const DesignPage: React.FC { - setImportQnAModalVisibility(false); - }; + const handleCreateQnA = async (data) => { + if (!dialogId) return; + createTrigger(dialogId, defaultQnATriggerData); - const handleCreateQnA = async (urls: string[]) => { - cancelImportQnAModal(); - const formData = { - $kind: qnaMatcherKey, - errors: { $kind: '', intent: '', event: '', triggerPhrases: '', regEx: '', activity: '' }, - event: '', - intent: '', - regEx: '', - triggerPhrases: '', - }; - const dialog = dialogs.find((d) => d.id === dialogId); - if (dialogId && dialog) { - const url = `/bot/${projectId}/knowledge-base/${dialogId}`; - const triggers = get(dialog, FieldNames.Events, []); - if (triggers.some((t) => t.type === qnaMatcherKey)) { - navigateTo(url); - } else { - createTrigger(dialogId, formData, url); - } - // import qna from urls - if (urls.length > 0) { - await importQnAFromUrls({ id: `${dialogId}.${locale}`, urls, projectId }); - } + const { name, url, multiTurn } = data; + if (url) { + await createQnAKBFromUrl({ id: `${dialogId}.${locale}`, name, url, multiTurn, projectId }); + } else { + await createQnAKBFromScratch({ id: `${dialogId}.${locale}`, name, projectId }); } }; @@ -692,9 +685,7 @@ const DesignPage: React.FC )} - {importQnAModalVisibility && ( - - )} + ) {displaySkillManifest && ( { - dialogId: string; - subscriptionKey?: string; - onDismiss: () => void; - onSubmit: (urls: string[]) => void; -} - -const DialogTitle = () => { - return ( -
- {formatMessage('Populate your Knowledge Base')} -

- - {formatMessage( - 'Extract question-and-answer pairs from an online FAQ, product manuals, or other files. Supported formats are .tsv, .pdf, .doc, .docx, .xlsx, containing questions and answers in sequence. ' - )} - - {formatMessage('Learn more about knowledge base sources. ')} - - {formatMessage( - 'Skip this step to add questions and answers manually after creation. The number of sources and file size you can add depends on the QnA service SKU you choose. ' - )} - - {formatMessage('Learn more about QnA Maker SKUs.')} - - -

-
- ); -}; - -interface FormField { - urls: string[]; -} - -const validateUrls = (urls: string[]) => { - const errors = Array(urls.length).fill(''); - - for (let i = 0; i < urls.length; i++) { - const baseUrl = urls[i].replace(/\/$/, ''); - for (let j = 0; j < urls.length; j++) { - const candidateUrl = urls[j].replace(/\/$/, ''); - if (baseUrl && candidateUrl && baseUrl === candidateUrl && i !== j) { - errors[i] = errors[j] = formatMessage('This url is duplicated'); - } - } - } - - for (let i = 0; i < urls.length; i++) { - if (urls[i] && !urls[i].startsWith('http://') && !urls[i].startsWith('https://')) { - errors[i] = formatMessage('A valid url should start with http:// or https://'); - } - } - - return errors; -}; - -const formConfig: FieldConfig = { - urls: { - required: true, - defaultValue: [''], - }, -}; - -export const ImportQnAFromUrlModal: React.FC = (props) => { - const { onDismiss, onSubmit, dialogId } = props; - const [urlErrors, setUrlErrors] = useState(['']); - - const { formData, updateField, hasErrors } = useForm(formConfig); - const isQnAFileselected = !(dialogId === 'all'); - const disabled = hasErrors || urlErrors.some((e) => !!e) || formData.urls.some((url) => !url); - - const addNewUrl = () => { - const urls = [...formData.urls, '']; - updateField('urls', urls); - setUrlErrors(validateUrls(urls)); - }; - - const updateUrl = (index: number, url = '') => { - const urls = [...formData.urls]; - urls[index] = url; - updateField('urls', urls); - setUrlErrors(validateUrls(urls)); - }; - - const removeUrl = (index: number) => { - const urls = [...formData.urls]; - urls.splice(index, 1); - updateField('urls', urls); - setUrlErrors(validateUrls(urls)); - }; - - return ( - , - styles: styles.dialog, - }} - hidden={false} - modalProps={{ - isBlocking: false, - styles: styles.modal, - }} - onDismiss={onDismiss} - > -
- - {formData.urls.map((l, index) => { - return ( -
- updateUrl(index, url)} - /> - {index !== 0 && ( -
- ); - })} - - {formatMessage('Add additional URL')} - - {!isQnAFileselected && ( -
{formatMessage('Please select a specific qna file to import QnA')}
- )} -
-
- - {window.location.href.indexOf('/knowledge-base/') == -1 && ( - { - if (hasErrors) { - return; - } - onSubmit([]); - }} - /> - )} - - { - if (hasErrors) { - return; - } - onSubmit(formData.urls); - }} - /> - -
- ); -}; - -export default ImportQnAFromUrlModal; diff --git a/Composer/packages/client/src/pages/knowledge-base/QnAPage.tsx b/Composer/packages/client/src/pages/knowledge-base/QnAPage.tsx index 8381f84378..9ab350edaa 100644 --- a/Composer/packages/client/src/pages/knowledge-base/QnAPage.tsx +++ b/Composer/packages/client/src/pages/knowledge-base/QnAPage.tsx @@ -6,21 +6,19 @@ import { jsx } from '@emotion/core'; import { useRecoilValue } from 'recoil'; import React, { Fragment, useMemo, useCallback, Suspense, useEffect, useState } from 'react'; import formatMessage from 'format-message'; -import { Toggle } from 'office-ui-fabric-react/lib/Toggle'; +import { ActionButton } from 'office-ui-fabric-react/lib/Button'; import { RouteComponentProps, Router } from '@reach/router'; import { LoadingSpinner } from '../../components/LoadingSpinner'; -import { actionButton } from '../language-understanding/styles'; import { navigateTo } from '../../utils/navigation'; import { TestController } from '../../components/TestController/TestController'; import { INavTreeItem } from '../../components/NavTree'; import { Page } from '../../components/Page'; -import { botDisplayNameState, dialogsState, qnaAllUpViewStatusState } from '../../recoilModel/atoms/botState'; +import { dialogsState, qnaFilesState } from '../../recoilModel/atoms/botState'; import { dispatcherState } from '../../recoilModel'; -import { QnAAllUpViewStatus } from '../../recoilModel/types'; +import { CreateQnAModal } from '../../components/QnA'; import TableView from './table-view'; -import { ImportQnAFromUrlModal } from './ImportQnAFromUrlModal'; const CodeEditor = React.lazy(() => import('./code-editor')); @@ -34,12 +32,11 @@ const QnAPage: React.FC = (props) => { const actions = useRecoilValue(dispatcherState); const dialogs = useRecoilValue(dialogsState(projectId)); - const botName = useRecoilValue(botDisplayNameState(projectId)); + const qnaFiles = useRecoilValue(qnaFilesState(projectId)); //To do: support other languages const locale = 'en-us'; //const locale = useRecoilValue(localeState); - const qnaAllUpViewStatus = useRecoilValue(qnaAllUpViewStatusState(projectId)); - const [importQnAFromUrlModalVisiability, setImportQnAFromUrlModalVisiability] = useState(false); + const [createOnDialogId, setCreateOnDialogId] = useState(''); const path = props.location?.pathname ?? ''; @@ -52,6 +49,27 @@ const QnAPage: React.FC = (props) => { name: dialog.displayName, ariaLabel: formatMessage('qna file'), url: `/bot/${projectId}/knowledge-base/${dialog.id}`, + menuIconProps: { + iconName: 'Add', + }, + menuItems: [ + { + name: formatMessage('Create KB from scratch'), + key: 'Create KB from scratch', + onClick: () => { + setCreateOnDialogId(dialog.id); + actions.createQnAFromScratchDialogBegin({ projectId }); + }, + }, + { + name: formatMessage('Create KB from URL or file'), + key: 'Create KB from URL or file', + onClick: () => { + setCreateOnDialogId(dialog.id); + actions.createQnAFromUrlDialogBegin({ projectId, showFromScratch: false }); + }, + }, + ], }; }); const mainDialogIndex = newDialogLinks.findIndex((link) => link.id === 'Main'); @@ -70,13 +88,7 @@ const QnAPage: React.FC = (props) => { }, [dialogs]); useEffect(() => { - const qnaKbUrls: string[] | undefined = props.location?.state?.qnaKbUrls; - if (qnaKbUrls && qnaKbUrls.length > 0) { - actions.importQnAFromUrls({ id: `${botName.toLocaleLowerCase()}.${locale}`, urls: qnaKbUrls, projectId }); - } - }, []); - - useEffect(() => { + setCreateOnDialogId(''); const activeDialog = dialogs.find(({ id }) => id === dialogId); if (!activeDialog && dialogs.length && dialogId !== 'all') { navigateTo(`/bot/${projectId}/knowledge-base/${dialogId}`); @@ -84,36 +96,15 @@ const QnAPage: React.FC = (props) => { }, [dialogId, dialogs, projectId]); const onToggleEditMode = useCallback( - (_e, checked) => { + (_e) => { let url = `/bot/${projectId}/knowledge-base/${dialogId}`; - if (checked) url += `/edit`; + if (!edit) url += `/edit`; navigateTo(url); }, - [dialogId, projectId] + [dialogId, projectId, edit] ); const toolbarItems = [ - { - type: 'dropdown', - text: formatMessage('Add'), - align: 'left', - dataTestid: 'AddFlyout', - buttonProps: { - iconProps: { iconName: 'Add' }, - }, - menuProps: { - items: [ - { - 'data-testid': 'FlyoutNewDialog', - key: 'importQnAFromUrls', - text: formatMessage('Import QnA From Url'), - onClick: () => { - setImportQnAFromUrlModalVisiability(true); - }, - }, - ], - }, - }, { type: 'element', element: , @@ -122,31 +113,16 @@ const QnAPage: React.FC = (props) => { ]; const onRenderHeaderContent = () => { - if (!isRoot || edit) { + if (!isRoot) { return ( - + + {edit ? formatMessage('Hide code') : formatMessage('Show code')} + ); } return null; }; - const onDismiss = () => { - setImportQnAFromUrlModalVisiability(false); - }; - - const onSubmit = async (urls: string[]) => { - onDismiss(); - await actions.importQnAFromUrls({ id: `${dialogId}.${locale}`, urls, projectId }); - }; - return ( = (props) => { }> - {qnaAllUpViewStatus !== QnAAllUpViewStatus.Loading && ( - - )} + - {qnaAllUpViewStatus === QnAAllUpViewStatus.Loading && ( - - )} - {importQnAFromUrlModalVisiability && ( - - )} + { + actions.createQnAFromUrlDialogCancel({ projectId }); + }} + onSubmit={async ({ name, url, multiTurn = false }) => { + if (url) { + await actions.createQnAKBFromUrl({ + id: `${createOnDialogId || dialogId}.${locale}`, + name, + url, + multiTurn, + projectId, + }); + } else { + await actions.createQnAKBFromScratch({ + id: `${createOnDialogId || dialogId}.${locale}`, + name, + projectId, + }); + } + }} + > ); diff --git a/Composer/packages/client/src/pages/knowledge-base/__tests__/importQnAFromUrlModal.test.tsx b/Composer/packages/client/src/pages/knowledge-base/__tests__/importQnAFromUrlModal.test.tsx deleted file mode 100644 index 677776b34a..0000000000 --- a/Composer/packages/client/src/pages/knowledge-base/__tests__/importQnAFromUrlModal.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import React from 'react'; -import { fireEvent } from '@bfc/test-utils'; - -import { renderWithRecoil } from './../../../../__tests__/testUtils/renderWithRecoil'; -import { ImportQnAFromUrlModal } from './../ImportQnAFromUrlModal'; - -describe('', () => { - const onDismiss = jest.fn(() => {}); - const onSubmit = jest.fn(() => {}); - let container; - beforeEach(() => { - container = renderWithRecoil( - , - () => {} - ); - }); - - it('renders and create from scratch', () => { - const { getByText } = container; - expect(getByText('Populate your Knowledge Base')).not.toBeNull(); - const createFromScratchButton = getByText('Create knowledge base from scratch'); - expect(createFromScratchButton).not.toBeNull(); - fireEvent.click(createFromScratchButton); - expect(onSubmit).toBeCalled(); - expect(onSubmit).toBeCalledWith([]); - }); - - it('click cancel', () => { - const { getByText } = container; - const cancelButton = getByText('Cancel'); - expect(cancelButton).not.toBeNull(); - fireEvent.click(cancelButton); - expect(onDismiss).toBeCalled(); - }); - - it('add new url and validate the value', () => { - const { findByText, getByTestId, getByText } = container; - const input0 = getByTestId('knowledgeLocationTextField-0'); - fireEvent.change(input0, { target: { value: 'test' } }); - - expect(input0.value).toBe('test'); - expect(findByText(/A valid url should start with/)).not.toBeNull(); - - const addButton = getByText(/Add additional URL/); - fireEvent.change(input0, { target: { value: 'http://test' } }); - fireEvent.click(addButton); - expect(getByTestId('knowledgeLocationTextField-1')).not.toBeNull(); - - const input1 = getByTestId('knowledgeLocationTextField-1'); - fireEvent.change(input1, { target: { value: 'http://test' } }); - expect(findByText(/This url is duplicated/)).not.toBeNull(); - fireEvent.change(input1, { target: { value: 'http://test1' } }); - - const createKnowledgeButton = getByText('Create knowledge base'); - expect(createKnowledgeButton).not.toBeNull(); - fireEvent.click(createKnowledgeButton); - expect(onSubmit).toBeCalled(); - expect(onSubmit).toBeCalledWith(['http://test', 'http://test1']); - - const deletebutton = getByTestId('deleteImportQnAUrl-1'); - fireEvent.click(deletebutton); - fireEvent.click(createKnowledgeButton); - expect(onSubmit).toBeCalled(); - expect(onSubmit).toBeCalledWith(['http://test']); - }); -}); diff --git a/Composer/packages/client/src/pages/knowledge-base/code-editor.tsx b/Composer/packages/client/src/pages/knowledge-base/code-editor.tsx index bd246f8397..5abad40aa8 100644 --- a/Composer/packages/client/src/pages/knowledge-base/code-editor.tsx +++ b/Composer/packages/client/src/pages/knowledge-base/code-editor.tsx @@ -2,20 +2,23 @@ // Licensed under the MIT License. /* eslint-disable react/display-name */ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo, Fragment } from 'react'; import { useRecoilValue } from 'recoil'; -import { EditorDidMount, defaultQnAPlaceholder } from '@bfc/code-editor'; +import { EditorDidMount, defaultQnAPlaceholder, QnAEditor } from '@bfc/code-editor'; import isEmpty from 'lodash/isEmpty'; import { RouteComponentProps } from '@reach/router'; import querystring from 'query-string'; import debounce from 'lodash/debounce'; import get from 'lodash/get'; import { CodeEditorSettings } from '@bfc/shared'; -import { QnAEditor } from '@bfc/code-editor'; +import { ActionButton } from 'office-ui-fabric-react/lib/Button'; + +import { dispatcherState, userSettingsState, qnaFilesState } from '../../recoilModel'; +import { navigateTo } from '../../utils/navigation'; +import { getBaseName } from '../../utils/fileUtil'; + +import { backIcon } from './styles'; -import { qnaFilesState } from '../../recoilModel/atoms/botState'; -import { dispatcherState } from '../../recoilModel'; -import { userSettingsState } from '../../recoilModel'; interface CodeEditorProps extends RouteComponentProps<{}> { dialogId: string; projectId: string; @@ -31,13 +34,20 @@ const CodeEditor: React.FC = (props) => { //const locale = useRecoilValue(localeState); const userSettings = useRecoilValue(userSettingsState); - const file = qnaFiles.find(({ id }) => id === `${dialogId}.${locale}`); + const search = props.location?.search ?? ''; + const searchContainerId = querystring.parse(search).C; + const searchContainerName = + searchContainerId && typeof searchContainerId === 'string' && getBaseName(searchContainerId); + const targetFileId = + searchContainerId && typeof searchContainerId === 'string' ? searchContainerId : `${dialogId}.${locale}`; + const file = qnaFiles.find(({ id }) => id === targetFileId); const hash = props.location?.hash ?? ''; const hashLine = querystring.parse(hash).L; const line = Array.isArray(hashLine) ? +hashLine[0] : typeof hashLine === 'string' ? +hashLine : 0; const [content, setContent] = useState(file?.content); const currentDiagnostics = get(file, 'diagnostics', []); const [qnaEditor, setQnAEditor] = useState(null); + useEffect(() => { if (qnaEditor) { window.requestAnimationFrame(() => { @@ -66,24 +76,39 @@ const CodeEditor: React.FC = (props) => { const onChangeContent = useMemo( () => debounce((newContent: string) => { - actions.updateQnAFile({ id: `${dialogId}.${locale}`, content: newContent, projectId }); + actions.updateQnAFile({ id: targetFileId, content: newContent, projectId }); }, 500), [projectId] ); return ( - + + {searchContainerName && ( + { + navigateTo(`/bot/${projectId}/knowledge-base/${dialogId}`); + }} + > + {searchContainerName} + + )} + + ); }; diff --git a/Composer/packages/client/src/pages/knowledge-base/styles.ts b/Composer/packages/client/src/pages/knowledge-base/styles.ts index e85f004e35..f6b0b92840 100644 --- a/Composer/packages/client/src/pages/knowledge-base/styles.ts +++ b/Composer/packages/client/src/pages/knowledge-base/styles.ts @@ -1,9 +1,33 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. import { css } from '@emotion/core'; -import { FontWeights } from '@uifabric/styling'; -import { NeutralColors, SharedColors } from '@uifabric/fluent-theme'; -import { IIconStyles } from 'office-ui-fabric-react/lib/Icon'; +import { FontWeights, mergeStyleSets } from '@uifabric/styling'; +import { NeutralColors, SharedColors, FontSizes } from '@uifabric/fluent-theme'; +import { IButtonStyles } from 'office-ui-fabric-react/lib/Button'; + +export const classNames = mergeStyleSets({ + groupHeader: { + display: 'flex', + fontSize: FontSizes.size16, + fontWeight: FontWeights.regular, + alignItems: 'center', + }, + emptyTableList: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + }, + emptyTableListCenter: { + display: 'flex', + alignItems: 'center', + flexDirection: 'column', + width: '50%', + textAlign: 'center', + marginTop: '-20%', + }, +}); + export const content = css` min-height: 28px; outline: none; @@ -25,6 +49,7 @@ export const formCell = css` white-space: pre-wrap; font-size: 14px; line-height: 28px; + height: 100%; `; export const inlineContainer = (isBold) => css` @@ -56,41 +81,6 @@ export const textFieldAnswer = { }, }; -export const link = { - root: { - fontSize: 14, - lineHeight: 28, - }, -}; - -export const addQnAPairLink = { - root: { - fontSize: 14, - lineHeight: 28, - marginLeft: 72, - }, -}; - -export const actionButton = css` - font-size: 16px; - margin: 0; - margin-left: 15px; -`; - -export const QnAIconStyle = { - root: { - padding: '8px', - boxSizing: 'border-box', - width: '40px', - height: '32px', - }, -} as IIconStyles; - -export const firstLine = css` - display: flex; - flex-direction: row; -`; - export const divider = css` height: 1px; background: ${NeutralColors.gray30}; @@ -98,22 +88,41 @@ export const divider = css` export const rowDetails = { root: { - minHeight: 76, + minHeight: 40, + width: '100%', selectors: { + '.ms-GroupHeader-expand': { + fontSize: 8, + }, '&:hover': { background: NeutralColors.gray30, + selectors: { + '.ms-TextField-fieldGroup': { + background: NeutralColors.gray30, + }, + '.ms-Button--icon': { + visibility: 'visible', + }, + '.ms-Button': { + display: 'block', + }, + }, }, - '&:hover .ms-Button--icon': { - visibility: 'visible', - }, - '&.is-selected .ms-Button--icon': { - visibility: 'visible', - }, - '&:hover .ms-Button': { - visibility: 'visible', + '&.is-selected': { + selectors: { + '.ms-Button--icon': { + visibility: 'visible', + }, + '.ms-Button': { + visibility: 'visible', + }, + '.ms-TextField-fieldGroup': { + background: NeutralColors.gray30, + }, + }, }, - '&.is-selected .ms-Button': { - visibility: 'visible', + '&.is-selected:hover': { + background: NeutralColors.gray30, }, }, }, @@ -126,33 +135,102 @@ export const icon = { }, }; -export const addButtonContainer = css` - z-index: 1; - background: ${NeutralColors.white}; -`; - export const addAlternative = { root: { - fontSize: 16, + fontSize: 12, paddingLeft: 0, marginLeft: -5, color: SharedColors.cyanBlue10, - visibility: 'hidden', + display: 'none', }, -}; +} as IButtonStyles; export const addQnAPair = { root: { - fontSize: 16, + fontSize: 12, paddingLeft: 0, - marginLeft: 68, + marginLeft: 57, + marginTop: -10, color: SharedColors.cyanBlue10, }, }; export const addIcon = { root: { - fontSize: '16px', + fontSize: FontSizes.size10, + margin: 0, color: SharedColors.cyanBlue10, }, }; + +export const backIcon = { + root: { + fontSize: FontSizes.size16, + color: NeutralColors.black, + marginTop: -10, + marginBottom: 10, + }, + icon: { + fontSize: FontSizes.size12, + marginTop: 2, + color: NeutralColors.black, + }, +}; + +export const editableField = { + root: { + height: '100%', + selectors: { + '.ms-TextField-wrapper': { + height: '100%', + }, + }, + }, + fieldGroup: { + height: '100%', + border: '0', + selectors: { + '&.ms-TextField-fieldGroup': { + selectors: { + '::after': { + border: 'none !important', + }, + }, + }, + }, + }, + field: { + overflowY: 'auto' as 'auto', + fontSize: FontSizes.size12, + maxHeight: 500, + selectors: { + '::placeholder': { + fontSize: FontSizes.size12, + }, + }, + }, +}; + +export const groupHeader = { + root: { + selectors: { + '.ms-GroupHeader-expand': { + fontSize: 8, + marginLeft: 16, + }, + }, + }, +}; + +export const groupNameStyle = css` + margin-top: -5px; + margin-left: 8px; + font-size: ${FontSizes.size16}; + font-weight: ${FontWeights.semibold}; +`; + +export const detailsHeaderStyle = css` + .ms-TooltipHost { + background: ${NeutralColors.white}; + } +`; diff --git a/Composer/packages/client/src/pages/knowledge-base/table-view.tsx b/Composer/packages/client/src/pages/knowledge-base/table-view.tsx index 33776ce19f..fa124aa594 100644 --- a/Composer/packages/client/src/pages/knowledge-base/table-view.tsx +++ b/Composer/packages/client/src/pages/knowledge-base/table-view.tsx @@ -4,303 +4,422 @@ /** @jsx jsx */ import { jsx } from '@emotion/core'; import { useRecoilValue } from 'recoil'; -import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react'; +import React, { useEffect, useState, useCallback, Fragment, useRef } from 'react'; import { DetailsList, DetailsRow, DetailsListLayoutMode, SelectionMode, CheckboxVisibility, + IDetailsGroupRenderProps, + IGroup, + IDetailsList, } from 'office-ui-fabric-react/lib/DetailsList'; -import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { GroupHeader, CollapseAllVisibility } from 'office-ui-fabric-react/lib/GroupedList'; +import { IOverflowSetItemProps, OverflowSet } from 'office-ui-fabric-react/lib/OverflowSet'; +import { Link } from 'office-ui-fabric-react/lib/Link'; import { TooltipHost } from 'office-ui-fabric-react/lib/Tooltip'; -import { IconButton, ActionButton } from 'office-ui-fabric-react/lib/Button'; +import { IconButton, ActionButton, PrimaryButton } from 'office-ui-fabric-react/lib/Button'; import { ScrollablePane, ScrollbarVisibility } from 'office-ui-fabric-react/lib/ScrollablePane'; import { Sticky, StickyPositionType } from 'office-ui-fabric-react/lib/Sticky'; import formatMessage from 'format-message'; import { RouteComponentProps } from '@reach/router'; -import get from 'lodash/get'; - -import { - addQuestion, - updateQuestion, - updateAnswer as updateAnswerUtil, - generateQnAPair, - insertSection, - removeSection, -} from '../../utils/qnaUtil'; -import { dialogsState, qnaFilesState } from '../../recoilModel/atoms/botState'; +import isEqual from 'lodash/isEqual'; +import isEmpty from 'lodash/isEmpty'; +import { QnASection, QnAFile } from '@bfc/shared'; +import { qnaUtil } from '@bfc/indexers'; +import { NeutralColors } from '@uifabric/fluent-theme'; + +import emptyQnAIcon from '../../images/emptyQnAIcon.svg'; +import { navigateTo } from '../../utils/navigation'; +import { dialogsState, qnaFilesState, localeState } from '../../recoilModel/atoms/botState'; import { dispatcherState } from '../../recoilModel'; +import { getBaseName } from '../../utils/fileUtil'; +import { EditableField } from '../../components/EditableField'; +import { EditQnAModal } from '../../components/QnA/EditQnAFrom'; +import { getQnAFileUrlOption } from '../../utils/qnaUtil'; import { formCell, - content, - textFieldQuestion, - textFieldAnswer, - contentAnswer, - addIcon, + addQnAPair, divider, rowDetails, icon, - addButtonContainer, addAlternative, - inlineContainer, - addQnAPair, + editableField, + groupHeader, + groupNameStyle, + detailsHeaderStyle, + classNames, } from './styles'; +interface QnASectionItem extends QnASection { + fileId: string; + dialogId: string | undefined; + used: boolean; + usedIn: { id: string; displayName: string }[]; +} + +const createQnASectionItem = (fileId: string): QnASectionItem => { + return { + fileId, + dialogId: '', + used: false, + usedIn: [], + sectionId: '', + Questions: [], + Answer: '', + Body: qnaUtil.generateQnAPair(), + }; +}; + interface TableViewProps extends RouteComponentProps<{}> { dialogId: string; projectId: string; } -enum EditMode { - None, - Creating, - Updating, -} - const TableView: React.FC = (props) => { const { dialogId = '', projectId = '' } = props; const actions = useRecoilValue(dispatcherState); const dialogs = useRecoilValue(dialogsState(projectId)); const qnaFiles = useRecoilValue(qnaFilesState(projectId)); - //To do: support other languages - const locale = 'en-us'; - - const file = qnaFiles.find(({ id }) => id === `${dialogId}.${locale}`); - const fileRef = useRef(file); - fileRef.current = file; - const dialogIdRef = useRef(dialogId); - dialogIdRef.current = dialogId; - const localeRef = useRef(locale); - localeRef.current = locale; - const limitedNumber = useRef(1).current; - const generateQnASections = (file) => { - return get(file, 'qnaSections', []).map((qnaSection, index) => { + const locale = useRecoilValue(localeState(projectId)); + const { + removeQnAImport, + removeQnAFile, + createQnAPairs, + removeQnAPairs, + createQnAQuestion, + updateQnAAnswer, + updateQnAQuestion, + } = useRecoilValue(dispatcherState); + + const targetFileId = dialogId.endsWith('.source') ? dialogId : `${dialogId}.${locale}`; + const qnaFile = qnaFiles.find(({ id }) => id === targetFileId); + const generateQnASections = (file: QnAFile): QnASectionItem[] => { + if (!file) return []; + const usedInDialog: any[] = []; + dialogs.forEach((dialog) => { + const dialogQnAFile = + qnaFiles.find(({ id }) => id === dialog.qnaFile) || + qnaFiles.find(({ id }) => id === `${dialog.qnaFile}.${locale}`); + if (dialogQnAFile) { + dialogQnAFile.imports.forEach(({ id }) => { + if (id === `${file.id}.qna`) { + usedInDialog.push({ id: dialog.id, displayName: dialog.displayName }); + } + }); + } + }); + + return file.qnaSections.map((qnaSection) => { const qnaDialog = dialogs.find((dialog) => file.id === `${dialog.id}.${locale}`); return { - fileId: file.fileId, - dialogId: qnaDialog?.id || '', - used: !!qnaDialog && qnaDialog, - indexId: index, - key: qnaSection.Body, + fileId: file.id, + dialogId: qnaDialog?.id, + used: !!qnaDialog, + usedIn: usedInDialog, + key: qnaSection.sectionId, ...qnaSection, }; }); }; - const allQnASections = qnaFiles.reduce((result: any[], qnaFile) => { - const res = generateQnASections(qnaFile); - return result.concat(res); - }, []); - const singleFileQnASections = generateQnASections(fileRef.current); - const qnaSections = useMemo(() => { + const detailListRef = useRef(null); + const [editQnAFile, setEditQnAFile] = useState(undefined); + const [expandedIndex, setExpandedIndex] = useState(-1); + const [kthSectionIsCreatingQuestion, setCreatingQuestionInKthSection] = useState(''); + const [creatQnAPairSettings, setCreatQnAPairSettings] = useState<{ + groupKey: string; + sectionIndex: number; + item?: { Qustion: string; Answer: string }; + }>({ + groupKey: '-1', + sectionIndex: -1, + }); + const currentDialogImportedFileIds = qnaFile?.imports.map(({ id }) => getBaseName(id)) || []; + const currentDialogImportedFiles = qnaFiles.filter(({ id }) => currentDialogImportedFileIds.includes(id)); + const currentDialogImportedSourceFiles = currentDialogImportedFiles.filter(({ id }) => id.endsWith('.source')); + const allSourceFiles = qnaFiles.filter(({ id }) => id.endsWith('.source')); + + const initializeQnASections = (qnaFiles, dialogId) => { + if (isEmpty(qnaFiles)) return; + + const allSections = qnaFiles + .filter(({ id }) => id.endsWith('.source')) + .reduce((result: any[], qnaFile) => { + const res = generateQnASections(qnaFile); + return result.concat(res); + }, []); if (dialogId === 'all') { - return allQnASections; + return allSections; } else { - return singleFileQnASections; - } - }, [dialogIdRef.current, qnaFiles]); - const [showQnAPairDetails, setShowQnAPairDetails] = useState(Array(qnaSections.length).fill(false)); - const [qnaSectionIndex, setQnASectionIndex] = useState(-1); - const [questionIndex, setQuestionIndex] = useState(-1); //used in QnASection.Questions array - const [question, setQuestion] = useState(''); - const [editMode, setEditMode] = useState(EditMode.None); - const [isUpdatingAnswer, setIsUpdatingAnswer] = useState(false); - const [answer, setAnswer] = useState(''); - const createOrUpdateQuestion = () => { - if (editMode === EditMode.Creating && question) { - const updatedQnAFileContent = addQuestion(question, qnaSections, qnaSectionIndex); - actions.updateQnAFile({ - id: `${dialogIdRef.current}.${localeRef.current}`, - content: updatedQnAFileContent, - projectId, - }); - } - if (editMode === EditMode.Updating && qnaSections[qnaSectionIndex].Questions[questionIndex].content !== question) { - const updatedQnAFileContent = updateQuestion(question, questionIndex, qnaSections, qnaSectionIndex); - actions.updateQnAFile({ - id: `${dialogIdRef.current}.${localeRef.current}`, - content: updatedQnAFileContent, - projectId, - }); + const dialogSections = allSections.filter((t) => currentDialogImportedFileIds.includes(t.fileId)); + return dialogSections; } - cancelQuestionEditOperation(); }; - const updateAnswer = () => { - if (editMode === EditMode.Updating && qnaSections[qnaSectionIndex].Answer !== answer) { - const updatedQnAFileContent = updateAnswerUtil(answer, qnaSections, qnaSectionIndex); - actions.updateQnAFile({ - id: `${dialogIdRef.current}.${localeRef.current}`, - content: updatedQnAFileContent, - projectId, - }); - } - cancelAnswerEditOperation(); - }; - - const cancelQuestionEditOperation = () => { - setEditMode(EditMode.None); - setQuestion(''); - setQuestionIndex(-1); - setQnASectionIndex(-1); - }; - - const cancelAnswerEditOperation = () => { - setEditMode(EditMode.None); - setAnswer(''); - setIsUpdatingAnswer(false); - setQnASectionIndex(-1); - }; + const [qnaSections, setQnASections] = useState(initializeQnASections(qnaFiles, dialogId)); useEffect(() => { - setShowQnAPairDetails(Array(qnaSections.length).fill(false)); - }, [dialogId, projectId]); - - const toggleShowAll = (index: number) => { - const newArray = showQnAPairDetails.map((element, i) => { - if (i === index) { - return !element; - } else { - return element; - } + if (isEmpty(qnaFiles)) return; + + const allSections = qnaFiles + .filter(({ id }) => id.endsWith('.source')) + .reduce((result: any[], qnaFile) => { + const res = generateQnASections(qnaFile); + return result.concat(res); + }, []); + if (dialogId === 'all') { + setQnASections(allSections); + } else { + const dialogSections = allSections.filter((t) => currentDialogImportedFileIds.includes(t.fileId)); + + setQnASections(dialogSections); + } + }, [qnaFiles, dialogId, projectId]); + + const onUpdateQnAQuestion = (fileId: string, sectionId: string, questionId: string, content: string) => { + if (!fileId) return; + actions.setMessage('item updated'); + updateQnAQuestion({ + id: fileId, + sectionId, + questionId, + content, + projectId, }); - setShowQnAPairDetails(newArray); }; - const expandDetails = (index: number) => { - const newArray = showQnAPairDetails.map((element, i) => { - if (i === index) { - return true; - } else { - return element; - } + const onUpdateQnAAnswer = (fileId: string, sectionId: string, content: string) => { + if (!fileId) return; + actions.setMessage('item updated'); + updateQnAAnswer({ + id: fileId, + sectionId, + content, + projectId, }); - setShowQnAPairDetails(newArray); }; - const handleQuestionKeydown = (e) => { - if (e.key === 'Enter') { - createOrUpdateQuestion(); - setEditMode(EditMode.None); - e.preventDefault(); - } - if (e.key === 'Escape') { - cancelQuestionEditOperation(); - e.preventDefault(); + const onRemoveQnAPairs = (fileId: string, sectionId: string) => { + if (!fileId) return; + actions.setMessage('item deleted'); + const sectionIndex = qnaSections.findIndex((item) => item.fileId === fileId); + removeQnAPairs({ + id: fileId, + sectionId, + projectId, + }); + // update expand status + if (expandedIndex) { + if (sectionIndex < expandedIndex) { + setExpandedIndex(expandedIndex - 1); + } else if (sectionIndex === expandedIndex) { + setExpandedIndex(-1); + } } }; - const handleQuestionOnBlur = (e) => { - createOrUpdateQuestion(); - e.preventDefault(); + const onCreateNewQnAPairsEnd = (fileId, updatedItem) => { + const { Question, Answer } = updatedItem; + if (!Question || !Answer) return; + const createdQnAPair = qnaUtil.generateQnAPair(Question, Answer); + setCreatQnAPairSettings({ groupKey: '', sectionIndex: -1 }); + createQnAPairs({ id: fileId, content: createdQnAPair, projectId }); }; - const handleAddingAlternatives = (e, index: number) => { - e.preventDefault(); - e.stopPropagation(); - setEditMode(EditMode.Creating); - setQnASectionIndex(index); - setQuestionIndex(-1); - expandDetails(index); - }; - - const handleUpdateingAlternatives = (e, qnaSectionIndex: number, questionIndex: number, question: string) => { - e.preventDefault(); - e.stopPropagation(); - setEditMode(EditMode.Updating); - setQuestion(question); - setQnASectionIndex(qnaSectionIndex); - setQuestionIndex(questionIndex); - expandDetails(qnaSectionIndex); - }; - - const handleQuestionOnChange = (newValue, index: number) => { - if (index !== qnaSectionIndex) return; - setQuestion(newValue); - }; - - const handleAnswerKeydown = (e) => { - if (e.key === 'Escape') { - setEditMode(EditMode.None); - setQnASectionIndex(-1); - setIsUpdatingAnswer(false); - e.preventDefault(); + const onCreateNewQnAPairsStart = (fileId: string | undefined) => { + if (!fileId) return; + const groupStartIndex = qnaSections.findIndex((item) => item.fileId === fileId); + // create on empty KB. + let insertPosition = groupStartIndex; + if (groupStartIndex === -1) { + insertPosition = 0; } + const newGroups = getGroups(fileId); + setGroups(newGroups); + const newItem = createQnASectionItem(fileId); + const newQnaSections = [...qnaSections]; + newQnaSections.splice(insertPosition, 0, newItem); + setQnASections(newQnaSections); + setExpandedIndex(insertPosition); + setCreatQnAPairSettings({ groupKey: fileId, sectionIndex: insertPosition, item: { Answer: '', Qustion: '' } }); }; - const handleAnswerOnBlur = (e) => { - updateAnswer(); - setEditMode(EditMode.None); - setQnASectionIndex(-1); - setQuestionIndex(-1); - e.preventDefault(); + const onCreateNewQuestion = (fileId, sectionId, content?: string) => { + if (!fileId || !sectionId) return; + const payload = { + id: fileId, + sectionId, + content: content || 'Add new question', + projectId, + }; + createQnAQuestion(payload); }; - const handleUpdateingAnswer = (e, qnaSectionIndex: number, answer: string) => { - e.preventDefault(); - e.stopPropagation(); - setEditMode(EditMode.Updating); - setAnswer(answer); - setQnASectionIndex(qnaSectionIndex); - setIsUpdatingAnswer(true); - expandDetails(qnaSectionIndex); + const onSubmitEditKB = async ({ name }: { name: string }) => { + if (!editQnAFile) return; + const newId = `${name}.source`; + await actions.renameQnAKB({ id: editQnAFile.id, name: newId, projectId }); + if (!qnaFile) return; + await actions.updateQnAImport({ id: qnaFile.id, sourceId: editQnAFile.id, newSourceId: newId, projectId }); + setEditQnAFile(undefined); }; - const handleAnswerOnChange = (answer, index: number) => { - if (index !== qnaSectionIndex) return; - setAnswer(answer); - }; + const onRenderGroupHeader: IDetailsGroupRenderProps['onRenderHeader'] = useCallback( + (props) => { + const groupName = props?.group?.name || ''; + const containerId = props?.group?.key || ''; + const containerQnAFile = qnaFiles.find(({ id }) => id === containerId); + const isImportedSource = containerId.endsWith('.source'); + const sourceUrl = isImportedSource && containerQnAFile && getQnAFileUrlOption(containerQnAFile); + const isAllTab = dialogId === 'all'; + const isCreatingQnA = creatQnAPairSettings.groupKey === containerId && creatQnAPairSettings.sectionIndex > -1; + const onRenderItem = (item: IOverflowSetItemProps): JSX.Element => { + return ( + + ); + }; - const deleteQnASection = (qnaSectionIndex: number) => { - actions.setMessage('item deleted'); - if (fileRef && fileRef.current) { - const updatedQnAFileContent = removeSection(qnaSectionIndex, fileRef.current.content); - actions.updateQnAFile({ - id: `${dialogIdRef.current}.${localeRef.current}`, - content: updatedQnAFileContent, - projectId, - }); - } - const newArray = [...showQnAPairDetails]; - newArray.splice(qnaSectionIndex, 1); - setShowQnAPairDetails(newArray); - }; + const onRenderOverflowButton = (overflowItems: any[] | undefined): JSX.Element => { + return ( +