Migrate gr-menu-editor to lit

Release-Notes: skip
Change-Id: I3ec78bf4eda22966921b48211fb6c400678f6915
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 01ff6ce..8cdd765 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -260,14 +260,19 @@
   NONE = 'NONE',
 }
 
-// TODO(TS): Many properties are omitted here, but they are required.
-// Add default values for missing properties.
-export function createDefaultPreferences() {
+export function createDefaultPreferences(): PreferencesInfo {
   return {
     changes_per_page: 25,
     diff_view: DiffViewMode.SIDE_BY_SIDE,
     size_bar_in_change_table: true,
-  } as PreferencesInfo;
+    my: [],
+    theme: AppTheme.LIGHT,
+    date_format: DateFormat.EURO,
+    time_format: TimeFormat.HHMM_24,
+    change_table: [],
+    email_strategy: EmailStrategy.ATTENTION_SET_ONLY,
+    default_base_for_merges: DefaultBase.AUTO_MERGE,
+  };
 }
 
 // These defaults should match the defaults in
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
index e6ca58f..845b30c 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
@@ -1,48 +1,53 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-button/gr-button';
-import '../../../styles/shared-styles';
-import '../../../styles/gr-form-styles';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-menu-editor_html';
-import {customElement, property} from '@polymer/decorators';
-import {TopMenuItemInfo} from '../../../types/common';
+import {PreferencesInfo, TopMenuItemInfo} from '../../../types/common';
+import {css, html, LitElement} from 'lit';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {css, html} from 'lit';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {state, customElement} from 'lit/decorators';
+import {BindValueChangeEvent} from '../../../types/events';
+import {subscribe} from '../../lit/subscription-controller';
+import {getAppContext} from '../../../services/app-context';
+import {deepEqual} from '../../../utils/deep-util';
+import {createDefaultPreferences} from '../../../constants/constants';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {classMap} from 'lit/directives/class-map';
+import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
 
 @customElement('gr-menu-editor')
-export class GrMenuEditor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+export class GrMenuEditor extends LitElement {
+  @state()
+  menuItems: TopMenuItemInfo[] = [];
+
+  @state()
+  originalPrefs: PreferencesInfo = createDefaultPreferences();
+
+  @state()
+  newName = '';
+
+  @state()
+  newUrl = '';
+
+  private readonly userModel = getAppContext().userModel;
+
+  override connectedCallback() {
+    super.connectedCallback();
+    subscribe(this, this.userModel.preferences$, prefs => {
+      this.originalPrefs = prefs;
+      this.menuItems = [...prefs.my];
+    });
   }
 
-  @property({type: Array})
-  menuItems!: TopMenuItemInfo[];
-
-  @property({type: String})
-  _newName?: string;
-
-  @property({type: String})
-  _newUrl?: string;
-
-  styles = [
+  static override styles = [
+    formStyles,
     sharedStyles,
+    fontStyles,
+    menuPageStyles,
     css`
       .buttonColumn {
         width: 2em;
@@ -64,154 +69,167 @@
     `,
   ];
 
-  render() {
+  override render() {
+    const unchanged = deepEqual(this.menuItems, this.originalPrefs.my);
+    const classes = {
+      'heading-2': true,
+      edited: !unchanged,
+    };
     return html`
       <div class="gr-form-styles">
-        <table>
-          <thead>
-            <tr>
-              <th class="nameHeader">Name</th>
-              <th class="url-header">URL</th>
-            </tr>
-          </thead>
-          <tbody>
-            <template is="dom-repeat" items="[[menuItems]]">
+        <h2 id="Menu" class=${classMap(classes)}>Menu</h2>
+        <fieldset id="menu">
+          <table>
+            <thead>
               <tr>
-                <td>[[item.name]]</td>
-                <td class="urlCell">[[item.url]]</td>
-                <td class="buttonColumn">
-                  <gr-button
-                    link=""
-                    data-index$="[[index]]"
-                    on-click="_handleMoveUpButton"
-                    class="moveUpButton"
-                    >↑</gr-button
-                  >
-                </td>
-                <td class="buttonColumn">
-                  <gr-button
-                    link=""
-                    data-index$="[[index]]"
-                    on-click="_handleMoveDownButton"
-                    class="moveDownButton"
-                    >↓</gr-button
-                  >
-                </td>
-                <td>
-                  <gr-button
-                    link=""
-                    data-index$="[[index]]"
-                    on-click="_handleDeleteButton"
-                    class="remove-button"
-                    >Delete</gr-button
-                  >
-                </td>
+                <th>Name</th>
+                <th>URL</th>
               </tr>
-            </template>
-          </tbody>
-          <tfoot>
-            <tr>
-              <th>
-                <iron-input
-                  placeholder="New Title"
-                  on-keydown="_handleInputKeydown"
-                  bind-value="{{_newName}}"
-                >
-                  <input
-                    is="iron-input"
-                    placeholder="New Title"
-                    on-keydown="_handleInputKeydown"
-                    bind-value="{{_newName}}"
-                  />
-                </iron-input>
-              </th>
-              <th>
-                <iron-input
-                  class="newUrlInput"
-                  placeholder="New URL"
-                  on-keydown="_handleInputKeydown"
-                  bind-value="{{_newUrl}}"
-                >
-                  <input
-                    class="newUrlInput"
-                    is="iron-input"
-                    placeholder="New URL"
-                    on-keydown="_handleInputKeydown"
-                    bind-value="{{_newUrl}}"
-                  />
-                </iron-input>
-              </th>
-              <th></th>
-              <th></th>
-              <th>
-                <gr-button
-                  link=""
-                  disabled$="[[_computeAddDisabled(_newName, _newUrl)]]"
-                  on-click="_handleAddButton"
-                  >Add</gr-button
-                >
-              </th>
-            </tr>
-          </tfoot>
-        </table>
+            </thead>
+            <tbody>
+              ${this.menuItems.map((item, index) =>
+                this.renderMenuItemRow(item, index)
+              )}
+            </tbody>
+            <tfoot>
+              ${this.renderFooterRow()}
+            </tfoot>
+          </table>
+          <gr-button id="save" @click=${this.handleSave} ?disabled=${unchanged}
+            >Save changes</gr-button
+          >
+          <gr-button id="reset" link @click=${this.handleReset}
+            >Reset</gr-button
+          >
+        </fieldset>
       </div>
     `;
   }
 
-  _handleMoveUpButton(e: Event) {
-    const target = (dom(e) as EventApi).localTarget;
-    if (!(target instanceof HTMLElement)) return;
-    const index = Number(target.dataset['index']);
-    if (index === 0) {
-      return;
-    }
-    const row = this.menuItems[index];
-    const prev = this.menuItems[index - 1];
-    this.splice('menuItems', index - 1, 2, row, prev);
+  private renderMenuItemRow(item: TopMenuItemInfo, index: number) {
+    return html`
+      <tr>
+        <td>${item.name}</td>
+        <td class="urlCell">${item.url}</td>
+        <td class="buttonColumn">
+          <gr-button
+            link
+            data-index=${index}
+            @click=${() => this.swapItems(index, index - 1)}
+            class="moveUpButton"
+            >↑</gr-button
+          >
+        </td>
+        <td class="buttonColumn">
+          <gr-button
+            link
+            data-index=${index}
+            @click=${() => this.swapItems(index, index + 1)}
+            class="moveDownButton"
+            >↓</gr-button
+          >
+        </td>
+        <td>
+          <gr-button
+            link
+            data-index=${index}
+            @click=${() => {
+              this.menuItems.splice(index, 1);
+              this.requestUpdate('menuItems');
+            }}
+            class="remove-button"
+            >Delete</gr-button
+          >
+        </td>
+      </tr>
+    `;
   }
 
-  _handleMoveDownButton(e: Event) {
-    const target = (dom(e) as EventApi).localTarget;
-    if (!(target instanceof HTMLElement)) return;
-    const index = Number(target.dataset['index']);
-    if (index === this.menuItems.length - 1) {
-      return;
-    }
-    const row = this.menuItems[index];
-    const next = this.menuItems[index + 1];
-    this.splice('menuItems', index, 2, next, row);
+  private renderFooterRow() {
+    return html`
+      <tr>
+        <th>
+          <iron-input
+            .bindValue=${this.newName}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              this.newName = e.detail.value ?? '';
+            }}
+          >
+            <input
+              is="iron-input"
+              placeholder="New Title"
+              @keydown=${this.handleInputKeydown}
+            />
+          </iron-input>
+        </th>
+        <th>
+          <iron-input
+            .bindValue=${this.newUrl}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              this.newUrl = e.detail.value ?? '';
+            }}
+          >
+            <input
+              class="newUrlInput"
+              placeholder="New URL"
+              @keydown=${this.handleInputKeydown}
+            />
+          </iron-input>
+        </th>
+        <th></th>
+        <th></th>
+        <th>
+          <gr-button
+            id="add"
+            link
+            ?disabled=${this.newName.length === 0 || this.newUrl.length === 0}
+            @click=${this.handleAddButton}
+            >Add</gr-button
+          >
+        </th>
+      </tr>
+    `;
   }
 
-  _handleDeleteButton(e: Event) {
-    const target = (dom(e) as EventApi).localTarget;
-    if (!(target instanceof HTMLElement)) return;
-    const index = Number(target.dataset['index']);
-    this.splice('menuItems', index, 1);
+  private handleSave() {
+    this.userModel.updatePreferences({
+      ...this.originalPrefs,
+      my: this.menuItems,
+    });
   }
 
-  _handleAddButton() {
-    if (this._computeAddDisabled(this._newName, this._newUrl)) {
-      return;
-    }
+  private handleReset() {
+    this.menuItems = [...this.originalPrefs.my];
+  }
 
-    this.splice('menuItems', this.menuItems.length, 0, {
-      name: this._newName,
-      url: this._newUrl,
+  private swapItems(i: number, j: number) {
+    const max = this.menuItems.length - 1;
+    if (i < 0 || j < 0) return;
+    if (i > max || j > max) return;
+    const x = this.menuItems[i];
+    this.menuItems[i] = this.menuItems[j];
+    this.menuItems[j] = x;
+    this.requestUpdate('menuItems');
+  }
+
+  // visible for testing
+  handleAddButton() {
+    if (this.newName.length === 0 || this.newUrl.length === 0) return;
+
+    this.menuItems.push({
+      name: this.newName,
+      url: this.newUrl,
       target: '_blank',
     });
-
-    this._newName = '';
-    this._newUrl = '';
+    this.newName = '';
+    this.newUrl = '';
+    this.requestUpdate('menuItems');
   }
 
-  _computeAddDisabled(newName?: string, newUrl?: string) {
-    return !newName?.length || !newUrl?.length;
-  }
-
-  _handleInputKeydown(e: KeyboardEvent) {
+  private handleInputKeydown(e: KeyboardEvent) {
     if (e.keyCode === 13) {
       e.stopPropagation();
-      this._handleAddButton();
+      this.handleAddButton();
     }
   }
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.ts
deleted file mode 100644
index e4d66e2..0000000
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    .buttonColumn {
-      width: 2em;
-    }
-    .moveUpButton,
-    .moveDownButton {
-      width: 100%;
-    }
-    tbody tr:first-of-type td .moveUpButton,
-    tbody tr:last-of-type td .moveDownButton {
-      display: none;
-    }
-    td.urlCell {
-      word-break: break-word;
-    }
-    .newUrlInput {
-      min-width: 23em;
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="gr-form-styles">
-    <table>
-      <thead>
-        <tr>
-          <th class="nameHeader">Name</th>
-          <th class="url-header">URL</th>
-        </tr>
-      </thead>
-      <tbody>
-        <template is="dom-repeat" items="[[menuItems]]">
-          <tr>
-            <td>[[item.name]]</td>
-            <td class="urlCell">[[item.url]]</td>
-            <td class="buttonColumn">
-              <gr-button
-                link=""
-                data-index$="[[index]]"
-                on-click="_handleMoveUpButton"
-                class="moveUpButton"
-                >↑</gr-button
-              >
-            </td>
-            <td class="buttonColumn">
-              <gr-button
-                link=""
-                data-index$="[[index]]"
-                on-click="_handleMoveDownButton"
-                class="moveDownButton"
-                >↓</gr-button
-              >
-            </td>
-            <td>
-              <gr-button
-                link=""
-                data-index$="[[index]]"
-                on-click="_handleDeleteButton"
-                class="remove-button"
-                >Delete</gr-button
-              >
-            </td>
-          </tr>
-        </template>
-      </tbody>
-      <tfoot>
-        <tr>
-          <th>
-            <iron-input
-              placeholder="New Title"
-              on-keydown="_handleInputKeydown"
-              bind-value="{{_newName}}"
-            >
-              <input
-                is="iron-input"
-                placeholder="New Title"
-                on-keydown="_handleInputKeydown"
-                bind-value="{{_newName}}"
-              />
-            </iron-input>
-          </th>
-          <th>
-            <iron-input
-              class="newUrlInput"
-              placeholder="New URL"
-              on-keydown="_handleInputKeydown"
-              bind-value="{{_newUrl}}"
-            >
-              <input
-                class="newUrlInput"
-                is="iron-input"
-                placeholder="New URL"
-                on-keydown="_handleInputKeydown"
-                bind-value="{{_newUrl}}"
-              />
-            </iron-input>
-          </th>
-          <th></th>
-          <th></th>
-          <th>
-            <gr-button
-              link=""
-              disabled$="[[_computeAddDisabled(_newName, _newUrl)]]"
-              on-click="_handleAddButton"
-              >Add</gr-button
-            >
-          </th>
-        </tr>
-      </tfoot>
-    </table>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.ts
index 9785ccb..c6130df 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.ts
@@ -1,29 +1,18 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../../test/common-test-setup-karma';
 import './gr-menu-editor';
 import {GrMenuEditor} from './gr-menu-editor';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {query, queryAll} from '../../../test/test-utils';
+import {query, queryAndAssert, waitUntil} from '../../../test/test-utils';
 import {PaperButtonElement} from '@polymer/paper-button';
 import {TopMenuItemInfo} from '../../../types/common';
-
-const basicFixture = fixtureFromElement('gr-menu-editor');
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {createDefaultPreferences} from '../../../constants/constants';
 
 suite('gr-menu-editor tests', () => {
   let element: GrMenuEditor;
@@ -53,52 +42,229 @@
   }
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture<GrMenuEditor>(
+      html`<gr-menu-editor></gr-menu-editor>`
+    );
     menu = [
       {url: '/first/url', name: 'first name', target: '_blank'},
       {url: '/second/url', name: 'second name', target: '_blank'},
       {url: '/third/url', name: 'third name', target: '_blank'},
     ];
-    element.set('menuItems', menu);
-    await flush();
+    element.originalPrefs = {...createDefaultPreferences(), my: menu};
+    element.menuItems = [...menu];
+    await element.updateComplete;
   });
 
   test('renders', () => {
-    const rows = queryAll(query<HTMLElement>(element, 'tbody')!, 'tr');
-    let tds;
-
-    assert.equal(rows.length, menu.length);
-    for (let i = 0; i < menu.length; i++) {
-      tds = rows[i].querySelectorAll('td');
-      assert.equal(tds[0].textContent, menu[i].name);
-      assert.equal(tds[1].textContent, menu[i].url);
-    }
-
-    assert.isTrue(
-      element._computeAddDisabled(element._newName, element._newUrl)
-    );
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <div class="gr-form-styles">
+        <h2 class="heading-2" id="Menu">Menu</h2>
+        <fieldset id="menu">
+          <table>
+            <thead>
+              <tr>
+                <th>Name</th>
+                <th>URL</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td>first name</td>
+                <td class="urlCell">/first/url</td>
+                <td class="buttonColumn">
+                  <gr-button
+                    aria-disabled="false"
+                    class="moveUpButton"
+                    data-index="0"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    ↑
+                  </gr-button>
+                </td>
+                <td class="buttonColumn">
+                  <gr-button
+                    aria-disabled="false"
+                    class="moveDownButton"
+                    data-index="0"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    ↓
+                  </gr-button>
+                </td>
+                <td>
+                  <gr-button
+                    aria-disabled="false"
+                    class="remove-button"
+                    data-index="0"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Delete
+                  </gr-button>
+                </td>
+              </tr>
+              <tr>
+                <td>second name</td>
+                <td class="urlCell">/second/url</td>
+                <td class="buttonColumn">
+                  <gr-button
+                    aria-disabled="false"
+                    class="moveUpButton"
+                    data-index="1"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    ↑
+                  </gr-button>
+                </td>
+                <td class="buttonColumn">
+                  <gr-button
+                    aria-disabled="false"
+                    class="moveDownButton"
+                    data-index="1"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    ↓
+                  </gr-button>
+                </td>
+                <td>
+                  <gr-button
+                    aria-disabled="false"
+                    class="remove-button"
+                    data-index="1"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Delete
+                  </gr-button>
+                </td>
+              </tr>
+              <tr>
+                <td>third name</td>
+                <td class="urlCell">/third/url</td>
+                <td class="buttonColumn">
+                  <gr-button
+                    aria-disabled="false"
+                    class="moveUpButton"
+                    data-index="2"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    ↑
+                  </gr-button>
+                </td>
+                <td class="buttonColumn">
+                  <gr-button
+                    aria-disabled="false"
+                    class="moveDownButton"
+                    data-index="2"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    ↓
+                  </gr-button>
+                </td>
+                <td>
+                  <gr-button
+                    aria-disabled="false"
+                    class="remove-button"
+                    data-index="2"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Delete
+                  </gr-button>
+                </td>
+              </tr>
+            </tbody>
+            <tfoot>
+              <tr>
+                <th>
+                  <iron-input>
+                    <input is="iron-input" placeholder="New Title" />
+                  </iron-input>
+                </th>
+                <th>
+                  <iron-input>
+                    <input class="newUrlInput" placeholder="New URL" />
+                  </iron-input>
+                </th>
+                <th></th>
+                <th></th>
+                <th>
+                  <gr-button
+                    aria-disabled="true"
+                    disabled=""
+                    id="add"
+                    link=""
+                    role="button"
+                    tabindex="-1"
+                  >
+                    Add
+                  </gr-button>
+                </th>
+              </tr>
+            </tfoot>
+          </table>
+          <gr-button
+            aria-disabled="true"
+            disabled=""
+            id="save"
+            role="button"
+            tabindex="-1"
+          >
+            Save changes
+          </gr-button>
+          <gr-button
+            aria-disabled="false"
+            id="reset"
+            link=""
+            role="button"
+            tabindex="0"
+          >
+            Reset
+          </gr-button>
+        </fieldset>
+      </div>
+    `);
   });
 
-  test('_computeAddDisabled', () => {
-    assert.isTrue(element._computeAddDisabled('', ''));
-    assert.isTrue(element._computeAddDisabled('name', ''));
-    assert.isTrue(element._computeAddDisabled('', 'url'));
-    assert.isFalse(element._computeAddDisabled('name', 'url'));
+  test('add button disabled', async () => {
+    element.newName = 'test-name';
+    await element.updateComplete;
+    let addButton = queryAndAssert<GrButton>(element, 'gr-button#add');
+    assert.isTrue(addButton.hasAttribute('disabled'));
+
+    element.newUrl = 'test-url';
+    await element.updateComplete;
+    addButton = queryAndAssert<GrButton>(element, 'gr-button#add');
+    assert.isFalse(addButton.hasAttribute('disabled'));
   });
 
-  test('add a new menu item', () => {
+  test('add a new menu item', async () => {
     const newName = 'new name';
     const newUrl = 'new url';
-
-    element._newName = newName;
-    element._newUrl = newUrl;
-    assert.isFalse(
-      element._computeAddDisabled(element._newName, element._newUrl)
-    );
-
     const originalMenuLength = element.menuItems.length;
 
-    element._handleAddButton();
+    element.newName = newName;
+    element.newUrl = newUrl;
+    await element.updateComplete;
+
+    const addButton = queryAndAssert<GrButton>(element, 'gr-button#add');
+    assert.isFalse(addButton.hasAttribute('disabled'));
+    addButton.click();
 
     assert.equal(element.menuItems.length, originalMenuLength + 1);
     assert.equal(element.menuItems[element.menuItems.length - 1].name, newName);
@@ -117,6 +283,37 @@
     assertMenuNamesEqual(element, ['first name', 'third name', 'second name']);
   });
 
+  test('move item down and save', async () => {
+    assertMenuNamesEqual(element, ['first name', 'second name', 'third name']);
+    const saveButton = queryAndAssert<GrButton>(element, 'gr-button#save');
+    assert.isTrue(saveButton.hasAttribute('disabled'));
+
+    move(element, 1, 'Down');
+    await element.updateComplete;
+    assertMenuNamesEqual(element, ['first name', 'third name', 'second name']);
+    assert.isFalse(saveButton.hasAttribute('disabled'));
+
+    saveButton.click();
+    await waitUntil(() => element.originalPrefs.my[1].name === 'third name');
+    await element.updateComplete;
+
+    assertMenuNamesEqual(element, ['first name', 'third name', 'second name']);
+    assert.isTrue(saveButton.hasAttribute('disabled'));
+  });
+
+  test('move item down and reset', async () => {
+    assertMenuNamesEqual(element, ['first name', 'second name', 'third name']);
+
+    move(element, 1, 'Down');
+    assertMenuNamesEqual(element, ['first name', 'third name', 'second name']);
+
+    const resetButton = queryAndAssert<GrButton>(element, 'gr-button#reset');
+    resetButton.click();
+    await element.updateComplete;
+
+    assertMenuNamesEqual(element, ['first name', 'second name', 'third name']);
+  });
+
   test('move items up', () => {
     assertMenuNamesEqual(element, ['first name', 'second name', 'third name']);
 
@@ -161,9 +358,9 @@
     assertMenuNamesEqual(element, []);
 
     // Add item to empty menu.
-    element._newName = 'new name';
-    element._newUrl = 'new url';
-    element._handleAddButton();
+    element.newName = 'new name';
+    element.newUrl = 'new url';
+    element.handleAddButton();
     assertMenuNamesEqual(element, ['new name']);
   });
 });
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
index fb60bf8..1218dfd 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
@@ -25,7 +25,6 @@
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../gr-change-table-editor/gr-change-table-editor';
 import '../../shared/gr-button/gr-button';
-import {GrButton} from '../../shared/gr-button/gr-button';
 import '../../shared/gr-diff-preferences/gr-diff-preferences';
 import '../../shared/gr-page-nav/gr-page-nav';
 import '../../shared/gr-select/gr-select';
@@ -50,11 +49,7 @@
 import {GrGroupList} from '../gr-group-list/gr-group-list';
 import {GrIdentities} from '../gr-identities/gr-identities';
 import {GrDiffPreferences} from '../../shared/gr-diff-preferences/gr-diff-preferences';
-import {
-  PreferencesInput,
-  ServerInfo,
-  TopMenuItemInfo,
-} from '../../../types/common';
+import {PreferencesInput, ServerInfo} from '../../../types/common';
 import {GrSshEditor} from '../gr-ssh-editor/gr-ssh-editor';
 import {GrGpgEditor} from '../gr-gpg-editor/gr-gpg-editor';
 import {GrEmailEditor} from '../gr-email-editor/gr-email-editor';
@@ -103,8 +98,6 @@
   LocalPrefsToPrefs,
 }
 
-type LocalMenuItemInfo = Omit<TopMenuItemInfo, 'id'>;
-
 export interface GrSettingsView {
   $: {
     accountInfo: GrAccountInfo;
@@ -129,8 +122,6 @@
     emailFormatSelect: HTMLInputElement;
     defaultBaseForMergesSelect: HTMLInputElement;
     diffViewSelect: HTMLInputElement;
-    menu: HTMLFieldSetElement;
-    resetButton: GrButton;
   };
 }
 
@@ -167,9 +158,6 @@
   @property({type: Array})
   _localChangeTableColumns: string[] = [];
 
-  @property({type: Array})
-  _localMenu: LocalMenuItemInfo[] = [];
-
   @property({type: Boolean})
   _loading = true;
 
@@ -183,9 +171,6 @@
   _diffPrefsChanged = false;
 
   @property({type: Boolean})
-  _menuChanged = false;
-
-  @property({type: Boolean})
   _watchedProjectsChanged = false;
 
   @property({type: Boolean})
@@ -247,7 +232,6 @@
         this.prefs = prefs;
         this._showNumber = !!prefs.legacycid_in_change_table;
         this._copyPrefs(CopyPrefsDirection.PrefsToLocalPrefs);
-        this._localMenu = this._cloneMenu(prefs.my);
         this._localChangeTableColumns =
           prefs.change_table.length === 0
             ? columnNames
@@ -355,11 +339,6 @@
     }
   }
 
-  _cloneMenu(prefs: TopMenuItemInfo[]) {
-    // eslint-disable-next-line @typescript-eslint/no-unused-vars
-    return prefs.map(({id, ...item}) => item);
-  }
-
   @observe('_localChangeTableColumns', '_showNumber')
   _handleChangeTableChanged() {
     if (this._isLoading()) {
@@ -422,14 +401,6 @@
     this.set('_localPrefs.signed_off_by', this.$.insertSignedOff.checked);
   }
 
-  @observe('_localMenu.splices')
-  _handleMenuChanged() {
-    if (this._isLoading()) {
-      return;
-    }
-    this._menuChanged = true;
-  }
-
   _handleSaveAccountInfo() {
     this.$.accountInfo.save();
   }
@@ -454,21 +425,6 @@
     this.$.diffPrefs.save();
   }
 
-  _handleSaveMenu() {
-    this.set('prefs.my', this._localMenu);
-    return this.restApiService.savePreferences(this.prefs).then(() => {
-      this._menuChanged = false;
-    });
-  }
-
-  _handleResetMenuButton() {
-    return this.restApiService.getDefaultPreferences().then(data => {
-      if (data?.my) {
-        this._localMenu = this._cloneMenu(data.my);
-      }
-    });
-  }
-
   _handleSaveWatchedProjects() {
     this.$.watchedProjectsEditor.save();
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
index e50b2de..6804470 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
@@ -361,19 +361,7 @@
         >
       </fieldset>
       <gr-edit-preferences id="editPrefs"></gr-edit-preferences>
-      <h2 id="Menu" class$="[[_computeHeaderClass(_menuChanged)]]">Menu</h2>
-      <fieldset id="menu">
-        <gr-menu-editor menu-items="{{_localMenu}}"></gr-menu-editor>
-        <gr-button
-          id="saveMenu"
-          on-click="_handleSaveMenu"
-          disabled="[[!_menuChanged]]"
-          >Save changes</gr-button
-        >
-        <gr-button id="resetButton" link="" on-click="_handleResetMenuButton"
-          >Reset</gr-button
-        >
-      </fieldset>
+      <gr-menu-editor></gr-menu-editor>
       <h2
         id="ChangeTableColumns"
         class$="[[_computeHeaderClass(_changeTableChanged)]]"
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
index 8049124..7f81f42 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
@@ -19,7 +19,7 @@
 import './gr-settings-view';
 import {GrSettingsView} from './gr-settings-view';
 import {GerritView} from '../../../services/router/router-model';
-import {queryAll, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {
   AuthInfo,
   AccountDetailInfo,
@@ -259,7 +259,6 @@
     );
 
     assert.isFalse(element._prefsChanged);
-    assert.isFalse(element._menuChanged);
 
     const publishOnPush = valueOf('Publish comments on push', 'preferences')!
       .firstElementChild!;
@@ -267,7 +266,6 @@
     MockInteractions.tap(publishOnPush);
 
     assert.isTrue(element._prefsChanged);
-    assert.isFalse(element._menuChanged);
 
     stubRestApi('savePreferences').callsFake(prefs => {
       assertMenusEqual(prefs.my, preferences.my);
@@ -278,7 +276,6 @@
     // Save the change.
     await element._handleSavePreferences();
     assert.isFalse(element._prefsChanged);
-    assert.isFalse(element._menuChanged);
   });
 
   test('publish comments on push', async () => {
@@ -288,7 +285,6 @@
     )!.firstElementChild!;
     MockInteractions.tap(publishCommentsOnPush);
 
-    assert.isFalse(element._menuChanged);
     assert.isTrue(element._prefsChanged);
 
     stubRestApi('savePreferences').callsFake(prefs => {
@@ -299,7 +295,6 @@
     // Save the change.
     await element._handleSavePreferences();
     assert.isFalse(element._prefsChanged);
-    assert.isFalse(element._menuChanged);
   });
 
   test('set new changes work-in-progress', async () => {
@@ -309,7 +304,6 @@
     )!.firstElementChild!;
     MockInteractions.tap(newChangesWorkInProgress);
 
-    assert.isFalse(element._menuChanged);
     assert.isTrue(element._prefsChanged);
 
     stubRestApi('savePreferences').callsFake(prefs => {
@@ -320,40 +314,6 @@
     // Save the change.
     await element._handleSavePreferences();
     assert.isFalse(element._prefsChanged);
-    assert.isFalse(element._menuChanged);
-  });
-
-  test('menu', async () => {
-    assert.isFalse(element._menuChanged);
-    assert.isFalse(element._prefsChanged);
-
-    assertMenusEqual(element._localMenu, preferences.my);
-
-    const menu = element.$.menu.firstElementChild!;
-    let tableRows = queryAll(menu, 'tbody tr');
-    // let tableRows = menu.root.querySelectorAll('tbody tr');
-    assert.equal(tableRows.length, preferences.my.length);
-
-    // Add a menu item:
-    element.splice('_localMenu', 1, 0, {name: 'foo', url: 'bar', target: ''});
-    flush();
-
-    // tableRows = menu.root.querySelectorAll('tbody tr');
-    tableRows = queryAll(menu, 'tbody tr');
-    assert.equal(tableRows.length, preferences.my.length + 1);
-
-    assert.isTrue(element._menuChanged);
-    assert.isFalse(element._prefsChanged);
-
-    stubRestApi('savePreferences').callsFake(prefs => {
-      assertMenusEqual(prefs.my, element._localMenu);
-      return Promise.resolve(createDefaultPreferences());
-    });
-
-    await element._handleSaveMenu();
-    assert.isFalse(element._menuChanged);
-    assert.isFalse(element._prefsChanged);
-    assertMenusEqual(element.prefs.my, element._localMenu);
   });
 
   test('add email validation', () => {
@@ -445,39 +405,6 @@
     assert.isTrue(element.prefs.legacycid_in_change_table);
   });
 
-  test('reset menu item back to default', async () => {
-    const originalMenu = {
-      ...createDefaultPreferences(),
-      my: [
-        {url: '/first/url', name: 'first name', target: '_blank'},
-        {url: '/second/url', name: 'second name', target: '_blank'},
-        {url: '/third/url', name: 'third name', target: '_blank'},
-      ] as TopMenuItemInfo[],
-    };
-
-    stubRestApi('getDefaultPreferences').returns(Promise.resolve(originalMenu));
-
-    const updatedMenu = [
-      {url: '/first/url', name: 'first name', target: '_blank'},
-      {url: '/second/url', name: 'second name', target: '_blank'},
-      {url: '/third/url', name: 'third name', target: '_blank'},
-      {url: '/fourth/url', name: 'fourth name', target: '_blank'},
-    ];
-
-    element.set('_localMenu', updatedMenu);
-
-    await element._handleResetMenuButton();
-    assertMenusEqual(element._localMenu, originalMenu.my);
-  });
-
-  test('test that reset button is called', () => {
-    const overlayOpen = sinon.stub(element, '_handleResetMenuButton');
-
-    MockInteractions.tap(element.$.resetButton);
-
-    assert.isTrue(overlayOpen.called);
-  });
-
   test('_showHttpAuth', () => {
     const serverConfig: ServerInfo = {
       ...createServerInfo(),
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index ebbdc9c..d91b438 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -69,6 +69,7 @@
   GroupName,
   UrlEncodedRepoName,
   NumericChangeId,
+  PreferencesInput,
 } from '../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../types/diff';
 import {readResponsePayload} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
@@ -84,7 +85,6 @@
 import {
   createDefaultDiffPrefs,
   createDefaultEditPrefs,
-  createDefaultPreferences,
 } from '../../constants/constants';
 import {ParsedChangeInfo} from '../../types/types';
 
@@ -502,8 +502,9 @@
   saveIncludedGroup(): Promise<GroupInfo | undefined> {
     throw new Error('saveIncludedGroup() not implemented by RestApiMock.');
   },
-  savePreferences(): Promise<PreferencesInfo> {
-    return Promise.resolve(createDefaultPreferences());
+  savePreferences(input: PreferencesInput): Promise<PreferencesInfo> {
+    const info = input as PreferencesInfo;
+    return Promise.resolve({...info});
   },
   saveRepoConfig(): Promise<Response> {
     return Promise.resolve(new Response());