[Search] [Synonyms] Synonym Rule flyout update (#213433)

## Summary

Updates Synonym Rule Flyout to match the designs. 


https://github.com/user-attachments/assets/8c034c2a-0b12-4a98-a627-fbef3a2542c7

Flyout tries to handle invalid cases which would throw from the endpoint
call

<img width="497" alt="Screenshot 2025-03-07 at 17 11 51"
src="https://github.com/user-attachments/assets/6e610177-ec56-4420-bcee-4c72935cdbb9"
/>
<img width="495" alt="Screenshot 2025-03-07 at 17 12 07"
src="https://github.com/user-attachments/assets/3fed1ed1-4be4-449e-a30c-c8c13e7d7968"
/>
<img width="509" alt="Screenshot 2025-03-07 at 17 12 33"
src="https://github.com/user-attachments/assets/117dbac5-dfbe-4160-a9d4-a92bcb3bcf89"
/>
<img width="472" alt="Screenshot 2025-03-07 at 17 12 44"
src="https://github.com/user-attachments/assets/70d50693-b2bf-4af4-b363-65f92d6812fd"
/>
<img width="484" alt="Screenshot 2025-03-07 at 17 12 53"
src="https://github.com/user-attachments/assets/ebb8f401-4dd6-4180-9028-396680091a4c"
/>
<img width="458" alt="Screenshot 2025-03-07 at 17 13 27"
src="https://github.com/user-attachments/assets/a7c1244b-3334-44d3-bd4c-e26b463e1b68"
/>

The text added needs a quick check as well cc: @leemthompo 


### Checklist

Check the PR satisfies following conditions. 

Reviewers should verify this PR satisfies this list as well.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Efe Gürkan YALAMAN 2025-03-13 15:35:11 +01:00 committed by GitHub
parent 005124a9ed
commit c42d763ce4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1455 additions and 235 deletions

View file

@ -46,7 +46,7 @@ export const SearchSynonymsOverview = () => {
solutionNav={searchNavigation?.useClassicNavigation(history)}
color="primary"
>
{synonymsData && !isInitialLoading && !isError && (
{!isInitialLoading && !isError && synonymsData?._meta.totalItemCount !== 0 && (
<KibanaPageTemplate.Header
pageTitle="Synonyms"
restrictWidth

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const ERROR_MESSAGES = {
empty_from_term: i18n.translate('xpack.searchSynonyms.synonymsSetRuleFlyout.invalidTerm', {
defaultMessage: 'Term cannot be empty.',
}),
empty_to_term: i18n.translate('xpack.searchSynonyms.synonymsSetRuleFlyout.invalidMapTo', {
defaultMessage: 'Terms cannot be empty.',
}),
term_exists: i18n.translate('xpack.searchSynonyms.synonymsSetRuleFlyout.termExists', {
defaultMessage: 'Term already exists.',
}),
multiple_explicit_separator: i18n.translate(
'xpack.searchSynonyms.synonymsSetRuleFlyout.invalidMapTo',
{ defaultMessage: 'Explicit separator "=>" is not allowed in terms.' }
),
};

View file

@ -0,0 +1,366 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { act, fireEvent, getByRole, render, screen } from '@testing-library/react';
import React from 'react';
import { SynonymRuleFlyout } from './synonym_rule_flyout';
import { I18nProvider } from '@kbn/i18n-react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { usePutSynonymsRule } from '../../hooks/use_put_synonyms_rule';
jest.mock('../../hooks/use_put_synonyms_rule', () => ({
usePutSynonymsRule: jest.fn().mockReturnValue({
mutate: jest.fn(),
}),
}));
const queryClient = new QueryClient();
const Wrapper = ({ children }: { children: React.ReactNode }) => {
return (
<I18nProvider>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</I18nProvider>
);
};
describe('SynonymRuleFlyout', () => {
const TEST_IDS = {
RuleIdText: 'searchSynonymsSynonymRuleFlyoutRuleIdText',
ErrorBanner: 'searchSynonymsSynonymsRuleFlyoutErrorBanner',
AddFromTermsInput: 'searchSynonymsSynonymsRuleFlyoutFromTermsInput',
FromTermCountLabel: 'searchSynonymsSynonymsRuleFlyoutTermCountLabel',
FromTermsSortAZButton: 'searchSynonymsSynonymsRuleFlyoutSortAZButton',
FromTermsRemoveAllButton: 'searchSynonymsSynonymsRuleFlyoutRemoveAllButton',
FromTermBadge: 'searchSynonymsSynonymsRuleFlyoutFromTermBadge',
NoTermsText: 'searchSynonymsSynonymsRuleFlyoutNoTermsText',
MapToTermsInput: 'searchSynonymsSynonymsRuleFlyoutMapToTermsInput',
HasChangesBadge: 'searchSynonymsSynonymsRuleFlyoutHasChangesBadge',
ResetChangesButton: 'searchSynonymsSynonymsRuleFlyoutResetChangesButton',
SaveChangesButton: 'searchSynonymsSynonymsRuleFlyoutSaveButton',
};
const ACTIONS = {
AddFromTerm: (term: string) => {
act(() => {
fireEvent.change(getByRole(screen.getByTestId(TEST_IDS.AddFromTermsInput), 'combobox'), {
target: { value: term },
});
fireEvent.keyDown(getByRole(screen.getByTestId(TEST_IDS.AddFromTermsInput), 'combobox'), {
key: 'Enter',
code: 'Enter',
});
});
},
AddMapToTerm: (term: string) => {
act(() => {
fireEvent.change(screen.getByTestId(TEST_IDS.MapToTermsInput), {
target: { value: term },
});
});
},
PressSaveChangesButton: () => {
act(() => {
fireEvent.click(screen.getByTestId(TEST_IDS.SaveChangesButton));
});
},
PressSortAZButton: () => {
act(() => {
fireEvent.click(screen.getByTestId(TEST_IDS.FromTermsSortAZButton));
});
},
};
const onCloseMock = jest.fn();
const mutateMock = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(usePutSynonymsRule as jest.Mock).mockReturnValue({
mutate: mutateMock,
});
});
describe('create mode', () => {
it('should render the flyout for equivalent synonyms', () => {
render(
<Wrapper>
<SynonymRuleFlyout
onClose={onCloseMock}
flyoutMode={'create'}
synonymsRule={{
id: 'generated-id',
synonyms: '',
}}
renderExplicit={false}
synonymsSetId="my_synonyms_set"
/>
</Wrapper>
);
// Header
expect(screen.queryByTestId(TEST_IDS.RuleIdText)).toBeInTheDocument();
expect(screen.queryByTestId(TEST_IDS.ErrorBanner)).not.toBeInTheDocument();
// From terms
expect(screen.getByTestId(TEST_IDS.NoTermsText).textContent).toBe('No terms found.');
expect(screen.getByTestId(TEST_IDS.FromTermCountLabel).textContent).toBe('0 term');
expect(screen.getByTestId(TEST_IDS.FromTermsSortAZButton)).toBeInTheDocument();
expect(screen.getByTestId(TEST_IDS.FromTermsRemoveAllButton)).toBeInTheDocument();
// Map to terms and bottom elements
expect(screen.queryByTestId(TEST_IDS.MapToTermsInput)).not.toBeInTheDocument();
expect(screen.queryByTestId(TEST_IDS.HasChangesBadge)).not.toBeInTheDocument();
expect(screen.getByTestId(TEST_IDS.ResetChangesButton)).toBeDisabled();
expect(screen.getByTestId(TEST_IDS.SaveChangesButton)).toBeDisabled();
});
it('should render the flyout for explicit synonyms', () => {
render(
<Wrapper>
<SynonymRuleFlyout
onClose={onCloseMock}
flyoutMode={'create'}
synonymsRule={{
id: 'generated-id',
synonyms: '',
}}
renderExplicit={true}
synonymsSetId="my_synonyms_set"
/>
</Wrapper>
);
// Header
expect(screen.queryByTestId(TEST_IDS.RuleIdText)).toBeInTheDocument();
expect(screen.queryByTestId(TEST_IDS.ErrorBanner)).not.toBeInTheDocument();
// From terms
expect(screen.getByTestId(TEST_IDS.NoTermsText).textContent).toBe('No terms found.');
expect(screen.getByTestId(TEST_IDS.FromTermCountLabel).textContent).toBe('0 term');
expect(screen.getByTestId(TEST_IDS.FromTermsSortAZButton)).toBeInTheDocument();
expect(screen.getByTestId(TEST_IDS.FromTermsRemoveAllButton)).toBeInTheDocument();
// Map to terms and bottom elements
expect(screen.getByTestId(TEST_IDS.MapToTermsInput)).toBeInTheDocument();
expect(screen.queryByTestId(TEST_IDS.HasChangesBadge)).not.toBeInTheDocument();
expect(screen.getByTestId(TEST_IDS.ResetChangesButton)).toBeDisabled();
expect(screen.getByTestId(TEST_IDS.SaveChangesButton)).toBeDisabled();
});
it('should call backend with correct payload for equivalent synonyms', () => {
render(
<Wrapper>
<SynonymRuleFlyout
onClose={onCloseMock}
flyoutMode={'create'}
synonymsRule={{
id: 'generated-id',
synonyms: '',
}}
renderExplicit={false}
synonymsSetId="my_synonyms_set"
/>
</Wrapper>
);
expect(screen.getByTestId(TEST_IDS.SaveChangesButton)).toBeDisabled();
ACTIONS.AddFromTerm('from1');
expect(screen.getByTestId(TEST_IDS.FromTermCountLabel).textContent).toBe('1 term');
expect(screen.getByTestId(TEST_IDS.SaveChangesButton)).toBeEnabled();
ACTIONS.AddFromTerm('from2');
expect(screen.getByTestId(TEST_IDS.FromTermCountLabel).textContent).toBe('2 terms');
ACTIONS.PressSaveChangesButton();
expect(mutateMock).toHaveBeenCalledWith({
synonymsSetId: 'my_synonyms_set',
ruleId: 'generated-id',
synonyms: 'from1,from2',
});
});
it('should call backend with correct payload for explicit synonyms', () => {
render(
<Wrapper>
<SynonymRuleFlyout
onClose={onCloseMock}
flyoutMode={'create'}
synonymsRule={{
id: 'generated-id',
synonyms: '',
}}
renderExplicit={true}
synonymsSetId="my_synonyms_set"
/>
</Wrapper>
);
ACTIONS.AddFromTerm('from1');
expect(screen.getByTestId(TEST_IDS.FromTermCountLabel).textContent).toBe('1 term');
expect(screen.getByTestId(TEST_IDS.SaveChangesButton)).toBeDisabled();
ACTIONS.AddMapToTerm('to1');
expect(screen.getByTestId(TEST_IDS.SaveChangesButton)).toBeEnabled();
ACTIONS.PressSaveChangesButton();
expect(mutateMock).toHaveBeenCalledWith({
synonymsSetId: 'my_synonyms_set',
ruleId: 'generated-id',
synonyms: 'from1 => to1',
});
});
it('should sort items in the flyout', () => {
render(
<Wrapper>
<SynonymRuleFlyout
onClose={onCloseMock}
flyoutMode={'create'}
synonymsRule={{
id: 'generated-id',
synonyms: '',
}}
renderExplicit={false}
synonymsSetId="my_synonyms_set"
/>
</Wrapper>
);
ACTIONS.AddFromTerm('a');
ACTIONS.AddFromTerm('b');
expect(screen.getByTestId(TEST_IDS.FromTermCountLabel).textContent).toBe('2 terms');
expect(screen.getAllByTestId(TEST_IDS.FromTermBadge)[0].textContent?.trim()).toBe('a');
expect(screen.getAllByTestId(TEST_IDS.FromTermBadge)[1].textContent?.trim()).toBe('b');
ACTIONS.PressSortAZButton();
expect(screen.getAllByTestId(TEST_IDS.FromTermBadge)[0].textContent?.trim()).toBe('b');
expect(screen.getAllByTestId(TEST_IDS.FromTermBadge)[1].textContent?.trim()).toBe('a');
ACTIONS.PressSortAZButton();
expect(screen.getAllByTestId(TEST_IDS.FromTermBadge)[0].textContent?.trim()).toBe('a');
expect(screen.getAllByTestId(TEST_IDS.FromTermBadge)[1].textContent?.trim()).toBe('b');
});
});
describe('edit mode', () => {
it('should render the flyout for equivalent synonyms', () => {
render(
<Wrapper>
<SynonymRuleFlyout
onClose={onCloseMock}
flyoutMode={'edit'}
synonymsRule={{
id: 'rule_id_3',
synonyms: 'synonym1,synonym2',
}}
renderExplicit={false}
synonymsSetId="my_synonyms_set"
/>
</Wrapper>
);
// Header
expect(screen.getByTestId(TEST_IDS.RuleIdText).textContent).toBe('Rule ID: rule_id_3');
expect(screen.queryByTestId(TEST_IDS.ErrorBanner)).not.toBeInTheDocument();
// From terms
expect(screen.queryByTestId(TEST_IDS.NoTermsText)).not.toBeInTheDocument();
expect(screen.getByTestId(TEST_IDS.FromTermCountLabel).textContent).toBe('2 terms');
expect(screen.getAllByTestId(TEST_IDS.FromTermBadge)[0].textContent?.trim()).toBe('synonym1');
expect(screen.getAllByTestId(TEST_IDS.FromTermBadge)[1].textContent?.trim()).toBe('synonym2');
expect(screen.getByTestId(TEST_IDS.FromTermsSortAZButton)).toBeInTheDocument();
expect(screen.getByTestId(TEST_IDS.FromTermsRemoveAllButton)).toBeInTheDocument();
// Map to terms and bottom elements
expect(screen.queryByTestId(TEST_IDS.MapToTermsInput)).not.toBeInTheDocument();
expect(screen.queryByTestId(TEST_IDS.HasChangesBadge)).not.toBeInTheDocument();
expect(screen.getByTestId(TEST_IDS.ResetChangesButton)).toBeDisabled();
expect(screen.getByTestId(TEST_IDS.SaveChangesButton)).toBeDisabled();
});
it('should call backend with correct payload for equivalent synonyms', () => {
render(
<Wrapper>
<SynonymRuleFlyout
onClose={onCloseMock}
flyoutMode={'edit'}
synonymsRule={{
id: 'rule_id_3',
synonyms: 'synonym1,synonym2',
}}
renderExplicit={false}
synonymsSetId="my_synonyms_set"
/>
</Wrapper>
);
expect(screen.getByTestId(TEST_IDS.SaveChangesButton)).toBeDisabled();
ACTIONS.AddFromTerm('synonym3');
expect(screen.getByTestId(TEST_IDS.SaveChangesButton)).toBeEnabled();
expect(screen.getByTestId(TEST_IDS.FromTermCountLabel).textContent).toBe('3 terms');
ACTIONS.PressSaveChangesButton();
expect(mutateMock).toHaveBeenCalledWith({
synonymsSetId: 'my_synonyms_set',
ruleId: 'rule_id_3',
synonyms: 'synonym1,synonym2,synonym3',
});
});
it('should render the flyout for explicit synonyms', () => {
render(
<Wrapper>
<SynonymRuleFlyout
onClose={onCloseMock}
flyoutMode={'edit'}
synonymsRule={{
id: 'rule_id_3',
synonyms: 'explicit-from => explicit-to',
}}
renderExplicit={true}
synonymsSetId="my_synonyms_set"
/>
</Wrapper>
);
// Header
expect(screen.getByTestId(TEST_IDS.RuleIdText).textContent).toBe('Rule ID: rule_id_3');
expect(screen.queryByTestId(TEST_IDS.ErrorBanner)).not.toBeInTheDocument();
// From terms
expect(screen.queryByTestId(TEST_IDS.NoTermsText)).not.toBeInTheDocument();
expect(screen.getByTestId(TEST_IDS.FromTermCountLabel).textContent).toBe('1 term');
expect(screen.getByTestId(TEST_IDS.FromTermBadge).textContent?.trim()).toBe('explicit-from');
expect(screen.getByTestId(TEST_IDS.FromTermsSortAZButton)).toBeInTheDocument();
expect(screen.getByTestId(TEST_IDS.FromTermsRemoveAllButton)).toBeInTheDocument();
// Map to terms and bottom elements
expect(screen.getByTestId(TEST_IDS.MapToTermsInput)).toBeInTheDocument();
expect(screen.getByTestId(TEST_IDS.MapToTermsInput)).toHaveValue('explicit-to');
expect(screen.queryByTestId(TEST_IDS.HasChangesBadge)).not.toBeInTheDocument();
expect(screen.getByTestId(TEST_IDS.ResetChangesButton)).toBeDisabled();
expect(screen.getByTestId(TEST_IDS.SaveChangesButton)).toBeDisabled();
});
it('should call backend with correct payload for explicit synonyms', () => {
render(
<Wrapper>
<SynonymRuleFlyout
onClose={onCloseMock}
flyoutMode={'edit'}
synonymsRule={{
id: 'rule_id_3',
synonyms: 'explicit-from => explicit-to',
}}
renderExplicit={true}
synonymsSetId="my_synonyms_set"
/>
</Wrapper>
);
expect(screen.getByTestId(TEST_IDS.SaveChangesButton)).toBeDisabled();
ACTIONS.AddMapToTerm('explicit-to-2');
expect(screen.getByTestId(TEST_IDS.SaveChangesButton)).toBeEnabled();
ACTIONS.PressSaveChangesButton();
expect(mutateMock).toHaveBeenCalledWith({
synonymsSetId: 'my_synonyms_set',
ruleId: 'rule_id_3',
synonyms: 'explicit-from => explicit-to-2',
});
});
});
});

View file

@ -0,0 +1,357 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import {
EuiBadge,
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiComboBox,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiFormRow,
EuiHealth,
EuiSpacer,
EuiText,
useEuiTheme,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { SynonymsSynonymRule } from '@elastic/elasticsearch/lib/api/types';
import { css } from '@emotion/react';
import { FormattedMessage } from '@kbn/i18n-react';
import { synonymsOptionToString } from '../../utils/synonyms_utils';
import { usePutSynonymsRule } from '../../hooks/use_put_synonyms_rule';
import { useSynonymRuleFlyoutState } from './use_flyout_state';
interface SynonymRuleFlyoutProps {
onClose: () => void;
flyoutMode: 'create' | 'edit';
synonymsRule: SynonymsSynonymRule;
synonymsSetId: string;
renderExplicit?: boolean;
}
export const SynonymRuleFlyout: React.FC<SynonymRuleFlyoutProps> = ({
flyoutMode,
synonymsRule,
onClose,
renderExplicit = false,
synonymsSetId,
}) => {
const { euiTheme } = useEuiTheme();
const [backendError, setBackendError] = React.useState<string | null>(null);
const { mutate: putSynonymsRule } = usePutSynonymsRule(
() => onClose(),
(error) => {
setBackendError(error);
}
);
const {
canSave,
currentSortDirection,
fromTermErrors,
fromTerms,
hasChanges,
isExplicit,
isFromTermsInvalid,
isMapToTermsInvalid,
mapToTermErrors,
mapToTerms,
clearFromTerms,
onCreateOption,
onMapToChange,
onSearchChange,
onSortTerms,
removeTermFromOptions,
resetChanges,
} = useSynonymRuleFlyoutState({
synonymRule: synonymsRule,
flyoutMode,
renderExplicit,
});
return (
<EuiFlyout onClose={onClose} size={'33%'} outsideClickCloses={false}>
<EuiFlyoutHeader hasBorder>
<EuiText data-test-subj="searchSynonymsSynonymRuleFlyoutRuleIdText">
<b>
{i18n.translate('xpack.searchSynonyms.synonymsSetRuleFlyout.title.ruleId', {
defaultMessage: 'Rule ID: {ruleId}',
values: { ruleId: synonymsRule.id },
})}
</b>
</EuiText>
</EuiFlyoutHeader>
<EuiFlyoutBody
css={css`
.euiFlyoutBody__overflowContent {
height: 100%;
}
`}
banner={
backendError && (
<EuiCallOut
data-test-subj="searchSynonymsSynonymsRuleFlyoutErrorBanner"
color="danger"
title={i18n.translate(
'xpack.searchSynonyms.synonymsSetRuleFlyout.errorCallout.title',
{
defaultMessage: 'An error occured while saving your changes',
}
)}
>
{backendError}
</EuiCallOut>
)
}
>
<EuiFlexGroup
justifyContent="spaceBetween"
direction="column"
gutterSize="none"
css={css`
height: 100%;
`}
>
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiFormRow
fullWidth
label={i18n.translate('xpack.searchSynonyms.synonymsSetRuleFlyout.synonyms', {
defaultMessage: 'Add terms to match against',
})}
isInvalid={isFromTermsInvalid}
error={fromTermErrors || null}
>
<EuiComboBox
fullWidth
data-test-subj="searchSynonymsSynonymsRuleFlyoutFromTermsInput"
isInvalid={isFromTermsInvalid}
noSuggestions
placeholder={i18n.translate(
'xpack.searchSynonyms.synonymsSetRuleFlyout.synonyms.inputPlaceholder',
{ defaultMessage: 'Add terms to match against' }
)}
onCreateOption={onCreateOption}
delimiter=","
onSearchChange={onSearchChange}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween" responsive={false}>
<EuiFlexItem>
<EuiFlexGroup responsive={false} alignItems="center">
<EuiFlexItem grow={false}>
<EuiText
size="s"
color="subdued"
data-test-subj="searchSynonymsSynonymsRuleFlyoutTermCountLabel"
>
<p>
{fromTerms.length <= 1 ? (
<FormattedMessage
id="xpack.searchSynonyms.synonymsSetRuleFlyout.synonymsCount.single"
defaultMessage="{count} term"
values={{ count: fromTerms.length }}
/>
) : (
<FormattedMessage
id="xpack.searchSynonyms.synonymsSetRuleFlyout.synonymsCount.multiple"
defaultMessage="{count} terms"
values={{ count: fromTerms.length }}
/>
)}
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="searchSynonymsSynonymsRuleFlyoutSortAZButton"
size="s"
color="text"
onClick={() => onSortTerms()}
iconType={currentSortDirection === 'ascending' ? 'sortUp' : 'sortDown'}
>
{currentSortDirection === 'ascending' ? (
<FormattedMessage
id="xpack.searchSynonyms.synonymsSetRuleFlyout.sortAZ"
defaultMessage="Sort A-Z"
/>
) : (
<FormattedMessage
id="xpack.searchSynonyms.synonymsSetRuleFlyout.sortZA"
defaultMessage="Sort Z-A"
/>
)}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="searchSynonymsSynonymsRuleFlyoutRemoveAllButton"
color="danger"
size="s"
onClick={clearFromTerms}
>
<FormattedMessage
id="xpack.searchSynonyms.synonymsSetRuleFlyout.clearAll"
defaultMessage="Remove all"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem
css={css`
max-height: ${isExplicit ? '75%' : '90%'};
`}
>
<EuiFlexGroup
direction="column"
gutterSize="xs"
tabIndex={0}
className="eui-yScrollWithShadows"
css={css`
margin: ${euiTheme.size.xs};
`}
>
<EuiSpacer size="xs" />
{fromTerms.map((opt, index) => (
<span key={index + '-' + opt.label.trim()}>
<EuiBadge
data-test-subj="searchSynonymsSynonymsRuleFlyoutFromTermBadge"
color="hollow"
iconSide="left"
iconType="cross"
iconOnClick={() => {
removeTermFromOptions(opt);
}}
iconOnClickAriaLabel="remove"
>
&nbsp;
{opt.label}
</EuiBadge>
</span>
))}
{fromTerms.length === 0 && (
<EuiText
color="subdued"
textAlign="center"
size="s"
data-test-subj="searchSynonymsSynonymsRuleFlyoutNoTermsText"
>
<p>
<FormattedMessage
id="xpack.searchSynonyms.synonymsSetRuleFlyout.noTerms"
defaultMessage="No terms found."
/>
</p>
</EuiText>
)}
<EuiSpacer size="s" />
</EuiFlexGroup>
</EuiFlexItem>
{isExplicit && (
<EuiFlexItem grow={false}>
<EuiFormRow
fullWidth
label={i18n.translate('xpack.searchSynonyms.synonymsSetRuleFlyout.synonymsTo', {
defaultMessage: 'Map to this term',
})}
isInvalid={mapToTerms !== '' && isMapToTermsInvalid}
error={mapToTermErrors || null}
>
<EuiFieldText
data-test-subj="searchSynonymsSynonymsRuleFlyoutMapToTermsInput"
fullWidth
value={mapToTerms}
isInvalid={mapToTerms !== '' && isMapToTermsInvalid}
onChange={(e) => {
onMapToChange(e.target.value);
}}
/>
</EuiFormRow>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
{hasChanges && (
<EuiHealth
color="primary"
data-test-subj="searchSynonymsSynonymsRuleFlyoutHasChangesBadge"
>
{i18n.translate('xpack.searchSynonyms.synonymsSetRuleFlyout.unsavedChanges', {
defaultMessage: 'Synonym rule has unsaved changes',
})}
</EuiHealth>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup responsive={false}>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="searchSynonymsSynonymsRuleFlyoutResetChangesButton"
iconType="refresh"
disabled={!hasChanges}
onClick={resetChanges}
>
{i18n.translate('xpack.searchSynonyms.synonymsSetRuleFlyout.reset', {
defaultMessage: 'Reset changes',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="searchSynonymsSynonymsRuleFlyoutSaveButton"
fill
disabled={!canSave}
onClick={() => {
if (!synonymsRule.id) {
return;
}
putSynonymsRule({
synonymsSetId,
ruleId: synonymsRule.id,
synonyms: synonymsOptionToString({
fromTerms,
toTerms: mapToTerms,
isExplicit,
}),
});
}}
>
{i18n.translate('xpack.searchSynonyms.synonymsSetRuleFlyout.save', {
defaultMessage: 'Save',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
};

View file

@ -0,0 +1,475 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
describe('useSynonymRuleFlyoutState hook', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const wrapper = ({ children }: { children: React.ReactNode }) => {
const queryClient = new QueryClient();
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
describe('hasChanges', () => {
describe('create mode', () => {
describe('equivalent terms', () => {
it('should be false by default in create mode', async () => {
const { useSynonymRuleFlyoutState } = jest.requireActual('./use_flyout_state');
const { result } = renderHook(
() =>
useSynonymRuleFlyoutState({
synonymRule: {
synonyms: '',
},
flyoutMode: 'create',
renderExplicit: false,
}),
{ wrapper }
);
await waitFor(() => {
expect(result.current.hasChanges).toBe(false);
});
});
it('should be true when fromTerms has changes in create mode', async () => {
const { useSynonymRuleFlyoutState } = jest.requireActual('./use_flyout_state');
const { result } = renderHook(
() =>
useSynonymRuleFlyoutState({
synonymRule: {
synonyms: '',
},
flyoutMode: 'create',
renderExplicit: false,
}),
{ wrapper }
);
await waitFor(() => {
expect(result.current.hasChanges).toBe(false);
});
const { onCreateOption } = result.current;
act(() => {
onCreateOption('test');
});
await waitFor(() => {
expect(result.current.hasChanges).toBe(true);
});
});
});
describe('explicit terms', () => {
it('should be false by default in create mode', async () => {
const { useSynonymRuleFlyoutState } = jest.requireActual('./use_flyout_state');
const { result } = renderHook(
() =>
useSynonymRuleFlyoutState({
synonymRule: {
synonyms: '',
},
flyoutMode: 'create',
renderExplicit: true,
}),
{ wrapper }
);
await waitFor(() => {
expect(result.current.hasChanges).toBe(false);
});
});
it('should be true when fromTerms has changes ', async () => {
const { useSynonymRuleFlyoutState } = jest.requireActual('./use_flyout_state');
const { result } = renderHook(
() =>
useSynonymRuleFlyoutState({
synonymRule: {
synonyms: '',
},
flyoutMode: 'create',
renderExplicit: true,
}),
{ wrapper }
);
await waitFor(() => {
expect(result.current.hasChanges).toBe(false);
});
const { onCreateOption } = result.current;
act(() => {
onCreateOption('test');
});
await waitFor(() => {
expect(result.current.hasChanges).toBe(true);
});
});
});
it('should be true when mapToTerms has changes', async () => {
const { useSynonymRuleFlyoutState } = jest.requireActual('./use_flyout_state');
const { result } = renderHook(
() =>
useSynonymRuleFlyoutState({
synonymRule: {
synonyms: '',
},
flyoutMode: 'create',
renderExplicit: true,
}),
{ wrapper }
);
await waitFor(() => {
expect(result.current.hasChanges).toBe(false);
});
const { onMapToChange } = result.current;
act(() => {
onMapToChange('test');
});
await waitFor(() => {
expect(result.current.hasChanges).toBe(true);
});
});
});
describe('edit mode', () => {
describe('equivalent terms', () => {
it('should be true when fromTerms has changes', async () => {
const { useSynonymRuleFlyoutState } = jest.requireActual('./use_flyout_state');
const { result } = renderHook(
() =>
useSynonymRuleFlyoutState({
synonymRule: {
synonyms: 'synonym1,synonym2',
},
flyoutMode: 'edit',
}),
{ wrapper }
);
await waitFor(() => {
expect(result.current.hasChanges).toBe(false);
});
const { onCreateOption } = result.current;
act(() => {
onCreateOption('test');
});
await waitFor(() => {
expect(result.current.hasChanges).toBe(true);
});
});
});
describe('explicit terms', () => {
it('should be true when mapToTerms has changes', async () => {
const { useSynonymRuleFlyoutState } = jest.requireActual('./use_flyout_state');
const { result } = renderHook(
() =>
useSynonymRuleFlyoutState({
synonymRule: {
synonyms: 'synonym1 => synonym2',
},
flyoutMode: 'edit',
}),
{ wrapper }
);
await waitFor(() => {
expect(result.current.hasChanges).toBe(false);
});
const { onMapToChange } = result.current;
act(() => {
onMapToChange('test');
});
await waitFor(() => {
expect(result.current.hasChanges).toBe(true);
});
});
});
});
});
describe('reset changes', () => {
it('should reset changes in equivalent when in edit mode', async () => {
const { useSynonymRuleFlyoutState } = jest.requireActual('./use_flyout_state');
const { result } = renderHook(
() =>
useSynonymRuleFlyoutState({
synonymRule: {
synonyms: 'synonym1,synonym2',
},
flyoutMode: 'edit',
}),
{ wrapper }
);
await waitFor(() => {
expect(result.current.hasChanges).toBe(false);
});
const { onCreateOption, resetChanges } = result.current;
act(() => {
onCreateOption('test');
});
await waitFor(() => {
expect(result.current.hasChanges).toBe(true);
expect(result.current.fromTerms).toEqual([
expect.objectContaining({ label: 'synonym1' }),
expect.objectContaining({ label: 'synonym2' }),
expect.objectContaining({ label: 'test' }),
]);
});
act(() => {
resetChanges();
});
await waitFor(() => {
expect(result.current.hasChanges).toBe(false);
expect(result.current.fromTerms).toEqual([
expect.objectContaining({ label: 'synonym1' }),
expect.objectContaining({ label: 'synonym2' }),
]);
});
});
it('should reset changes in explicit when in edit mode', async () => {
const { useSynonymRuleFlyoutState } = jest.requireActual('./use_flyout_state');
const { result } = renderHook(
() =>
useSynonymRuleFlyoutState({
synonymRule: {
synonyms: 'synonym1 => synonym2',
},
flyoutMode: 'edit',
}),
{ wrapper }
);
await waitFor(() => {
expect(result.current.hasChanges).toBe(false);
});
const { onMapToChange, resetChanges } = result.current;
act(() => {
onMapToChange('test');
});
await waitFor(() => {
expect(result.current.hasChanges).toBe(true);
expect(result.current.mapToTerms).toBe('test');
});
act(() => {
resetChanges();
});
await waitFor(() => {
expect(result.current.hasChanges).toBe(false);
expect(result.current.mapToTerms).toBe('synonym2');
});
});
it('should reset changes in equivalent when in create mode', async () => {
const { useSynonymRuleFlyoutState } = jest.requireActual('./use_flyout_state');
const { result } = renderHook(
() =>
useSynonymRuleFlyoutState({
synonymRule: {
synonyms: '',
},
flyoutMode: 'create',
}),
{ wrapper }
);
await waitFor(() => {
expect(result.current.hasChanges).toBe(false);
});
const { onCreateOption, resetChanges } = result.current;
act(() => {
onCreateOption('test');
});
await waitFor(() => {
expect(result.current.hasChanges).toBe(true);
expect(result.current.fromTerms).toEqual([expect.objectContaining({ label: 'test' })]);
});
act(() => {
resetChanges();
});
await waitFor(() => {
expect(result.current.hasChanges).toBe(false);
expect(result.current.fromTerms).toEqual([]);
});
});
it('should reset changes in explicit when in create mode', async () => {
const { useSynonymRuleFlyoutState } = jest.requireActual('./use_flyout_state');
const { result } = renderHook(
() =>
useSynonymRuleFlyoutState({
synonymRule: {
synonyms: '',
},
flyoutMode: 'create',
renderExplicit: true,
}),
{ wrapper }
);
await waitFor(() => {
expect(result.current.hasChanges).toBe(false);
});
const { onMapToChange, resetChanges, onCreateOption } = result.current;
act(() => {
onMapToChange('test');
onCreateOption('from');
});
await waitFor(() => {
expect(result.current.hasChanges).toBe(true);
expect(result.current.fromTerms).toEqual([expect.objectContaining({ label: 'from' })]);
expect(result.current.mapToTerms).toBe('test');
});
act(() => {
resetChanges();
});
await waitFor(() => {
expect(result.current.hasChanges).toBe(false);
expect(result.current.fromTerms).toEqual([]);
expect(result.current.mapToTerms).toBe('');
});
});
describe('fromTerms validation', () => {
it('should be invalid when fromTerms has multiple explicit separators', async () => {
const { useSynonymRuleFlyoutState } = jest.requireActual('./use_flyout_state');
const { result } = renderHook(
() =>
useSynonymRuleFlyoutState({
synonymRule: {
synonyms: '',
},
flyoutMode: 'create',
renderExplicit: true,
}),
{ wrapper }
);
const { onCreateOption, canSave } = result.current;
act(() => {
onCreateOption('from => term => term');
});
await waitFor(() => {
expect(result.current.isFromTermsInvalid).toBe(true);
expect(result.current.fromTermErrors).toEqual([
'Explicit separator "=>" is not allowed in terms.',
]);
expect(canSave).toBe(false);
});
});
it('should be invalid when search term exist in fromTerms', async () => {
const { useSynonymRuleFlyoutState } = jest.requireActual('./use_flyout_state');
const { result } = renderHook(
() =>
useSynonymRuleFlyoutState({
synonymRule: {
synonyms: 'search',
},
flyoutMode: 'edit',
}),
{ wrapper }
);
const { onCreateOption, canSave } = result.current;
act(() => {
onCreateOption('search');
});
await waitFor(() => {
expect(result.current.isFromTermsInvalid).toBe(true);
expect(result.current.fromTermErrors).toEqual(['Term already exists.']);
expect(canSave).toBe(false);
});
});
});
describe('mapToTerms validation', () => {
it('shoud be invalid when mapToTerms is empty', async () => {
const { useSynonymRuleFlyoutState } = jest.requireActual('./use_flyout_state');
const { result } = renderHook(
() =>
useSynonymRuleFlyoutState({
synonymRule: {
synonyms: '',
},
flyoutMode: 'create',
renderExplicit: true,
}),
{ wrapper }
);
const { onMapToChange, canSave } = result.current;
act(() => {
onMapToChange('');
});
await waitFor(() => {
expect(result.current.isMapToTermsInvalid).toBe(true);
expect(result.current.mapToTermErrors).toEqual(['Terms cannot be empty.']);
expect(canSave).toBe(false);
});
});
it('should be invalid when mapToTerms has explicit separators', async () => {
const { useSynonymRuleFlyoutState } = jest.requireActual('./use_flyout_state');
const { result } = renderHook(
() =>
useSynonymRuleFlyoutState({
synonymRule: {
synonyms: '',
},
flyoutMode: 'create',
renderExplicit: true,
}),
{ wrapper }
);
const { onMapToChange, canSave } = result.current;
act(() => {
onMapToChange('from => term => term');
});
await waitFor(() => {
expect(result.current.isMapToTermsInvalid).toBe(true);
expect(result.current.mapToTermErrors).toEqual([
'Explicit separator "=>" is not allowed in terms.',
]);
expect(canSave).toBe(false);
});
});
it('should be invalid when mapToTerms has empty values', async () => {
const { useSynonymRuleFlyoutState } = jest.requireActual('./use_flyout_state');
const { result } = renderHook(
() =>
useSynonymRuleFlyoutState({
synonymRule: {
synonyms: 'test => thing',
},
flyoutMode: 'edit',
}),
{ wrapper }
);
const { onMapToChange, canSave } = result.current;
act(() => {
onMapToChange('from,,term');
});
await waitFor(() => {
expect(result.current.isMapToTermsInvalid).toBe(true);
expect(result.current.mapToTermErrors).toEqual(['Terms cannot be empty.']);
expect(canSave).toBe(false);
});
});
});
});
});

View file

@ -0,0 +1,179 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiComboBoxOptionOption } from '@elastic/eui';
import { useState } from 'react';
import { SynonymsSynonymRule } from '@elastic/elasticsearch/lib/api/types';
import { synonymToComboBoxOption, synonymsOptionToString } from '../../utils/synonyms_utils';
import { ERROR_MESSAGES } from './constants';
export interface InitialFlyoutState {
synonymRule: SynonymsSynonymRule;
flyoutMode: 'create' | 'edit';
renderExplicit?: boolean;
}
type SortDirection = 'ascending' | 'descending';
export const useSynonymRuleFlyoutState = ({
synonymRule,
flyoutMode,
renderExplicit = false,
}: InitialFlyoutState) => {
const { parsedFromTerms, parsedToTermsString, parsedIsExplicit } = synonymToComboBoxOption(
flyoutMode === 'create' ? '' : synonymRule.synonyms
);
const sortedParsedFromTerms = [...parsedFromTerms].sort((a, b) => a.label.localeCompare(b.label));
const isExplicit = renderExplicit || parsedIsExplicit;
const [fromTerms, setFromTerms] = useState<EuiComboBoxOptionOption[]>(sortedParsedFromTerms);
const [mapToTerms, setMapToTerms] = useState<string>(parsedToTermsString);
const [isFromTermsInvalid, setIsFromTermsInvalid] = useState(false);
const [isMapToTermsInvalid, setIsMapToTermsInvalid] = useState(false);
const [fromTermErrors, setFromTermErrors] = useState<string[]>([]);
const [mapToTermErrors, setMapToTermErrors] = useState<string[]>([]);
const [currentSortDirection, setCurrentSortDirection] = useState<SortDirection>('ascending');
const hasChanges =
flyoutMode === 'create'
? (fromTerms.length !== 0 || mapToTerms.length !== 0) &&
synonymsOptionToString({
fromTerms,
toTerms: mapToTerms,
isExplicit,
}) !== synonymRule.synonyms
: synonymsOptionToString({
fromTerms,
toTerms: mapToTerms,
isExplicit,
}) !== synonymRule.synonyms;
const canSave =
fromTerms.length > 0 &&
!(isExplicit && !mapToTerms) &&
hasChanges &&
!isFromTermsInvalid &&
!isMapToTermsInvalid;
const resetChanges = () => {
setFromTerms(parsedFromTerms);
setMapToTerms(parsedToTermsString);
setIsFromTermsInvalid(false);
setIsMapToTermsInvalid(false);
setFromTermErrors([]);
setMapToTermErrors([]);
};
const isValid = (value: string) => {
const trimmedValue = value.trim();
if (value !== '' && trimmedValue === '') {
setIsFromTermsInvalid(true);
setFromTermErrors([ERROR_MESSAGES.empty_from_term]);
return false;
}
if (isExplicit && trimmedValue.includes('=>')) {
setIsFromTermsInvalid(true);
setFromTermErrors([ERROR_MESSAGES.multiple_explicit_separator]);
return false;
}
const exists = fromTerms.find((term) => term.label === value);
if (exists !== undefined) {
setFromTermErrors([ERROR_MESSAGES.term_exists]);
setIsFromTermsInvalid(true);
return false;
} else {
setIsFromTermsInvalid(false);
setFromTermErrors([]);
return true;
}
};
const isMapToValid = (value: string) => {
const trimmedValue = value.trim();
if (value !== '' && trimmedValue === '') {
setIsMapToTermsInvalid(true);
setMapToTermErrors([ERROR_MESSAGES.empty_to_term]);
return false;
}
if (trimmedValue.includes('=>')) {
setIsMapToTermsInvalid(true);
setMapToTermErrors([ERROR_MESSAGES.multiple_explicit_separator]);
return false;
}
if (trimmedValue.split(',').some((term) => term.trim() === '')) {
setIsMapToTermsInvalid(true);
setMapToTermErrors([ERROR_MESSAGES.empty_to_term]);
return false;
}
setIsMapToTermsInvalid(false);
return true;
};
const onSortTerms = (direction?: SortDirection) => {
if (!direction) {
direction = currentSortDirection === 'ascending' ? 'descending' : 'ascending';
}
fromTerms.sort((a, b) =>
direction === 'ascending' ? a.label.localeCompare(b.label) : b.label.localeCompare(a.label)
);
setCurrentSortDirection(direction);
setFromTerms([...fromTerms]);
};
const onSearchChange = (searchValue: string) => {
if (!searchValue) {
setIsFromTermsInvalid(false);
setFromTermErrors([]);
return;
}
setIsFromTermsInvalid(!isValid(searchValue));
};
const onCreateOption = (searchValue: string) => {
if (searchValue.trim() === '') {
return;
}
if (!isValid(searchValue)) {
return false;
}
setFromTerms([...fromTerms, { label: searchValue, key: searchValue }]);
};
const removeTermFromOptions = (term: EuiComboBoxOptionOption) => {
setFromTerms(fromTerms.filter((t) => t.label !== term.label));
};
const clearFromTerms = () => setFromTerms([]);
const onMapToChange = (value: string) => {
isMapToValid(value);
setMapToTerms(value);
};
return {
canSave,
clearFromTerms,
currentSortDirection,
fromTermErrors,
fromTerms,
hasChanges,
isExplicit,
isFromTermsInvalid,
isMapToTermsInvalid,
mapToTermErrors,
mapToTerms,
onCreateOption,
onMapToChange,
onSearchChange,
onSortTerms,
removeTermFromOptions,
resetChanges,
};
};

View file

@ -1,215 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiComboBox,
EuiComboBoxOptionOption,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiFormRow,
EuiHealth,
EuiText,
useGeneratedHtmlId,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { SynonymsSynonymRule } from '@elastic/elasticsearch/lib/api/types';
import {
getExplicitSynonym,
isExplicitSynonym,
synonymsOptionToString,
synonymsStringToOption,
} from '../../utils/synonyms_utils';
import { usePutSynonymsRule } from '../../hooks/use_put_synonyms_rule';
interface SynonymsRuleFlyoutProps {
onClose: () => void;
flyoutMode: 'create' | 'edit';
synonymsRule: SynonymsSynonymRule;
synonymsSetId: string;
renderExplicit?: boolean;
}
export const SynonymsRuleFlyout: React.FC<SynonymsRuleFlyoutProps> = ({
flyoutMode,
synonymsRule,
onClose,
renderExplicit = false,
synonymsSetId,
}) => {
const flyoutHeadingId = useGeneratedHtmlId({ prefix: 'synonymsRuleFlyoutHeading' });
const { mutate: putSynonymsRule } = usePutSynonymsRule(() => onClose());
const synonyms = synonymsRule.synonyms.trim();
const isExplicit = renderExplicit || isExplicitSynonym(synonyms);
const [from, to] =
flyoutMode === 'create' ? ['', ''] : isExplicit ? getExplicitSynonym(synonyms) : [synonyms, ''];
const [selectedFromTerms, setSelectedFromTerms] = useState<EuiComboBoxOptionOption[]>(
synonymsStringToOption(from)
);
const [selectedToTerms, setSelectedToTerms] = useState<EuiComboBoxOptionOption[]>(
synonymsStringToOption(to)
);
const hasChanges =
synonyms.trim() !==
synonymsOptionToString({ fromTerms: selectedFromTerms, toTerms: selectedToTerms, isExplicit });
return (
<EuiFlyout onClose={onClose} size="s">
<EuiFlyoutHeader hasBorder aria-labelledby={flyoutHeadingId}>
{flyoutMode === 'edit' ? (
<EuiText>
<b>
{i18n.translate('xpack.searchSynonyms.synonymsSetRuleFlyout.title.ruleId', {
defaultMessage: 'Rule ID: {ruleId}',
values: { ruleId: synonymsRule.id },
})}
</b>
</EuiText>
) : (
<EuiFormRow
label={i18n.translate('xpack.searchSynonyms.synonymsSetRuleFlyout.title.ruleId', {
defaultMessage: 'Rule ID',
})}
>
<EuiFieldText
data-test-subj="searchSynonymsSynonymsRuleFlyoutFieldText"
value={synonymsRule.id}
/>
</EuiFormRow>
)}
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiFormRow
label={i18n.translate('xpack.searchSynonyms.synonymsSetRuleFlyout.synonyms', {
defaultMessage: 'Add terms to match against',
})}
>
<EuiComboBox
fullWidth
title="Synonyms"
id="synonyms"
options={selectedFromTerms}
selectedOptions={selectedFromTerms}
onChange={(options) => {
setSelectedFromTerms(options);
}}
onCreateOption={(searchValue, options = []) => {
if (!searchValue.trim()) {
return;
}
if (!options.find((option) => option.label.trim() === searchValue.toLowerCase())) {
setSelectedFromTerms([
...selectedFromTerms,
{ label: searchValue, key: searchValue },
]);
}
}}
/>
</EuiFormRow>
{isExplicit && (
<EuiFormRow
label={i18n.translate('xpack.searchSynonyms.synonymsSetRuleFlyout.synonymsTo', {
defaultMessage: 'Map to this term',
})}
>
<EuiComboBox
fullWidth
title="Synonyms"
id="synonyms-to"
options={selectedToTerms}
selectedOptions={selectedToTerms}
onChange={(options) => {
setSelectedToTerms(options);
}}
onCreateOption={(searchValue, options = []) => {
if (!searchValue.trim()) {
return;
}
if (!options.find((option) => option.label.trim() === searchValue.toLowerCase())) {
setSelectedToTerms([
...selectedToTerms,
{ label: searchValue, key: searchValue },
]);
}
}}
/>
</EuiFormRow>
)}
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
{hasChanges && (
<EuiHealth color="primary">
{i18n.translate('xpack.searchSynonyms.synonymsSetRuleFlyout.unsavedChanges', {
defaultMessage: 'Synonym rule has unsaved changes',
})}
</EuiHealth>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="searchSynonymsSynonymsRuleFlyoutResetChangesButton"
iconType={'refresh'}
disabled={!hasChanges}
onClick={() => {
setSelectedFromTerms(synonymsStringToOption(from));
setSelectedToTerms(synonymsStringToOption(to));
}}
>
{i18n.translate('xpack.searchSynonyms.synonymsSetRuleFlyout.reset', {
defaultMessage: 'Reset changes',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="searchSynonymsSynonymsRuleFlyoutSaveButton"
fill
onClick={() => {
if (!synonymsRule.id) {
return;
}
putSynonymsRule({
synonymsSetId,
ruleId: synonymsRule.id,
synonyms: synonymsOptionToString({
fromTerms: selectedFromTerms,
toTerms: selectedToTerms,
isExplicit,
}),
});
}}
>
{i18n.translate('xpack.searchSynonyms.synonymsSetRuleFlyout.save', {
defaultMessage: 'Save',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
};

View file

@ -9,6 +9,7 @@ import React from 'react';
import { render, screen } from '@testing-library/react';
import { SynonymsSetRuleTable } from './synonyms_set_rule_table';
import { I18nProvider } from '@kbn/i18n-react';
jest.mock('../../hooks/use_fetch_synonyms_set', () => ({
useFetchSynonymsSet: () => ({
@ -64,7 +65,11 @@ jest.mock('../../hooks/use_put_synonyms_rule', () => ({
describe('SynonymSetDetail table', () => {
it('should render the list with synonym rules', () => {
render(<SynonymsSetRuleTable synonymsSetId="synonymSetId" />);
render(
<I18nProvider>
<SynonymsSetRuleTable synonymsSetId="synonymSetId" />
</I18nProvider>
);
const synonymSetTable = screen.getByTestId('synonyms-set-table');
expect(synonymSetTable).toBeInTheDocument();

View file

@ -27,9 +27,9 @@ import { getExplicitSynonym, isExplicitSynonym } from '../../utils/synonyms_util
import { DeleteSynonymRuleModal } from './delete_synonym_rule_modal';
import { SynonymsSetEmptyRuleTable } from './empty_rules_table';
import { SynonymsSetEmptyRulesCards } from './empty_rules_cards';
import { SynonymsRuleFlyout } from './synonyms_set_rule_flyout';
import { useFetchSynonymRule } from '../../hooks/use_fetch_synonym_rule';
import { useFetchGeneratedRuleId } from '../../hooks/use_fetch_generated_rule_id';
import { SynonymRuleFlyout } from '../synonyms_rule_flyout/synonym_rule_flyout';
export const SynonymsSetRuleTable = ({ synonymsSetId = '' }: { synonymsSetId: string }) => {
const [pageIndex, setPageIndex] = useState(0);
@ -42,7 +42,7 @@ export const SynonymsSetRuleTable = ({ synonymsSetId = '' }: { synonymsSetId: st
});
const [addNewRulePopoverOpen, setAddNewRulePopoverOpen] = useState(false);
const [isRuleFlyoutOpen, setIsRuleFlyoutOpen] = useState(false);
const [isRuleFlyoutOpen, setIsRuleFlyoutOpen] = useState(true);
const [synonymsRuleToEdit, setSynonymsRuleToEdit] = useState<string | null>(null);
const [generatedId, setGeneratedId] = useState<string | null>(null);
const { data: synonymsRule } = useFetchSynonymRule(synonymsSetId, synonymsRuleToEdit || '');
@ -74,7 +74,9 @@ export const SynonymsSetRuleTable = ({ synonymsSetId = '' }: { synonymsSetId: st
}),
render: (synonyms: string, synonymRule: SynonymsSynonymRule) => {
const isExplicit = isExplicitSynonym(synonyms);
const [explicitFrom = '', explicitTo = ''] = isExplicit ? getExplicitSynonym(synonyms) : [];
const { mapFromString: explicitFrom = '', mapToString: explicitTo = '' } = isExplicit
? getExplicitSynonym(synonyms)
: {};
return (
<EuiFlexGroup responsive={false}>
@ -187,7 +189,7 @@ export const SynonymsSetRuleTable = ({ synonymsSetId = '' }: { synonymsSetId: st
)}
{isRuleFlyoutOpen && generatedId ? (
<SynonymsRuleFlyout
<SynonymRuleFlyout
synonymsSetId={synonymsSetId}
onClose={() => {
setIsRuleFlyoutOpen(false);
@ -201,7 +203,7 @@ export const SynonymsSetRuleTable = ({ synonymsSetId = '' }: { synonymsSetId: st
/>
) : (
synonymsRule && (
<SynonymsRuleFlyout
<SynonymRuleFlyout
synonymsSetId={synonymsSetId}
onClose={() => {
setIsRuleFlyoutOpen(false);

View file

@ -24,13 +24,16 @@ describe('isExplicitSynonym util function', () => {
describe('getExplicitSynonym util function', () => {
it('should return an array with the explicit synonym', () => {
expect(getExplicitSynonym('synonym1 => synonym2')).toEqual(['synonym1', 'synonym2']);
expect(getExplicitSynonym('synonym1,synonym2, synonym5 => synonym2')).toEqual([
'synonym1,synonym2, synonym5',
'synonym2',
]);
expect(getExplicitSynonym('synonym1 => synonym2')).toEqual({
mapFromString: 'synonym1',
mapToString: 'synonym2',
});
expect(getExplicitSynonym('synonym1,synonym2, synonym5 => synonym2')).toEqual({
mapFromString: 'synonym1,synonym2, synonym5',
mapToString: 'synonym2',
});
expect(
getExplicitSynonym(' synonym1,synonym2, synonym5 => synonym2 ')
).toEqual(['synonym1,synonym2, synonym5', 'synonym2']);
).toEqual({ mapFromString: 'synonym1,synonym2, synonym5', mapToString: 'synonym2' });
});
});

View file

@ -13,7 +13,10 @@ export const isExplicitSynonym = (synonym: string) => {
};
export const getExplicitSynonym = (synonym: string) => {
return [synonym.split('=>')[0].trim(), synonym.split('=>')[1].trim()];
return {
mapFromString: synonym.split('=>')[0].trim(),
mapToString: synonym.split('=>')[1].trim(),
};
};
export const formatSynonymsSetName = (rawName: string) =>
@ -23,25 +26,46 @@ export const formatSynonymsSetName = (rawName: string) =>
.replace(/^[-]+|[-]+$/g, '') // Strip all leading and trailing dashes
.toLowerCase();
type SynonymsToComboBoxOption = (synonymString: string) => {
parsedFromTerms: EuiComboBoxOptionOption[];
parsedToTermsString: string;
parsedIsExplicit: boolean;
};
export const synonymToComboBoxOption: SynonymsToComboBoxOption = (synonymString: string) => {
const isExplicit = isExplicitSynonym(synonymString);
if (!isExplicit) {
return {
parsedFromTerms: synonymsStringToOption(synonymString),
parsedToTermsString: '',
parsedIsExplicit: isExplicit,
};
} else {
const { mapFromString, mapToString } = getExplicitSynonym(synonymString);
return {
parsedFromTerms: synonymsStringToOption(mapFromString),
parsedToTermsString: mapToString,
parsedIsExplicit: isExplicit,
};
}
};
export const synonymsStringToOption = (synonyms: string) =>
synonyms.length === 0
? []
: synonyms
.trim()
.split(',')
.map((s, index) => ({ label: s, key: index + '-' + s }));
.map((s, index) => ({ label: s, key: index + '-' + s.trim() }));
export const synonymsOptionToString = ({
fromTerms,
toTerms,
isExplicit,
}: {
fromTerms: EuiComboBoxOptionOption[];
toTerms: EuiComboBoxOptionOption[];
toTerms: string;
isExplicit: boolean;
}) =>
`${fromTerms.map((s) => s.label).join(',')}${
isExplicit ? ' => ' + toTerms.map((s) => s.label).join(',') : ''
}`;
}) => `${fromTerms.map((s) => s.label).join(',')}${isExplicit ? ' => ' + toTerms.trim() : ''}`;
export const isPermissionError = (error: { body: KibanaServerError }) => {
return error.body.statusCode === 403;