Repository: elastic/kibana
Pull request #213433: [Search] [Synonyms] Synonym Rule flyout update
Author: login: efegurkan
Labels: name: release_note:skip
description: Skip the PR/issue when compiling release notes
name: v9.0.0
name: Team:Search
name: ci:project-deploy-elasticsearch
description: Create an Elasticsearch Serverless project
name: backport:version
description: Backport to applied version labels
name: v8.18.0
name: v9.1.0
name: v8.19.0
name: v8.18.1
name: v9.0.1
Created: 2025-03-06T16:56:13Z | Merged: 2025-03-13T14:35:12Z

## 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)




<!--ONMERGE {"backportTargets":["8.18","8.x"]} ONMERGE-->


Diff:
diff --git a/x-pack/solutions/search/plugins/search_synonyms/public/components/overview/overview.tsx b/x-pack/solutions/search/plugins/search_synonyms/public/components/overview/overview.tsx
index 59028311047ca..a225f926cb844 100644
--- a/x-pack/solutions/search/plugins/search_synonyms/public/components/overview/overview.tsx
+++ b/x-pack/solutions/search/plugins/search_synonyms/public/components/overview/overview.tsx
@@ -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
diff --git a/x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/constants.ts b/x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/constants.ts
new file mode 100644
index 0000000000000..181226caf5e03
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/constants.ts
@@ -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.' }
+  ),
+};
diff --git a/x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/synonym_rule_flyout.test.tsx b/x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/synonym_rule_flyout.test.tsx
new file mode 100644
index 0000000000000..53a6cc59b7641
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/synonym_rule_flyout.test.tsx
@@ -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',
+      });
+    });
+  });
+});
diff --git a/x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/synonym_rule_flyout.tsx b/x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/synonym_rule_flyout.tsx
new file mode 100644
index 0000000000000..c3d1a5d39bb66
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/synonym_rule_flyout.tsx
@@ -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>
+  );
+};
diff --git a/x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/use_flyout_state.test.tsx b/x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/use_flyout_state.test.tsx
new file mode 100644
index 0000000000000..6c3bbe6de6465
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/use_flyout_state.test.tsx
@@ -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);
+        });
+      });
+    });
+  });
+});
diff --git a/x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/use_flyout_state.tsx b/x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/use_flyout_state.tsx
new file mode 100644
index 0000000000000..32f68026e8773
--- /dev/null
+++ b/x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/use_flyout_state.tsx
@@ -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,
+  };
+};
diff --git a/x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_set_detail/synonyms_set_rule_flyout.tsx b/x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_set_detail/synonyms_set_rule_flyout.tsx
deleted file mode 100644
index 06de1b6b49183..0000000000000
--- a/x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_set_detail/synonyms_set_rule_flyout.tsx
+++ /dev/null
@@ -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>
-  );
-};
diff --git a/x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_set_detail/synonyms_set_rule_table.test.tsx b/x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_set_detail/synonyms_set_rule_table.test.tsx
index c23934c2641af..c0f984fa2dfe4 100644
--- a/x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_set_detail/synonyms_set_rule_table.test.tsx
+++ b/x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_set_detail/synonyms_set_rule_table.test.tsx
@@ -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();
 
diff --git a/x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_set_detail/synonyms_set_rule_table.tsx b/x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_set_detail/synonyms_set_rule_table.tsx
index 6347892d48094..7bead3fa40bdc 100644
--- a/x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_set_detail/synonyms_set_rule_table.tsx
+++ b/x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_set_detail/synonyms_set_rule_table.tsx
@@ -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);
diff --git a/x-pack/solutions/search/plugins/search_synonyms/public/utils/synonyms_util.test.ts b/x-pack/solutions/search/plugins/search_synonyms/public/utils/synonyms_util.test.ts
index 1093314fac386..87c91ed58de4a 100644
--- a/x-pack/solutions/search/plugins/search_synonyms/public/utils/synonyms_util.test.ts
+++ b/x-pack/solutions/search/plugins/search_synonyms/public/utils/synonyms_util.test.ts
@@ -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' });
   });
 });
diff --git a/x-pack/solutions/search/plugins/search_synonyms/public/utils/synonyms_utils.ts b/x-pack/solutions/search/plugins/search_synonyms/public/utils/synonyms_utils.ts
index 59a1c2fc802d9..6f75ec54abaed 100644
--- a/x-pack/solutions/search/plugins/search_synonyms/public/utils/synonyms_utils.ts
+++ b/x-pack/solutions/search/plugins/search_synonyms/public/utils/synonyms_utils.ts
@@ -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;


Reviews:
user: login: leemthompo
body: Some minor copy tweaks :-)
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T13:39:00Z
user: login: Samiul-TheSoccerFan
body: Overall look good. Left some comments. 
I could not test locally although I see `xpack.searchSynonyms.enabled: true` in `serverless.es.yml` and tried to logged in as both `admin` and `developer`. Am I missing something?


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T18:02:23Z
user: login: efegurkan
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T12:35:52Z
user: login: efegurkan
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T12:38:58Z
user: login: efegurkan
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T12:41:07Z
user: login: efegurkan
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T12:44:28Z
user: login: efegurkan
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T12:45:48Z
user: login: mdefazio
body: Thanks Efe, a few things i'm noticing:

1. When I save a rule / close the flyout, opening it up again should apply the sort order to alphabetical (a-z).  
2. We should provide the option to flip to reverse alphabetical as well when hitting sort (z-a)
3. I think we'll want a max-height and scroll for the terms block so the explicit input doesn't get pushed out of view.  Perhaps this is only necessary for that rule type and we can just let it scroll naturally for the equivalent type.  
4. When there are many terms, the explicit term input seems to be getting cut off at the bottom. (See screenshot 1)
5. Let's add `outsideClickCloses={false}` to the `EuiFlyout` to avoid users accidentally clicking outside it and losing work 😬 
6. When I delete all the rules in a synonym set, the rule itself is also removed from the list.  I understand this requirement.  However, it seems it is still registering the name somehow and it won't let me create a set with the same naming—saying it still exists. (See screenshot 2)
7. A similar situation happens when creating a new one. We show a toast that it was created, but it gets deleted without a warning if I leave the page. (See video 3)

For these last two points, I think the expectation is that we show a modal when leaving the page much like we do in dashboards or visualizations. We can create a follow up issue for this though as I can imagine it might be outside the scope of this flyout PR.

// 1
![CleanShot 2025-03-11 at 09 19 30@2x](https://github.com/user-attachments/assets/4be4837f-d527-41bc-bd87-79892a9c929d)

// 2
![CleanShot 2025-03-11 at 09 29 46@2x](https://github.com/user-attachments/assets/a0ca1ff4-abdf-4052-ace8-b0be42e7fa3c)

// 3
https://github.com/user-attachments/assets/21160571-4e4e-498e-a7f0-3b33c2ea2950



state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T13:39:28Z
user: login: Samiul-TheSoccerFan
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T18:42:31Z
user: login: Samiul-TheSoccerFan
body: Code changes look good. I have few questions related to the flow and design:

1. When opening an existing rule, the message "Synonym rule has unsaved changes" appears, along with the "Reset changes" button, and the "Save" button is active. Should these buttons be disabled and the message removed if no actual changes have been made? (Screenshot attached.)
![Screenshot 2025-03-12 at 1 22 23 PM](https://github.com/user-attachments/assets/42e95d47-f7a3-42c1-bcac-fd80f7443635)

2. When adding an existing term, an error message appears. However, even after removing all items, the error message persists. Is this expected behavior?
![Screenshot 2025-03-12 at 1 25 50 PM](https://github.com/user-attachments/assets/619acbd2-5dc5-4b3e-aa2e-d0de216b8729)
![Screenshot 2025-03-12 at 1 26 05 PM](https://github.com/user-attachments/assets/1def727c-470a-4648-80a8-94ffee77ca7e)

3. ENTER key behavior: Pressing ENTER in the From field adds the term, but it is disabled for the To field. Is this intentional? Also, do we assume users will intuitively press ENTER, or should there be some indication or message to guide them?

4. "Term not empty" error message: Should this be fixed?
![Screenshot 2025-03-12 at 1 30 54 PM](https://github.com/user-attachments/assets/d50ec1ee-fae7-4a92-ac7f-173bba70a58f)
![Screenshot 2025-03-12 at 1 31 10 PM](https://github.com/user-attachments/assets/620ef57d-e4c5-48d5-8e54-5e2b78b55aed)

Other than #4, I do not think any of these are pressing concerns however I will let @mdefazio and @efegurkan to decide.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T17:43:18Z
user: login: TattdCodeMonkey
body: Some non-blocking nitpick comments, but overall this LGTM.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T20:06:39Z
user: login: TattdCodeMonkey
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T20:09:24Z
user: login: efegurkan
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T11:34:17Z
user: login: efegurkan
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T11:40:46Z
user: login: Samiul-TheSoccerFan
body: Code changes LGTM. Thank you for the iterations and responding to the questions. 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T14:16:16Z

Review comments:
diff_hunk: @@ -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: 'Cannot have empty terms.',
path: x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/constants.ts
user: login: leemthompo
body: ```suggestion
    defaultMessage: 'Terms cannot be empty.',
```
created_at: 2025-03-10T13:35:24Z
author_association: MEMBER
diff_hunk: @@ -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: 'Cannot have empty terms.',
+  }),
+  term_exists: i18n.translate('xpack.searchSynonyms.synonymsSetRuleFlyout.termExists', {
+    defaultMessage: 'Term already exists',
+  }),
+  multiple_explicit_separator: i18n.translate(
+    'xpack.searchSynonyms.synonymsSetRuleFlyout.invalidMapTo',
+    { defaultMessage: 'Cannot have explicit separator "=>" in terms.' }
path: x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/constants.ts
user: login: leemthompo
body: ```suggestion
    { defaultMessage: 'Explicit separator "=>" is not allowed in terms.' }
```
created_at: 2025-03-10T13:36:04Z
author_association: MEMBER
diff_hunk: @@ -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: 'Cannot have empty terms.',
+  }),
+  term_exists: i18n.translate('xpack.searchSynonyms.synonymsSetRuleFlyout.termExists', {
+    defaultMessage: 'Term already exists',
path: x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/constants.ts
user: login: leemthompo
body: ```suggestion
    defaultMessage: 'Term already exists.',
```
created_at: 2025-03-10T13:36:39Z
author_association: MEMBER
diff_hunk: @@ -46,7 +46,7 @@ export const SearchSynonymsOverview = () => {
       solutionNav={searchNavigation?.useClassicNavigation(history)}
       color="primary"
     >
-      {synonymsData && !isInitialLoading && !isError && (
+      {synonymsData && !isInitialLoading && !isError && synonymsData._meta.totalItemCount !== 0 && (
path: x-pack/solutions/search/plugins/search_synonyms/public/components/overview/overview.tsx
user: login: Samiul-TheSoccerFan
body: How about `!isInitialLoading && !isError && synonymsData?._meta.totalItemCount !== 0`?
created_at: 2025-03-10T15:46:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,352 @@
+/*
+ * 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 { useFlyoutState } 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,
+    fromTermErrors,
+    fromTerms,
+    hasChanges,
+    isExplicit,
+    isFromTermsInvalid,
+    isMapToTermsInvalid,
+    mapToTermErrors,
+    mapToTerms,
+    clearFromTerms,
+    onCreateOption,
+    onMapToChange,
+    onSearchChange,
+    onSortTerms,
+    removeTermFromOptions,
+    resetChanges,
+  } = useFlyoutState({
+    synonymRule: synonymsRule,
+    flyoutMode,
+    renderExplicit,
+  });
+
+  return (
+    <EuiFlyout onClose={onClose} size="s">
+      <EuiFlyoutHeader hasBorder>
+        {flyoutMode === 'edit' ? (
+          <EuiText data-test-subj="searchSynonymsSynonymRuleFlyoutEditModeRuleIdText">
+            <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="searchSynonymsSynonymRuleFlyoutGeneratedIdText"
+              value={synonymsRule.id}
+              readOnly
+            />
+          </EuiFormRow>
+        )}
+      </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"
+          css={css`
+            height: 100%;
+          `}
+        >
+          <EuiFlexItem grow={false}>
+            <EuiFormRow
+              label={i18n.translate('xpack.searchSynonyms.synonymsSetRuleFlyout.synonyms', {
+                defaultMessage: 'Add terms to match against',
+              })}
+              isInvalid={isFromTermsInvalid}
+              error={fromTermErrors || null}
+            >
+              <EuiComboBox
+                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">
+              <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="sortUp"
+                    >
+                      <FormattedMessage
+                        id="xpack.searchSynonyms.synonymsSetRuleFlyout.sortAZ"
+                        defaultMessage="Sort A-Z"
+                      />
+                    </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>
+
+          <EuiFlexItem>
+            <EuiFlexGroup
+              direction="column"
+              gutterSize="xs"
+              tabIndex={0}
+              className="eui-yScrollWithShadows"
+              css={css`
+                height: 100%;
+                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"
+                  >
+                    {' '}
+                    {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
+                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>
+              <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}
path: x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/synonym_rule_flyout.tsx
user: login: Samiul-TheSoccerFan
body: should we keep it disabled if `synonymsRule.id` is not available?
created_at: 2025-03-10T16:04:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,362 @@
+/*
+ * 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 = {
+    GeneratedIdText: 'searchSynonymsSynonymRuleFlyoutGeneratedIdText',
+    EditModeRuleIdText: 'searchSynonymsSynonymRuleFlyoutEditModeRuleIdText',
+    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.getByTestId(TEST_IDS.GeneratedIdText)).toHaveValue('generated-id');
+      expect(screen.queryByTestId(TEST_IDS.EditModeRuleIdText)).not.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)).toBeInTheDocument();
path: x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/synonyms_rule_flyout.test.tsx
user: login: Samiul-TheSoccerFan
body: I think the `save` button renders without any condition so it might be worth to check if the button is disabled (enable) depending on the scenario.
created_at: 2025-03-10T16:19:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,362 @@
+/*
+ * 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 = {
+    GeneratedIdText: 'searchSynonymsSynonymRuleFlyoutGeneratedIdText',
+    EditModeRuleIdText: 'searchSynonymsSynonymRuleFlyoutEditModeRuleIdText',
+    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.getByTestId(TEST_IDS.GeneratedIdText)).toHaveValue('generated-id');
+      expect(screen.queryByTestId(TEST_IDS.EditModeRuleIdText)).not.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)).toBeInTheDocument();
+    });
+    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.getByTestId(TEST_IDS.GeneratedIdText)).toHaveValue('generated-id');
+      expect(screen.queryByTestId(TEST_IDS.EditModeRuleIdText)).not.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)).toBeInTheDocument();
path: x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/synonyms_rule_flyout.test.tsx
user: login: Samiul-TheSoccerFan
body: Same as above and throughout the remaining tests...
created_at: 2025-03-10T16:24:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,362 @@
+/*
+ * 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 = {
+    GeneratedIdText: 'searchSynonymsSynonymRuleFlyoutGeneratedIdText',
+    EditModeRuleIdText: 'searchSynonymsSynonymRuleFlyoutEditModeRuleIdText',
+    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.getByTestId(TEST_IDS.GeneratedIdText)).toHaveValue('generated-id');
+      expect(screen.queryByTestId(TEST_IDS.EditModeRuleIdText)).not.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)).toBeInTheDocument();
+    });
+    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.getByTestId(TEST_IDS.GeneratedIdText)).toHaveValue('generated-id');
+      expect(screen.queryByTestId(TEST_IDS.EditModeRuleIdText)).not.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)).toBeInTheDocument();
+    });
+
+    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>
+      );
+      ACTIONS.AddFromTerm('from1');
+      expect(screen.getByTestId(TEST_IDS.FromTermCountLabel).textContent).toBe('1 term');
+
+      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');
+
path: x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/synonyms_rule_flyout.test.tsx
user: login: Samiul-TheSoccerFan
body: nitpick: WDYT about adding check to verify if `save` button is still disabled for explicit?
created_at: 2025-03-10T16:33:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,362 @@
+/*
+ * 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 = {
+    GeneratedIdText: 'searchSynonymsSynonymRuleFlyoutGeneratedIdText',
+    EditModeRuleIdText: 'searchSynonymsSynonymRuleFlyoutEditModeRuleIdText',
+    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.getByTestId(TEST_IDS.GeneratedIdText)).toHaveValue('generated-id');
+      expect(screen.queryByTestId(TEST_IDS.EditModeRuleIdText)).not.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)).toBeInTheDocument();
+    });
+    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.getByTestId(TEST_IDS.GeneratedIdText)).toHaveValue('generated-id');
+      expect(screen.queryByTestId(TEST_IDS.EditModeRuleIdText)).not.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)).toBeInTheDocument();
+    });
+
+    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>
+      );
+      ACTIONS.AddFromTerm('from1');
+      expect(screen.getByTestId(TEST_IDS.FromTermCountLabel).textContent).toBe('1 term');
+
+      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');
+
+      ACTIONS.AddMapToTerm('to1');
+      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('b');
+      ACTIONS.AddFromTerm('a');
+
+      expect(screen.getByTestId(TEST_IDS.FromTermCountLabel).textContent).toBe('2 terms');
+      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.EditModeRuleIdText).textContent).toBe(
+        'Rule ID: rule_id_3'
+      );
+      expect(screen.queryByTestId(TEST_IDS.GeneratedIdText)).not.toBeInTheDocument();
+      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)).toBeInTheDocument();
+    });
+
+    it('should call backend with correct payload for equivalent synonyms', () => {
path: x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/synonyms_rule_flyout.test.tsx
user: login: Samiul-TheSoccerFan
body: To clarify, for each rule set, we can add or delete a rule but can not modify a rule itself? If yes, is it possible to cover a scenario where we delete an existing and add a new one?
created_at: 2025-03-10T16:43:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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(),
+  };
path: x-pack/solutions/search/plugins/search_synonyms/public/utils/synonyms_utils.ts
user: login: Samiul-TheSoccerFan
body: How about 
```
export const getExplicitSynonym = (synonym: string) => {
  const parts = synonym.split('=>').map((s) => s.trim());

  return {
    mapFromString: parts[0] || '',
    mapToString: parts[1] || '',
  };
};
```
created_at: 2025-03-10T17:09:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,172 @@
+/*
+ * 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';
path: x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/use_flyout_state.ts
user: login: Samiul-TheSoccerFan
body: nitpick: Should we declare this as global enum?
created_at: 2025-03-10T17:19:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,172 @@
+/*
+ * 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;
+}
+
+export const useFlyoutState = ({
+  synonymRule,
+  flyoutMode,
+  renderExplicit = false,
+}: InitialFlyoutState) => {
+  const { parsedFromTerms, parsedToTermsString, parsedIsExplicit } = synonymToComboBoxOption(
+    flyoutMode === 'create' ? '' : synonymRule.synonyms
+  );
+
+  const isExplicit = renderExplicit || parsedIsExplicit;
+
+  const [fromTerms, setFromTerms] = useState<EuiComboBoxOptionOption[]>(parsedFromTerms);
+
+  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 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 &&
path: x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/use_flyout_state.ts
user: login: Samiul-TheSoccerFan
body: nitpick: `fromTerms.length > 0`
created_at: 2025-03-10T17:37:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,172 @@
+/*
+ * 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;
+}
+
+export const useFlyoutState = ({
+  synonymRule,
+  flyoutMode,
+  renderExplicit = false,
+}: InitialFlyoutState) => {
+  const { parsedFromTerms, parsedToTermsString, parsedIsExplicit } = synonymToComboBoxOption(
+    flyoutMode === 'create' ? '' : synonymRule.synonyms
+  );
+
+  const isExplicit = renderExplicit || parsedIsExplicit;
+
+  const [fromTerms, setFromTerms] = useState<EuiComboBoxOptionOption[]>(parsedFromTerms);
+
+  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 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 &&
+    !(isExplicit && !mapToTerms) &&
+    hasChanges &&
+    !isFromTermsInvalid &&
+    !isMapToTermsInvalid;
+
+  const resetChanges = () => {
+    setFromTerms(parsedFromTerms);
+    setMapToTerms(parsedToTermsString);
+    setIsFromTermsInvalid(false);
+    setIsMapToTermsInvalid(false);
+    setFromTermErrors([]);
+    setMapToTermErrors([]);
+  };
+
+  const isValid = (value: string) => {
+    if (value !== '' && value.trim() === '') {
path: x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/use_flyout_state.ts
user: login: Samiul-TheSoccerFan
body: what happens if `value` is empty?

Also, we could write something like this:
```
const trimmedValue = value.trim();

if (trimmedValue === '')
```
created_at: 2025-03-10T17:40:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,172 @@
+/*
+ * 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;
+}
+
+export const useFlyoutState = ({
+  synonymRule,
+  flyoutMode,
+  renderExplicit = false,
+}: InitialFlyoutState) => {
+  const { parsedFromTerms, parsedToTermsString, parsedIsExplicit } = synonymToComboBoxOption(
+    flyoutMode === 'create' ? '' : synonymRule.synonyms
+  );
+
+  const isExplicit = renderExplicit || parsedIsExplicit;
+
+  const [fromTerms, setFromTerms] = useState<EuiComboBoxOptionOption[]>(parsedFromTerms);
+
+  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 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 &&
+    !(isExplicit && !mapToTerms) &&
+    hasChanges &&
+    !isFromTermsInvalid &&
+    !isMapToTermsInvalid;
+
+  const resetChanges = () => {
+    setFromTerms(parsedFromTerms);
+    setMapToTerms(parsedToTermsString);
+    setIsFromTermsInvalid(false);
+    setIsMapToTermsInvalid(false);
+    setFromTermErrors([]);
+    setMapToTermErrors([]);
+  };
+
+  const isValid = (value: string) => {
+    if (value !== '' && value.trim() === '') {
+      setIsFromTermsInvalid(true);
+      setFromTermErrors([ERROR_MESSAGES.empty_from_term]);
+      return false;
+    }
+
+    if (
+      isExplicit &&
+      value
+        .trim()
+        .split(',')
+        .some((term) => term.trim().includes('=>'))
path: x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/use_flyout_state.ts
user: login: Samiul-TheSoccerFan
body: nitpick: `isExplicit && trimmedValue.includes('=>')`. Is `split` necessary in here?
created_at: 2025-03-10T17:41:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,172 @@
+/*
+ * 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;
+}
+
+export const useFlyoutState = ({
+  synonymRule,
+  flyoutMode,
+  renderExplicit = false,
+}: InitialFlyoutState) => {
+  const { parsedFromTerms, parsedToTermsString, parsedIsExplicit } = synonymToComboBoxOption(
+    flyoutMode === 'create' ? '' : synonymRule.synonyms
+  );
+
+  const isExplicit = renderExplicit || parsedIsExplicit;
+
+  const [fromTerms, setFromTerms] = useState<EuiComboBoxOptionOption[]>(parsedFromTerms);
+
+  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 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 &&
+    !(isExplicit && !mapToTerms) &&
+    hasChanges &&
+    !isFromTermsInvalid &&
+    !isMapToTermsInvalid;
+
+  const resetChanges = () => {
+    setFromTerms(parsedFromTerms);
+    setMapToTerms(parsedToTermsString);
+    setIsFromTermsInvalid(false);
+    setIsMapToTermsInvalid(false);
+    setFromTermErrors([]);
+    setMapToTermErrors([]);
+  };
+
+  const isValid = (value: string) => {
+    if (value !== '' && value.trim() === '') {
+      setIsFromTermsInvalid(true);
+      setFromTermErrors([ERROR_MESSAGES.empty_from_term]);
+      return false;
+    }
+
+    if (
+      isExplicit &&
+      value
+        .trim()
+        .split(',')
+        .some((term) => term.trim().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) => {
+    if (value !== '' && value.trim() === '') {
path: x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/use_flyout_state.ts
user: login: Samiul-TheSoccerFan
body: same question as above..
created_at: 2025-03-10T17:48:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,172 @@
+/*
+ * 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;
+}
+
+export const useFlyoutState = ({
+  synonymRule,
+  flyoutMode,
+  renderExplicit = false,
+}: InitialFlyoutState) => {
+  const { parsedFromTerms, parsedToTermsString, parsedIsExplicit } = synonymToComboBoxOption(
+    flyoutMode === 'create' ? '' : synonymRule.synonyms
+  );
+
+  const isExplicit = renderExplicit || parsedIsExplicit;
+
+  const [fromTerms, setFromTerms] = useState<EuiComboBoxOptionOption[]>(parsedFromTerms);
+
+  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 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 &&
+    !(isExplicit && !mapToTerms) &&
+    hasChanges &&
+    !isFromTermsInvalid &&
+    !isMapToTermsInvalid;
+
+  const resetChanges = () => {
+    setFromTerms(parsedFromTerms);
+    setMapToTerms(parsedToTermsString);
+    setIsFromTermsInvalid(false);
+    setIsMapToTermsInvalid(false);
+    setFromTermErrors([]);
+    setMapToTermErrors([]);
+  };
+
+  const isValid = (value: string) => {
+    if (value !== '' && value.trim() === '') {
+      setIsFromTermsInvalid(true);
+      setFromTermErrors([ERROR_MESSAGES.empty_from_term]);
+      return false;
+    }
+
+    if (
+      isExplicit &&
+      value
+        .trim()
+        .split(',')
+        .some((term) => term.trim().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) => {
+    if (value !== '' && value.trim() === '') {
+      setIsMapToTermsInvalid(true);
+      setMapToTermErrors([ERROR_MESSAGES.empty_to_term]);
+      return false;
+    }
+    if (value.trim().includes('=>')) {
+      setIsMapToTermsInvalid(true);
+      setMapToTermErrors([ERROR_MESSAGES.multiple_explicit_separator]);
+      return false;
+    }
+    if (value.trim().split(',').includes('') || value.trim().split(',').includes(' ')) {
path: x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/use_flyout_state.ts
user: login: Samiul-TheSoccerFan
body: nitpick: `trimmedValue.split(',').some((term) => term.trim() === '')`
created_at: 2025-03-10T17:48:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,352 @@
+/*
+ * 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 { useFlyoutState } 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,
+    fromTermErrors,
+    fromTerms,
+    hasChanges,
+    isExplicit,
+    isFromTermsInvalid,
+    isMapToTermsInvalid,
+    mapToTermErrors,
+    mapToTerms,
+    clearFromTerms,
+    onCreateOption,
+    onMapToChange,
+    onSearchChange,
+    onSortTerms,
+    removeTermFromOptions,
+    resetChanges,
+  } = useFlyoutState({
+    synonymRule: synonymsRule,
+    flyoutMode,
+    renderExplicit,
+  });
+
+  return (
+    <EuiFlyout onClose={onClose} size="s">
+      <EuiFlyoutHeader hasBorder>
+        {flyoutMode === 'edit' ? (
+          <EuiText data-test-subj="searchSynonymsSynonymRuleFlyoutEditModeRuleIdText">
+            <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="searchSynonymsSynonymRuleFlyoutGeneratedIdText"
+              value={synonymsRule.id}
+              readOnly
+            />
+          </EuiFormRow>
+        )}
+      </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"
+          css={css`
+            height: 100%;
+          `}
+        >
+          <EuiFlexItem grow={false}>
+            <EuiFormRow
+              label={i18n.translate('xpack.searchSynonyms.synonymsSetRuleFlyout.synonyms', {
+                defaultMessage: 'Add terms to match against',
+              })}
+              isInvalid={isFromTermsInvalid}
+              error={fromTermErrors || null}
+            >
+              <EuiComboBox
+                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">
+              <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="sortUp"
+                    >
+                      <FormattedMessage
+                        id="xpack.searchSynonyms.synonymsSetRuleFlyout.sortAZ"
+                        defaultMessage="Sort A-Z"
+                      />
+                    </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>
+
+          <EuiFlexItem>
+            <EuiFlexGroup
+              direction="column"
+              gutterSize="xs"
+              tabIndex={0}
+              className="eui-yScrollWithShadows"
+              css={css`
+                height: 100%;
+                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"
+                  >
+                    {' '}
+                    {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
+                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>
+              <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}
path: x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/synonym_rule_flyout.tsx
user: login: efegurkan
body: It is not necessary, as it would always have an id. 
created_at: 2025-03-11T12:35:52Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,362 @@
+/*
+ * 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 = {
+    GeneratedIdText: 'searchSynonymsSynonymRuleFlyoutGeneratedIdText',
+    EditModeRuleIdText: 'searchSynonymsSynonymRuleFlyoutEditModeRuleIdText',
+    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.getByTestId(TEST_IDS.GeneratedIdText)).toHaveValue('generated-id');
+      expect(screen.queryByTestId(TEST_IDS.EditModeRuleIdText)).not.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)).toBeInTheDocument();
+    });
+    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.getByTestId(TEST_IDS.GeneratedIdText)).toHaveValue('generated-id');
+      expect(screen.queryByTestId(TEST_IDS.EditModeRuleIdText)).not.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)).toBeInTheDocument();
+    });
+
+    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>
+      );
+      ACTIONS.AddFromTerm('from1');
+      expect(screen.getByTestId(TEST_IDS.FromTermCountLabel).textContent).toBe('1 term');
+
+      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');
+
+      ACTIONS.AddMapToTerm('to1');
+      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('b');
+      ACTIONS.AddFromTerm('a');
+
+      expect(screen.getByTestId(TEST_IDS.FromTermCountLabel).textContent).toBe('2 terms');
+      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.EditModeRuleIdText).textContent).toBe(
+        'Rule ID: rule_id_3'
+      );
+      expect(screen.queryByTestId(TEST_IDS.GeneratedIdText)).not.toBeInTheDocument();
+      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)).toBeInTheDocument();
+    });
+
+    it('should call backend with correct payload for equivalent synonyms', () => {
path: x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/synonyms_rule_flyout.test.tsx
user: login: efegurkan
body: It is an upsert only. You cannot delete the rule. deleting everything and adding something new is eventually same thing as editing.
created_at: 2025-03-11T12:38:58Z
author_association: MEMBER
diff_hunk: @@ -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(),
+  };
path: x-pack/solutions/search/plugins/search_synonyms/public/utils/synonyms_utils.ts
user: login: efegurkan
body: It would work exactly the same. Is there a reason for your suggestion that I cannot see ?
created_at: 2025-03-11T12:41:07Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,172 @@
+/*
+ * 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;
+}
+
+export const useFlyoutState = ({
+  synonymRule,
+  flyoutMode,
+  renderExplicit = false,
+}: InitialFlyoutState) => {
+  const { parsedFromTerms, parsedToTermsString, parsedIsExplicit } = synonymToComboBoxOption(
+    flyoutMode === 'create' ? '' : synonymRule.synonyms
+  );
+
+  const isExplicit = renderExplicit || parsedIsExplicit;
+
+  const [fromTerms, setFromTerms] = useState<EuiComboBoxOptionOption[]>(parsedFromTerms);
+
+  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 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 &&
+    !(isExplicit && !mapToTerms) &&
+    hasChanges &&
+    !isFromTermsInvalid &&
+    !isMapToTermsInvalid;
+
+  const resetChanges = () => {
+    setFromTerms(parsedFromTerms);
+    setMapToTerms(parsedToTermsString);
+    setIsFromTermsInvalid(false);
+    setIsMapToTermsInvalid(false);
+    setFromTermErrors([]);
+    setMapToTermErrors([]);
+  };
+
+  const isValid = (value: string) => {
+    if (value !== '' && value.trim() === '') {
path: x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/use_flyout_state.ts
user: login: efegurkan
body: if the value is empty, we should not consider it in invalid state and paint the UI in red. So we check if value is not empty and filled with whitespaces. 
created_at: 2025-03-11T12:44:28Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,172 @@
+/*
+ * 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;
+}
+
+export const useFlyoutState = ({
+  synonymRule,
+  flyoutMode,
+  renderExplicit = false,
+}: InitialFlyoutState) => {
+  const { parsedFromTerms, parsedToTermsString, parsedIsExplicit } = synonymToComboBoxOption(
+    flyoutMode === 'create' ? '' : synonymRule.synonyms
+  );
+
+  const isExplicit = renderExplicit || parsedIsExplicit;
+
+  const [fromTerms, setFromTerms] = useState<EuiComboBoxOptionOption[]>(parsedFromTerms);
+
+  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 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 &&
+    !(isExplicit && !mapToTerms) &&
+    hasChanges &&
+    !isFromTermsInvalid &&
+    !isMapToTermsInvalid;
+
+  const resetChanges = () => {
+    setFromTerms(parsedFromTerms);
+    setMapToTerms(parsedToTermsString);
+    setIsFromTermsInvalid(false);
+    setIsMapToTermsInvalid(false);
+    setFromTermErrors([]);
+    setMapToTermErrors([]);
+  };
+
+  const isValid = (value: string) => {
+    if (value !== '' && value.trim() === '') {
+      setIsFromTermsInvalid(true);
+      setFromTermErrors([ERROR_MESSAGES.empty_from_term]);
+      return false;
+    }
+
+    if (
+      isExplicit &&
+      value
+        .trim()
+        .split(',')
+        .some((term) => term.trim().includes('=>'))
path: x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/use_flyout_state.ts
user: login: efegurkan
body: good catch.
created_at: 2025-03-11T12:45:48Z
author_association: MEMBER
diff_hunk: @@ -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(),
+  };
path: x-pack/solutions/search/plugins/search_synonyms/public/utils/synonyms_utils.ts
user: login: Samiul-TheSoccerFan
body: A little cleaner approach but definitely not blocking.
created_at: 2025-03-11T18:42:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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 useFlyoutState = ({
+  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([]);
path: x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/use_flyout_state.tsx
user: login: Samiul-TheSoccerFan
body: Do we need to reset sort direction as well?
created_at: 2025-03-12T17:07:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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 useFlyoutState = ({
+  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 === '') {
path: x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/use_flyout_state.tsx
user: login: Samiul-TheSoccerFan
body: Do not need to check `value !== ''` anymore
created_at: 2025-03-12T17:08:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,352 @@
+/*
+ * 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 { useFlyoutState } 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,
+  } = useFlyoutState({
+    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"
+          css={css`
+            height: 100%;
+          `}
+        >
+          <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>
+
+          <EuiFlexItem
+            css={css`
+              max-height: 65%;
+            `}
+          >
+            <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"
+                  >
+                    {' '}
path: x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/synonym_rule_flyout.tsx
user: login: TattdCodeMonkey
body: nitpick: do we need this? should it be `&nbsp;` or better add the space with CSS?
created_at: 2025-03-12T19:53:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,352 @@
+/*
+ * 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 { useFlyoutState } 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,
+  } = useFlyoutState({
+    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"
+          css={css`
+            height: 100%;
+          `}
+        >
+          <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>
+
+          <EuiFlexItem
+            css={css`
+              max-height: 65%;
+            `}
+          >
+            <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"
+                  >
+                    {' '}
+                    {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'}
path: x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/synonym_rule_flyout.tsx
user: login: TattdCodeMonkey
body: nitpick: 
```suggestion
                  iconType="refresh"
```
created_at: 2025-03-12T19:53:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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 useFlyoutState = ({
path: x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/use_flyout_state.tsx
user: login: TattdCodeMonkey
body: nitpick: 
```suggestion
export const useSynonymRuleFlyoutState = ({
```
created_at: 2025-03-12T19:59:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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 useFlyoutState = ({
+  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 === '') {
path: x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/use_flyout_state.tsx
user: login: efegurkan
body: https://github.com/elastic/kibana/pull/213433#discussion_r1989183420
created_at: 2025-03-13T11:34:17Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,352 @@
+/*
+ * 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 { useFlyoutState } 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,
+  } = useFlyoutState({
+    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"
+          css={css`
+            height: 100%;
+          `}
+        >
+          <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>
+
+          <EuiFlexItem
+            css={css`
+              max-height: 65%;
+            `}
+          >
+            <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"
+                  >
+                    {' '}
path: x-pack/solutions/search/plugins/search_synonyms/public/components/synonyms_rule_flyout/synonym_rule_flyout.tsx
user: login: efegurkan
body: Normally I convert those to `&nbsp;` I forgot this. It is necessary otherwise the button and the `x` icon is too close to each other.
created_at: 2025-03-13T11:40:46Z
author_association: MEMBER

PR comments:
user: login: efegurkan
created_at: 2025-03-10T11:14:39Z
body: @elasticmachine merge upstream
author_association: MEMBER
user: login: efegurkan
created_at: 2025-03-11T13:17:48Z
body: @Samiul-TheSoccerFan 
You have to enable the feature flag
`POST kbn:/internal/kibana/settings/searchSynonyms:synonymsEnabled
{"value": true}`
author_association: MEMBER
user: login: efegurkan
created_at: 2025-03-11T13:18:02Z
body: @elasticmachine merge upstream
author_association: MEMBER
user: login: efegurkan
created_at: 2025-03-11T15:31:29Z
body: Hey @mdefazio,

The number 6 and 7 is a bug in the API level, it is being tracked and fixed. 
The others I will add changes. Thank you <3
author_association: MEMBER
user: login: efegurkan
created_at: 2025-03-13T11:39:25Z
body: @Samiul-TheSoccerFan some answers to your questions
1- They are sorted now so there is a change. (see Michaels review above)
2- That should be cleared, a bug
3- It is a feedback I gave as well, Michael will take a look into it, it is designs.
4- If you pass that to the API it will get an error. due to the comma at the end. It is on purpose implemented this way. There are a million other edge cases that we can find, but fixing those currently will only defer this for no good reason.
author_association: MEMBER
user: login: elasticmachine
created_at: 2025-03-13T13:56:52Z
body: ## :hourglass_flowing_sand: Build in-progress
* [Buildkite Build](https://buildkite.com/elastic/kibana-pull-request/builds/283682)
* Commit: d895de2d2cab6c37664f80f35b3c90b17b6fc516
* Kibana Serverless Image: `docker.elastic.co/kibana-ci/kibana-serverless:pr-213433-d895de2d2cab`
* [Elasticsearch Serverless Deployment](https://kibana-pr-213433-elasticsearch-f21b9b.kb.eu-west-1.aws.qa.elastic.cloud/login)

### History
* :green_heart: [Build #283275](https://buildkite.com/elastic/kibana-pull-request/builds/283275) succeeded 9ba6cd922e2ff03e0efff754cfd658cdd12466af
* :green_heart: [Build #283210](https://buildkite.com/elastic/kibana-pull-request/builds/283210) succeeded a6ccfbe3960123fa9a38e504336b56a81b143359
* :green_heart: [Build #282716](https://buildkite.com/elastic/kibana-pull-request/builds/282716) succeeded c7c84d9680cd5cfb1ed5da5c047523c37e982159
* :yellow_heart: [Build #282323](https://buildkite.com/elastic/kibana-pull-request/builds/282323) was flaky 40e8f589e12c40dc0b706d2d835ed7155708b817
* :green_heart: [Build #282135](https://buildkite.com/elastic/kibana-pull-request/builds/282135) succeeded 66fe12a13d9ccc09f7b06fbee5b26c487e21f984

<!--buildkite-pr-comment-kibana-pull-request
{"builds":[{"buildStatus":{"state":"running","success":true,"hasRetries":true,"hasNonPreemptionRetries":false},"url":"https://buildkite.com/elastic/kibana-pull-request/builds/283682","number":283682,"commit":"d895de2d2cab6c37664f80f35b3c90b17b6fc516"},{"buildStatus":{"state":"passed","success":true,"hasRetries":true,"hasNonPreemptionRetries":false},"url":"https://buildkite.com/elastic/kibana-pull-request/builds/283275","number":283275,"commit":"9ba6cd922e2ff03e0efff754cfd658cdd12466af"},{"buildStatus":{"state":"running","success":true,"hasRetries":false,"hasNonPreemptionRetries":false},"url":"https://buildkite.com/elastic/kibana-pull-request/builds/283210","number":283210,"commit":"a6ccfbe3960123fa9a38e504336b56a81b143359"},{"buildStatus":{"state":"passed","success":true,"hasRetries":true,"hasNonPreemptionRetries":false},"url":"https://buildkite.com/elastic/kibana-pull-request/builds/282716","number":282716,"commit":"c7c84d9680cd5cfb1ed5da5c047523c37e982159"},{"buildStatus":{"state":"passed","success":true,"hasRetries":true,"hasNonPreemptionRetries":true},"url":"https://buildkite.com/elastic/kibana-pull-request/builds/282323","number":282323,"commit":"40e8f589e12c40dc0b706d2d835ed7155708b817"}],"number":283682}
buildkite-pr-comment-->
author_association: CONTRIBUTOR
user: login: kibanamachine
created_at: 2025-03-13T14:35:31Z
body: Starting backport for target branches: 8.18, 8.x

https://github.com/elastic/kibana/actions/runs/13837230279
author_association: CONTRIBUTOR
user: login: kibanamachine
created_at: 2025-03-13T14:42:17Z
body: ## 💚 All backports created successfully

| Status | Branch | Result |
|:------:|:------:|:------|
|✅|8.18|[<img src="https://img.shields.io/github/pulls/detail/state/elastic/kibana/214397">](https://github.com/elastic/kibana/pull/214397)|
|✅|8.x|[<img src="https://img.shields.io/github/pulls/detail/state/elastic/kibana/214398">](https://github.com/elastic/kibana/pull/214398)|

Note: Successful backport PRs will be merged automatically after passing CI.

### Questions ?
Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport)

<!--- Backport version: 9.6.6 -->
author_association: CONTRIBUTOR
user: login: Samiul-TheSoccerFan
created_at: 2025-03-13T14:46:17Z
body: @efegurkan : As you are back-porting this for `8.18.1`, do we also need to backport for `9.0.1`?
author_association: CONTRIBUTOR
user: login: efegurkan
created_at: 2025-03-13T15:12:44Z
body: Good point, I will create the Backport

author_association: MEMBER
user: login: efegurkan
created_at: 2025-03-13T15:16:19Z
body: ## 💚 All backports created successfully

| Status | Branch | Result |
|:------:|:------:|:------|
|✅|9.0|[<img src="https://img.shields.io/github/pulls/detail/state/elastic/kibana/214414">](https://github.com/elastic/kibana/pull/214414)|

Note: Successful backport PRs will be merged automatically after passing CI.

### Questions ?
Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport)

<!--- Backport version: 9.6.6 -->
author_association: MEMBER