This is an automated email from the ASF dual-hosted git repository.

yasithdev pushed a commit to branch feat/generic-experiment-launcher
in repository https://gitbox.apache.org/repos/asf/airavata-portals.git

commit fbb34a3f2e2c0d58dce4734891d3a85beb6d9dbf
Author: yasithdev <[email protected]>
AuthorDate: Fri Apr 24 22:38:06 2026 -0400

    feat(launcher): AppPicker (categories + search + tile grid)
---
 .../js/components/launch/AppPicker.vue             | 80 ++++++++++++++++++++++
 .../tests/unit/components/launch/AppPicker.spec.ts | 41 +++++++++++
 2 files changed, 121 insertions(+)

diff --git 
a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/AppPicker.vue
 
b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/AppPicker.vue
new file mode 100644
index 000000000..7df9ef1ad
--- /dev/null
+++ 
b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/launch/AppPicker.vue
@@ -0,0 +1,80 @@
+<template>
+  <div>
+    <div class="d-flex flex-wrap gap-1 mb-2">
+      <button
+        v-for="cat in categories"
+        :key="cat"
+        type="button"
+        class="btn btn-sm"
+        :class="cat === activeCat ? 'btn-primary' : 'btn-light'"
+        :data-test="`cat-${cat}`"
+        @click="activeCat = cat"
+      >
+        {{ cat }}<span class="ms-1 opacity-50">{{ countByCat[cat] }}</span>
+      </button>
+    </div>
+    <input
+      v-model="search"
+      type="search"
+      class="form-control mb-2"
+      data-test="app-search"
+      placeholder="Filter…"
+    />
+    <div class="row g-2">
+      <div
+        v-for="a in filtered"
+        :key="a.app_id"
+        class="col-6 col-md-3"
+        data-test="app-tile"
+      >
+        <button
+          type="button"
+          class="card w-100 text-start p-2"
+          :class="{ 'border-primary': store.draft.app_id === a.app_id }"
+          :data-test="`app-tile-${a.app_id}`"
+          @click="pick(a)"
+        >
+          <strong>{{ a.name }}</strong>
+          <span class="text-muted small d-block">{{ a.content.url }}</span>
+        </button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from "vue";
+import type { Application } from 
"django-airavata-common-ui/js/stores/launch-types";
+import { useLaunchStore } from "django-airavata-common-ui/js/stores/launch";
+
+const props = defineProps<{ applications: Application[] }>();
+const store = useLaunchStore();
+const activeCat = ref<string>("All");
+const search = ref("");
+
+const categories = computed(() => {
+  const set = new Set<string>(["All"]);
+  for (const a of props.applications) set.add(a.category);
+  return Array.from(set);
+});
+
+const countByCat = computed<Record<string, number>>(() => {
+  const out: Record<string, number> = { All: props.applications.length };
+  for (const a of props.applications) out[a.category] = (out[a.category] ?? 0) 
+ 1;
+  return out;
+});
+
+const filtered = computed(() => {
+  let xs = props.applications;
+  if (activeCat.value !== "All") xs = xs.filter((a) => a.category === 
activeCat.value);
+  if (search.value) {
+    const n = search.value.toLowerCase();
+    xs = xs.filter((a) => a.name.toLowerCase().includes(n));
+  }
+  return xs;
+});
+
+function pick(a: Application) {
+  store.pickApp(a);
+}
+</script>
diff --git 
a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/AppPicker.spec.ts
 
b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/AppPicker.spec.ts
new file mode 100644
index 000000000..3bf80e2e1
--- /dev/null
+++ 
b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/components/launch/AppPicker.spec.ts
@@ -0,0 +1,41 @@
+import { mount } from "@vue/test-utils";
+import { createPinia, setActivePinia } from "pinia";
+import { beforeEach, describe, expect, it } from "vitest";
+import AppPicker from "../../../../js/components/launch/AppPicker.vue";
+import { useLaunchStore } from "django-airavata-common-ui/js/stores/launch";
+
+const APPS = [
+  { app_id: "namd", name: "NAMD", category: "Molecular Dynamics",
+    content: { kind: "github" as const, url: "g" }, interfaces: [] },
+  { app_id: "gromacs", name: "GROMACS", category: "Molecular Dynamics",
+    content: { kind: "tarball" as const, url: "t" }, interfaces: [] },
+  { app_id: "alphafold", name: "AlphaFold", category: "ML / AI",
+    content: { kind: "github" as const, url: "g" }, interfaces: [] },
+];
+
+describe("AppPicker", () => {
+  beforeEach(() => setActivePinia(createPinia()));
+
+  it("renders all apps when category=All", () => {
+    const w = mount(AppPicker, { props: { applications: APPS } });
+    expect(w.findAll("[data-test='app-tile']")).toHaveLength(3);
+  });
+
+  it("filters by category chip", async () => {
+    const w = mount(AppPicker, { props: { applications: APPS } });
+    await w.find("[data-test='cat-ML / AI']").trigger("click");
+    expect(w.findAll("[data-test='app-tile']")).toHaveLength(1);
+  });
+
+  it("filters by search text within current category", async () => {
+    const w = mount(AppPicker, { props: { applications: APPS } });
+    await w.find("input[data-test='app-search']").setValue("NAMD");
+    expect(w.findAll("[data-test='app-tile']")).toHaveLength(1);
+  });
+
+  it("clicking a tile picks the app via the store", async () => {
+    const w = mount(AppPicker, { props: { applications: APPS } });
+    await w.find("[data-test='app-tile-namd']").trigger("click");
+    expect(useLaunchStore().draft.app_id).toBe("namd");
+  });
+});

Reply via email to