Favorites frontend

- Added a dialog to capture favorite inputs from user
- Added a button to Add favorite on landing page

Bug: b/344960153, b/344960015
Change-Id: If852c8afea6612e18fb025f2b301ac768d7b4249
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/865882
Commit-Queue: Vidit Chitkara <viditchitkara@google.com>
Reviewed-by: Ashwin Verleker <ashwinpv@google.com>
diff --git a/perf/go/config/config.go b/perf/go/config/config.go
index d2fb059..32e980c 100644
--- a/perf/go/config/config.go
+++ b/perf/go/config/config.go
@@ -772,6 +772,9 @@
 }
 
 type FavoritesSectionLinkConfig struct {
+	// Id of a user's personalized favorite
+	Id string `json:"id,omitempty"`
+
 	// Text to display on the link
 	Text string `json:"text"`
 
diff --git a/perf/go/config/validate/instanceConfigSchema.json b/perf/go/config/validate/instanceConfigSchema.json
index 323da8e..4ad83e2 100644
--- a/perf/go/config/validate/instanceConfigSchema.json
+++ b/perf/go/config/validate/instanceConfigSchema.json
@@ -140,6 +140,9 @@
     },
     "FavoritesSectionLinkConfig": {
       "properties": {
+        "id": {
+          "type": "string"
+        },
         "text": {
           "type": "string"
         },
diff --git a/perf/go/frontend/frontend.go b/perf/go/frontend/frontend.go
index fa805da..4e375f0 100644
--- a/perf/go/frontend/frontend.go
+++ b/perf/go/frontend/frontend.go
@@ -2188,6 +2188,7 @@
 
 		for _, favorite := range favsFromDb {
 			favoriteList = append(favoriteList, config.FavoritesSectionLinkConfig{
+				Id:          favorite.ID,
 				Text:        favorite.Name,
 				Href:        favorite.Url,
 				Description: favorite.Description,
diff --git a/perf/modules/explore-multi-sk/BUILD.bazel b/perf/modules/explore-multi-sk/BUILD.bazel
index e5d1515..2f26bf7 100644
--- a/perf/modules/explore-multi-sk/BUILD.bazel
+++ b/perf/modules/explore-multi-sk/BUILD.bazel
@@ -7,6 +7,8 @@
         "//perf/modules/explore-simple-sk",
         "//perf/modules/test-picker-sk",
         "//golden/modules/pagination-sk",
+        "//perf/modules/favorites-dialog-sk",
+        "//infra-sk/modules/alogin-sk",
     ],
     ts_deps = [
         "//elements-sk/modules:define_ts_lib",
@@ -18,6 +20,8 @@
         "//:node_modules/lit-html",
         "//infra-sk/modules:jsonorthrow_ts_lib",
         "//perf/modules/json:index_ts_lib",
+        "//infra-sk/modules:dom_ts_lib",
+        "//infra-sk/modules/json:index_ts_lib",
     ],
     ts_srcs = [
         "explore-multi-sk.ts",
diff --git a/perf/modules/explore-multi-sk/explore-multi-sk.scss b/perf/modules/explore-multi-sk/explore-multi-sk.scss
index 7d33da7..35157b3 100644
--- a/perf/modules/explore-multi-sk/explore-multi-sk.scss
+++ b/perf/modules/explore-multi-sk/explore-multi-sk.scss
@@ -24,4 +24,7 @@
   #add-graph-button.hidden {
     display: none;
   }
+  #favBtn {
+    margin: 16px 0 0 16px;
+  }
 }
diff --git a/perf/modules/explore-multi-sk/explore-multi-sk.ts b/perf/modules/explore-multi-sk/explore-multi-sk.ts
index 6997e1b..46fe9c8 100644
--- a/perf/modules/explore-multi-sk/explore-multi-sk.ts
+++ b/perf/modules/explore-multi-sk/explore-multi-sk.ts
@@ -30,14 +30,18 @@
 import { HintableObject } from '../../../infra-sk/modules/hintable';
 import { errorMessage } from '../errorMessage';
 import { ElementSk } from '../../../infra-sk/modules/ElementSk';
+import { QueryConfig } from '../json';
 
 import '../explore-simple-sk';
+import '../favorites-dialog-sk';
 import '../test-picker-sk';
 import '../../../golden/modules/pagination-sk/pagination-sk';
 
-import { QueryConfig } from '../json';
-
+import { $$ } from '../../../infra-sk/modules/dom';
 import { jsonOrThrow } from '../../../infra-sk/modules/jsonOrThrow';
+import { LoggedIn } from '../../../infra-sk/modules/alogin-sk/alogin-sk';
+import { Status as LoginStatus } from '../../../infra-sk/modules/json';
+import { FavoritesDialogSk } from '../favorites-dialog-sk/favorites-dialog-sk';
 import { PaginationSkPageChangedEventDetail } from '../../../golden/modules/pagination-sk/pagination-sk';
 
 class State {
@@ -93,6 +97,8 @@
 
   private defaults: QueryConfig | null = null;
 
+  private userEmail: string = '';
+
   constructor() {
     super(ExploreMultiSk.template);
   }
@@ -151,8 +157,24 @@
         this.updateButtons();
       }
     );
+
+    LoggedIn()
+      .then((status: LoginStatus) => {
+        this.userEmail = status.email;
+        this._render();
+      })
+      .catch(errorMessage);
   }
 
+  private canAddFav(): boolean {
+    return this.userEmail !== null && this.userEmail !== '';
+  }
+
+  private openAddFavoriteDialog = async () => {
+    const d = $$<FavoritesDialogSk>('#fav-dialog', this) as FavoritesDialogSk;
+    await d!.open();
+  };
+
   private static template = (ele: ExploreMultiSk) => html`
     <div id="menu">
       <h1>MultiGraph Menu</h1>
@@ -184,6 +206,15 @@
         title="Merge all graphs into a single graph.">
         Merge Graphs
       </button>
+      <button
+        id="favBtn"
+        ?disabled=${!ele.canAddFav()}
+        @click=${() => {
+          ele.openAddFavoriteDialog();
+        }}>
+        Add to Favorites
+      </button>
+      <favorites-dialog-sk id="fav-dialog"></favorites-dialog-sk>
       <test-picker-sk id="test-picker" class="hidden"></test-picker-sk>
     </div>
     <hr />
diff --git a/perf/modules/explore-sk/BUILD.bazel b/perf/modules/explore-sk/BUILD.bazel
index ab3d0fe..55d84b8 100644
--- a/perf/modules/explore-sk/BUILD.bazel
+++ b/perf/modules/explore-sk/BUILD.bazel
@@ -4,6 +4,8 @@
     name = "explore-sk",
     sk_element_deps = [
         "//perf/modules/explore-simple-sk",
+        "//perf/modules/favorites-dialog-sk",
+        "//infra-sk/modules/alogin-sk",
     ],
     ts_deps = [
         "//elements-sk/modules:define_ts_lib",
@@ -13,6 +15,9 @@
         "//:node_modules/lit-html",
         "//infra-sk/modules:jsonorthrow_ts_lib",
         "//perf/modules/json:index_ts_lib",
+        "//infra-sk/modules:dom_ts_lib",
+        "//infra-sk/modules/json:index_ts_lib",
+        "//perf/modules/errorMessage:index_ts_lib",
     ],
     ts_srcs = [
         "explore-sk.ts",
diff --git a/perf/modules/explore-sk/explore-sk-demo.scss b/perf/modules/explore-sk/explore-sk-demo.scss
index 9c32d21..668dda3 100644
--- a/perf/modules/explore-sk/explore-sk-demo.scss
+++ b/perf/modules/explore-sk/explore-sk-demo.scss
@@ -1,4 +1,8 @@
 explore-sk {
   display: block;
   height: 1400px;
+
+  button {
+    margin: 16px 0 0 16px;
+  }
 }
diff --git a/perf/modules/explore-sk/explore-sk.ts b/perf/modules/explore-sk/explore-sk.ts
index 540cc89..0540af2 100644
--- a/perf/modules/explore-sk/explore-sk.ts
+++ b/perf/modules/explore-sk/explore-sk.ts
@@ -14,6 +14,12 @@
 import { jsonOrThrow } from '../../../infra-sk/modules/jsonOrThrow';
 
 import '../explore-simple-sk';
+import '../favorites-dialog-sk';
+import { FavoritesDialogSk } from '../favorites-dialog-sk/favorites-dialog-sk';
+import { $$ } from '../../../infra-sk/modules/dom';
+import { LoggedIn } from '../../../infra-sk/modules/alogin-sk/alogin-sk';
+import { Status as LoginStatus } from '../../../infra-sk/modules/json';
+import { errorMessage } from '../errorMessage';
 
 export class ExploreSk extends ElementSk {
   private exploreSimpleSk: ExploreSimpleSk | null = null;
@@ -24,6 +30,8 @@
 
   private defaults: QueryConfig | null = null;
 
+  private userEmail: string = '';
+
   constructor() {
     super(ExploreSk.template);
   }
@@ -58,17 +66,35 @@
       this.showMultiViewButton = true;
       this._render();
     });
+
+    LoggedIn()
+      .then((status: LoginStatus) => {
+        this.userEmail = status.email;
+      })
+      .catch(errorMessage);
   }
 
+  private openAddFavoriteDialog = async () => {
+    const d = $$<FavoritesDialogSk>('#fav-dialog', this) as FavoritesDialogSk;
+    await d!.open();
+  };
+
   private static template = (ele: ExploreSk) => html`
     <div ?hidden=${!ele.showMultiViewButton}>
+      <favorites-dialog-sk id="fav-dialog"></favorites-dialog-sk>
       <button
-        style="margin: 16px 0 0 16px;"
         @click=${() => {
           ele.exploreSimpleSk?.viewMultiGraph();
         }}>
         View in multi-graph
       </button>
+      <button
+        ?disabled=${!ele.userEmail || ele.userEmail === ''}
+        @click=${() => {
+          ele.openAddFavoriteDialog();
+        }}>
+        Add to Favorites
+      </button>
     </div>
     <explore-simple-sk></explore-simple-sk>
   `;
diff --git a/perf/modules/favorites-dialog-sk/BUILD.bazel b/perf/modules/favorites-dialog-sk/BUILD.bazel
new file mode 100644
index 0000000..4c82f79
--- /dev/null
+++ b/perf/modules/favorites-dialog-sk/BUILD.bazel
@@ -0,0 +1,68 @@
+load(
+    "//infra-sk:index.bzl",
+    "karma_test",
+    "sk_demo_page_server",
+    "sk_element",
+    "sk_element_puppeteer_test",
+    "sk_page",
+)
+
+sk_demo_page_server(
+    name = "demo_page_server",
+    sk_page = ":favorites-dialog-sk-demo",
+)
+
+sk_element(
+    name = "favorites-dialog-sk",
+    sass_deps = [
+        "//perf/modules/themes:themes_sass_lib",
+    ],
+    sass_srcs = ["favorites-dialog-sk.scss"],
+    sk_element_deps = [
+        "//elements-sk/modules/spinner-sk",
+    ],
+    ts_deps = [
+        "//:node_modules/lit-html",
+        "//infra-sk/modules/ElementSk:index_ts_lib",
+        "//elements-sk/modules:define_ts_lib",
+        "//elements-sk/modules:errormessage_ts_lib",
+        "//infra-sk/modules:dom_ts_lib",
+    ],
+    ts_srcs = [
+        "favorites-dialog-sk.ts",
+        "index.ts",
+    ],
+    visibility = ["//visibility:public"],
+)
+
+sk_page(
+    name = "favorites-dialog-sk-demo",
+    html_file = "favorites-dialog-sk-demo.html",
+    sk_element_deps = [":favorites-dialog-sk"],
+    ts_deps = [
+        "//infra-sk/modules:dom_ts_lib",
+    ],
+    ts_entry_point = "favorites-dialog-sk-demo.ts",
+)
+
+sk_element_puppeteer_test(
+    name = "favorites-dialog-sk_puppeteer_test",
+    src = "favorites-dialog-sk_puppeteer_test.ts",
+    sk_demo_page_server = ":demo_page_server",
+    deps = [
+        "//:node_modules/@types/chai",
+        "//:node_modules/chai",
+        "//puppeteer-tests:util_ts_lib",
+    ],
+)
+
+karma_test(
+    name = "favorites-dialog-sk_test",
+    src = "favorites-dialog-sk_test.ts",
+    deps = [
+        ":favorites-dialog-sk",
+        "//:node_modules/@types/chai",
+        "//:node_modules/chai",
+        "//infra-sk/modules:test_util_ts_lib",
+    ],
+)
diff --git a/perf/modules/favorites-dialog-sk/favorites-dialog-sk-demo.html b/perf/modules/favorites-dialog-sk/favorites-dialog-sk-demo.html
new file mode 100644
index 0000000..3e8a759
--- /dev/null
+++ b/perf/modules/favorites-dialog-sk/favorites-dialog-sk-demo.html
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <title>favorites-dialog-sk</title>
+  <meta charset="utf-8" />
+  <meta http-equiv="X-UA-Compatible" content="IE=edge">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+</head>
+<body class="body-sk">
+  <h1>favorites-dialog-sk</h1>
+  <favorites-dialog-sk></favorites-dialog-sk>
+  <section>
+    <h2>Events</h2>
+    <button id="newFav">New Favorite</button>
+    <button id="editFav">Edit Favorite</button>
+  </section>
+</body>
+</html>
diff --git a/perf/modules/favorites-dialog-sk/favorites-dialog-sk-demo.ts b/perf/modules/favorites-dialog-sk/favorites-dialog-sk-demo.ts
new file mode 100644
index 0000000..7381123
--- /dev/null
+++ b/perf/modules/favorites-dialog-sk/favorites-dialog-sk-demo.ts
@@ -0,0 +1,14 @@
+import { $$ } from '../../../infra-sk/modules/dom';
+import { FavoritesDialogSk } from './favorites-dialog-sk';
+import './index';
+
+const elem: FavoritesDialogSk | null = document.querySelector(
+  'favorites-dialog-sk'
+);
+$$('#newFav')!.addEventListener('click', () => {
+  elem!.open('', '', '', 'a.com');
+});
+
+$$('#editFav')!.addEventListener('click', () => {
+  elem!.open('1234', 'Fav', 'Fav Desc', 'b.com');
+});
diff --git a/perf/modules/favorites-dialog-sk/favorites-dialog-sk.scss b/perf/modules/favorites-dialog-sk/favorites-dialog-sk.scss
new file mode 100644
index 0000000..dd9610f
--- /dev/null
+++ b/perf/modules/favorites-dialog-sk/favorites-dialog-sk.scss
@@ -0,0 +1,37 @@
+@import '../themes/themes.scss';
+
+favorites-dialog-sk {
+  dialog {
+    padding: 16px;
+    border: solid var(--on-background) 1px;
+    color: var(--on-background);
+    background: var(--background);
+    padding: 24px;
+
+    h2 {
+      font-size: 2em !important;
+      margin-bottom: 40px !important;
+    }
+
+    & > span {
+      display: inline-block;
+      width: 100px;
+
+      label {
+        font-size: 1.2em !important;
+      }
+    }
+
+    & > input {
+      margin-bottom: 20px;
+      width: 500px;
+      margin-left: 12px;
+      font-size: 1.2em !important;
+      padding: 6px;
+    }
+
+    button {
+      font-size: 1.2em !important;
+    }
+  }
+}
diff --git a/perf/modules/favorites-dialog-sk/favorites-dialog-sk.ts b/perf/modules/favorites-dialog-sk/favorites-dialog-sk.ts
new file mode 100644
index 0000000..3a1d8e2
--- /dev/null
+++ b/perf/modules/favorites-dialog-sk/favorites-dialog-sk.ts
@@ -0,0 +1,191 @@
+/**
+ * @module modules/favorites-dialog-sk
+ * @description <h2><code>favorites-dialog-sk</code></h2>
+ *
+ * This module is a modal that contains a form to capture user
+ * input for adding/editing a new favorite.
+ */
+import { html } from 'lit-html';
+import { ElementSk } from '../../../infra-sk/modules/ElementSk';
+import { $$ } from '../../../infra-sk/modules/dom';
+import { define } from '../../../elements-sk/modules/define';
+import { errorMessage } from '../../../elements-sk/modules/errorMessage';
+import '../../../elements-sk/modules/spinner-sk';
+
+// FavoritesDialogSk is a modal that contains a form to capture user
+// input for adding/editing a new favorite.
+export class FavoritesDialogSk extends ElementSk {
+  favId: string = '';
+
+  name: string = '';
+
+  description: string = '';
+
+  url: string = '';
+
+  private dialog: HTMLDialogElement | null = null;
+
+  private updatingFavorite: boolean = false;
+
+  private resolve: ((value?: any) => void) | null = null;
+
+  private reject: ((value?: any) => void) | null = null;
+
+  constructor() {
+    super(FavoritesDialogSk.template);
+  }
+
+  connectedCallback(): void {
+    super.connectedCallback();
+    this._render();
+    this.dialog = $$('dialog', this);
+  }
+
+  private dismiss(): void {
+    this.dialog!.close();
+    this.reject!();
+  }
+
+  private async confirm(): Promise<void> {
+    if (this.name === '' || this.description === '' || this.url === '') {
+      errorMessage('All the fields must be non empty');
+      return;
+    }
+
+    let apiUrl = '/_/favorites/new';
+    let body: {
+      id?: string;
+      name: string;
+      description: string;
+      url: string;
+    } = {
+      name: this.name,
+      description: this.description,
+      url: this.url,
+    };
+    if (this.favId !== '') {
+      body = { ...body, id: this.favId };
+      apiUrl = '/_/favorites/edit';
+    }
+
+    try {
+      this.updatingFavorite = true;
+      this._render();
+      const resp = await fetch(apiUrl, {
+        method: 'POST',
+        body: JSON.stringify(body),
+        headers: {
+          'Content-Type': 'application/json',
+        },
+      });
+
+      if (!resp.ok) {
+        const msg = await resp.text();
+        errorMessage(`${resp.statusText}: ${msg}`);
+      }
+    } finally {
+      this.updatingFavorite = false;
+      this.dialog!.close();
+      this.resolve!();
+    }
+  }
+
+  // open shows the popup dialog when called.
+  public open(
+    favId?: string,
+    name?: string,
+    description?: string,
+    url?: string
+  ): Promise<void> {
+    this.favId = favId || '';
+    this.name = name || '';
+    this.description = description || '';
+    this.url = url || window.location.href;
+
+    this._render();
+    this.dialog!.showModal();
+
+    // If the dialog closes it could be due to 2 reasons:
+    //    1: User pressed on close
+    //    2: The favorite got added/edited.
+    // In this module, we want to re-fetch the favorites when the dialog is closed
+    // but we only want to re-fetch if closed due to reason 2.
+    // So we're using the reject function when the user presses on close dialog
+    // which is eventually used in favorites-sk to decide if it wants to
+    // re-fetch the favorites or not.
+    return new Promise((resolve, reject) => {
+      this.resolve = resolve;
+      this.reject = reject;
+    });
+  }
+
+  private filterName(e: Event): void {
+    this.name = (e.target as HTMLInputElement).value;
+    this._render();
+  }
+
+  private filterDescription(e: Event): void {
+    this.description = (e.target as HTMLInputElement).value;
+    this._render();
+  }
+
+  private filterUrl(e: Event): void {
+    this.url = (e.target as HTMLInputElement).value;
+    this._render();
+  }
+
+  private static template = (ele: FavoritesDialogSk) => html`
+      <dialog id="favDialog">
+        <h2>Favorite</h2>
+
+        <div id=spinContainer>
+          <spinner-sk ?active=${ele.updatingFavorite}></spinner-sk>
+        </div>
+
+        <span class="label">
+          <label>Name*</label>
+        </span>
+        <input
+          id="name"
+          placeholder="Name"
+          .value="${ele.name}"
+          @input=${(e: Event) => ele.filterName(e)}>
+        </input>
+        <br/>
+
+        <span class="label">
+          <label>Description*</label>
+        </span>
+        <input
+          id="desc"
+          placeholder="Description"
+          .value="${ele.description}"
+          @input=${(e: Event) => ele.filterDescription(e)}></input>
+        <br/>
+
+        <span class="label">
+          <label>URL*</label>
+        </span>
+        <input
+          id="url"
+          placeholder="URL"
+          value="${ele.url}"
+          @input=${(e: Event) => ele.filterUrl(e)}></input>
+        <br/><br/>
+
+        <div ?hidden="${!ele.updatingFavorite}">
+          Working on it...
+        </div>
+
+        <div class="buttons">
+          <button ?disabled="${ele.updatingFavorite}" @click=${
+            ele.dismiss
+          }>Cancel</button>
+          <button ?disabled="${ele.updatingFavorite}" @click=${
+            ele.confirm
+          }>Save</button>
+        </div>
+      </dialog>`;
+}
+
+define('favorites-dialog-sk', FavoritesDialogSk);
diff --git a/perf/modules/favorites-dialog-sk/favorites-dialog-sk_puppeteer_test.ts b/perf/modules/favorites-dialog-sk/favorites-dialog-sk_puppeteer_test.ts
new file mode 100644
index 0000000..3e5b800
--- /dev/null
+++ b/perf/modules/favorites-dialog-sk/favorites-dialog-sk_puppeteer_test.ts
@@ -0,0 +1,34 @@
+import { expect } from 'chai';
+import {
+  loadCachedTestBed,
+  takeScreenshot,
+  TestBed,
+} from '../../../puppeteer-tests/util';
+
+describe('favorites-dialog-sk', () => {
+  let testBed: TestBed;
+  before(async () => {
+    testBed = await loadCachedTestBed();
+  });
+
+  beforeEach(async () => {
+    await testBed.page.goto(testBed.baseUrl);
+    await testBed.page.setViewport({ width: 400, height: 550 });
+  });
+
+  it('should render the demo page (smoke test)', async () => {
+    expect(await testBed.page.$$('favorites-dialog-sk')).to.have.length(1);
+  });
+
+  describe('screenshots', () => {
+    it('shows dialog for new favorite', async () => {
+      await testBed.page.click('#newFav');
+      await takeScreenshot(testBed.page, 'perf', 'favorites-dialog-sk');
+    });
+
+    it('shows dialog for editing an existing favorite', async () => {
+      await testBed.page.click('#editFav');
+      await takeScreenshot(testBed.page, 'perf', 'favorites-dialog-sk');
+    });
+  });
+});
diff --git a/perf/modules/favorites-dialog-sk/favorites-dialog-sk_test.ts b/perf/modules/favorites-dialog-sk/favorites-dialog-sk_test.ts
new file mode 100644
index 0000000..daef56b
--- /dev/null
+++ b/perf/modules/favorites-dialog-sk/favorites-dialog-sk_test.ts
@@ -0,0 +1,40 @@
+import './index';
+import { expect } from 'chai';
+import { FavoritesDialogSk } from './favorites-dialog-sk';
+
+import { setUpElementUnderTest } from '../../../infra-sk/modules/test_util';
+
+describe('favorites-dialog-sk', () => {
+  const newInstance = setUpElementUnderTest<FavoritesDialogSk>(
+    'favorites-dialog-sk'
+  );
+
+  let element: FavoritesDialogSk;
+  beforeEach(() => {
+    element = newInstance((el: FavoritesDialogSk) => {
+      // Place here any code that must run after the element is instantiated but
+      // before it is attached to the DOM (e.g. property setter calls,
+      // document-level event listeners, etc.).
+    });
+  });
+
+  it('renders for new', async () => {
+    expect(element).to.not.be.null;
+    element.open('12345', '', '', 'url1.com').then(() => {
+      const n = document.getElementById('name');
+      expect(n?.nodeValue).to.be.equal('');
+      expect(n?.nodeValue).to.be.equal('');
+      expect(n?.nodeValue).to.be.equal('url1.com');
+    });
+  });
+
+  it('renders for update', async () => {
+    expect(element).to.not.be.null;
+    element.open('', 'Fav', 'Fav Desc', 'url.com').then(() => {
+      const n = document.getElementById('name');
+      expect(n?.nodeValue).to.be.equal('Fav');
+      expect(n?.nodeValue).to.be.equal('Fav Desc');
+      expect(n?.nodeValue).to.be.equal('url.com');
+    });
+  });
+});
diff --git a/perf/modules/favorites-dialog-sk/index.ts b/perf/modules/favorites-dialog-sk/index.ts
new file mode 100644
index 0000000..34c14ca
--- /dev/null
+++ b/perf/modules/favorites-dialog-sk/index.ts
@@ -0,0 +1 @@
+import './favorites-dialog-sk';
diff --git a/perf/modules/favorites-sk/BUILD.bazel b/perf/modules/favorites-sk/BUILD.bazel
index 0bf6692..9e0e180 100644
--- a/perf/modules/favorites-sk/BUILD.bazel
+++ b/perf/modules/favorites-sk/BUILD.bazel
@@ -3,6 +3,10 @@
 sk_element(
     name = "favorites-sk",
     sass_srcs = ["favorites-sk.scss"],
+    sk_element_deps = [
+        "//elements-sk/modules/icons/delete-icon-sk",
+        "//perf/modules/favorites-dialog-sk",
+    ],
     ts_deps = [
         "//infra-sk/modules/ElementSk:index_ts_lib",
         "//infra-sk/modules:jsonorthrow_ts_lib",
@@ -11,6 +15,7 @@
         "//elements-sk/modules:errormessage_ts_lib",
         "//perf/modules/json:index_ts_lib",
         "//:node_modules/lit-html",
+        "//infra-sk/modules:dom_ts_lib",
     ],
     ts_srcs = [
         "favorites-sk.ts",
diff --git a/perf/modules/favorites-sk/favorites-sk.ts b/perf/modules/favorites-sk/favorites-sk.ts
index 578c9a3..447bdc4 100644
--- a/perf/modules/favorites-sk/favorites-sk.ts
+++ b/perf/modules/favorites-sk/favorites-sk.ts
@@ -15,6 +15,10 @@
 import { Favorites } from '../json';
 import '../window/window';
 import { errorMessage } from '../../../elements-sk/modules/errorMessage';
+import '../../../elements-sk/modules/icons/delete-icon-sk';
+import '../favorites-dialog-sk';
+import { FavoritesDialogSk } from '../favorites-dialog-sk/favorites-dialog-sk';
+import { $$ } from '../../../infra-sk/modules/dom';
 
 export class FavoritesSk extends ElementSk {
   private favoritesConfig: Favorites | null = null;
@@ -23,6 +27,62 @@
     super(FavoritesSk.template);
   }
 
+  private deleteFavorite = async (favId: string) => {
+    const body = {
+      id: favId,
+    };
+    const resp = await fetch('/_/favorites/delete', {
+      method: 'POST',
+      body: JSON.stringify(body),
+      headers: {
+        'Content-Type': 'application/json',
+      },
+    });
+    if (!resp.ok) {
+      const msg = await resp.text();
+      errorMessage(`${resp.statusText}: ${msg}`);
+      return;
+    }
+
+    await this.fetchFavorites();
+  };
+
+  private deleteFavoriteConfirm = async (
+    id: string | undefined,
+    name: string
+  ) => {
+    if (id === undefined) return;
+
+    const confirmed = window.confirm(
+      `Deleting favorite: ${name}. Are you sure?`
+    );
+    if (!confirmed) {
+      return;
+    }
+
+    this.deleteFavorite(id);
+  };
+
+  private editFavorite = async (
+    id: string | undefined,
+    name: string,
+    desc: string,
+    url: string
+  ) => {
+    const d = $$<FavoritesDialogSk>('#fav-dialog', this) as FavoritesDialogSk;
+
+    d!
+      .open(id, name, desc, url)
+      .then(() => {
+        this.fetchFavorites();
+      })
+      .catch((e) => {
+        if (e !== undefined) {
+          errorMessage(`${e}`);
+        }
+      });
+  };
+
   private static template = (ele: FavoritesSk) => html`
     <header><h1 class="name">Favorites</h1></header>
     <hr />
@@ -34,38 +94,81 @@
     if (sections == null || sections.length === 0) {
       return html`No favorites have been configured for this instance.`;
     }
-    return html`${sections.map(
-      (section) =>
-        html` <div class="section">
+    return html`${sections.map((section) => {
+      if (section.name === 'My Favorites') {
+        return html` <div class="section">
             <h3>${section.name}</h3>
             <table>
               <tr>
                 <th>Link</th>
                 <th>Description</th>
+                <th>Actions</th>
               </tr>
               ${section.links?.map(
                 (link) => html`
                   <tr>
                     <td><a href=${link.href}>${link.text}</a></td>
                     <td>${link.description}</td>
+                    <td>
+                      <button
+                        @click=${() =>
+                          this.editFavorite(
+                            link.id,
+                            link.text,
+                            link.description,
+                            link.href
+                          )}>
+                        Edit
+                      </button>
+                      <button
+                        @click=${() =>
+                          this.deleteFavoriteConfirm(link.id, link.text)}>
+                        Delete
+                      </button>
+                    </td>
                   </tr>
                 `
               )}
             </table>
+            <favorites-dialog-sk id="fav-dialog"></favorites-dialog-sk>
           </div>
-          <hr />`
-    )}`;
+          <hr />`;
+      }
+
+      return html` <div class="section">
+          <h3>${section.name}</h3>
+          <table>
+            <tr>
+              <th>Link</th>
+              <th>Description</th>
+            </tr>
+            ${section.links?.map(
+              (link) => html`
+                <tr>
+                  <td><a href=${link.href}>${link.text}</a></td>
+                  <td>${link.description}</td>
+                </tr>
+              `
+            )}
+          </table>
+        </div>
+        <hr />`;
+    })}`;
   }
 
+  private fetchFavorites = async () => {
+    const response = await fetch('/_/favorites/');
+    const json = await jsonOrThrow(response);
+    this.favoritesConfig = json;
+    this._render();
+  };
+
   async connectedCallback(): Promise<void> {
     super.connectedCallback();
     this._render();
     if (this.favoritesConfig == null) {
       try {
-        const response = await fetch('/_/favorites/');
-        const json = await jsonOrThrow(response);
-        this.favoritesConfig = json;
-        this._render();
+        this.fetchFavorites();
       } catch (error) {
         errorMessage(String(error));
       }
diff --git a/perf/modules/json/index.ts b/perf/modules/json/index.ts
index 6a2d968..4421c9b 100644
--- a/perf/modules/json/index.ts
+++ b/perf/modules/json/index.ts
@@ -83,6 +83,7 @@
 }
 
 export interface FavoritesSectionLinkConfig {
+	id?: string;
 	text: string;
 	href: string;
 	description: string;