feat(plugins): add marketplace sort modes with release dates
This commit is contained in:
1 parent
4c1eaa6b16
commit
4b9a1c4d5c
14 files changed
+373
-6
No files matched your search
@@ -26,11 +26,14 @@ import {
|
||||
buildInstalledUpdateIndex,
|
||||
buildMarketplacePluginListings,
|
||||
filterMarketplacePluginListings,
|
||||
formatMarketplaceReleasedDate,
|
||||
listingRepositoryCanVerify,
|
||||
marketplaceHomepageUrl,
|
||||
pluginSourceChange,
|
||||
sortMarketplacePluginListings,
|
||||
type InstalledPluginUpdateEntry,
|
||||
type MarketplacePluginListing,
|
||||
type MarketplacePluginSortMode,
|
||||
type PluginSourceChange,
|
||||
} from "@/lib/plugins/pluginMarketplace";
|
||||
import { isBatchSelectableListing, runBatch } from "@/lib/plugins/pluginBatch";
|
||||
@@ -70,6 +73,7 @@ const GithubIcon = defineComponent({
|
||||
|
||||
const PLUGIN_ALLOW_UNSIGNED_STORAGE_KEY = "dbx-plugin-allow-unsigned";
|
||||
const MARKETPLACE_VIEW_MODE_STORAGE_KEY = "dbx-plugin-marketplace-view-mode";
|
||||
const MARKETPLACE_SORT_MODE_STORAGE_KEY = "dbx-plugin-marketplace-sort-mode";
|
||||
|
||||
type TauriFileDropPayload = { type: "enter"; paths: string[]; position: { x: number; y: number } } | { type: "over"; position: { x: number; y: number } } | { type: "drop"; paths: string[]; position: { x: number; y: number } } | { type: "leave" };
|
||||
|
||||
@@ -113,6 +117,15 @@ const repositoryCatalogUrl = ref("");
|
||||
const marketplaceQuery = ref("");
|
||||
const marketplaceRepositoryId = ref("all");
|
||||
const marketplaceViewMode = ref<"grid" | "list">(safeLocalStorageGet(MARKETPLACE_VIEW_MODE_STORAGE_KEY) === "list" ? "list" : "grid");
|
||||
const MARKETPLACE_SORT_MODES: MarketplacePluginSortMode[] = ["name", "recently-updated", "recently-listed", "updates-first"];
|
||||
// "name" stays the default: the batch specs select listings positionally, so a persisted
|
||||
// value must be re-validated against the known modes before it may change the order.
|
||||
const marketplaceSortMode = ref<MarketplacePluginSortMode>(
|
||||
((): MarketplacePluginSortMode => {
|
||||
const stored = safeLocalStorageGet(MARKETPLACE_SORT_MODE_STORAGE_KEY) as MarketplacePluginSortMode;
|
||||
return MARKETPLACE_SORT_MODES.includes(stored) ? stored : "name";
|
||||
})(),
|
||||
);
|
||||
const webFileInput = ref<HTMLInputElement | null>(null);
|
||||
const panelRootRef = ref<HTMLElement | null>(null);
|
||||
const draggingPackage = ref(false);
|
||||
@@ -148,6 +161,8 @@ const providerConnections = computed(() => {
|
||||
const selectedConnection = computed(() => providerConnections.value.find((connection) => connection.id === selectedConnectionId.value));
|
||||
const marketplaceListings = computed(() => buildMarketplacePluginListings(catalogResults.value, installedPlugins.value, appLocale.value));
|
||||
const filteredMarketplaceListings = computed(() => filterMarketplacePluginListings(marketplaceListings.value, marketplaceQuery.value, marketplaceRepositoryId.value));
|
||||
// Render order only: batch selection and update execution keep the builder's name order.
|
||||
const sortedMarketplaceListings = computed(() => sortMarketplacePluginListings(filteredMarketplaceListings.value, marketplaceSortMode.value));
|
||||
const batchUpdatableListings = computed(() => filteredMarketplaceListings.value.filter((listing) => listing.status === "update"));
|
||||
const batchSelectedListings = computed(() => filteredMarketplaceListings.value.filter((listing) => isBatchSelectableListing(listing.status) && selectedListingKeys.value.has(listing.key)));
|
||||
const batchSelectedInstalled = computed(() => definitions.value.filter((definition) => selectedInstalledIds.value.has(definition.plugin.manifest.id)));
|
||||
@@ -898,6 +913,7 @@ onMounted(() => {
|
||||
if (isTauriRuntime()) document.addEventListener("dbx:tauri-file-drop", onTauriPluginDrop);
|
||||
});
|
||||
watch(marketplaceViewMode, (mode) => safeLocalStorageSet(MARKETPLACE_VIEW_MODE_STORAGE_KEY, mode));
|
||||
watch(marketplaceSortMode, (mode) => safeLocalStorageSet(MARKETPLACE_SORT_MODE_STORAGE_KEY, mode));
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener(COMPONENT_PLUGINS_UPDATED_EVENT, handleComponentPluginsUpdated);
|
||||
if (isTauriRuntime()) document.removeEventListener("dbx:tauri-file-drop", onTauriPluginDrop);
|
||||
@@ -934,6 +950,15 @@ onBeforeUnmount(() => {
|
||||
<Input data-plugin-marketplace-search v-model="marketplaceQuery" class="h-8 min-w-0 pl-8 text-xs sm:w-[min(100%,28rem)]" :placeholder="t('pluginPlatform.searchMarketplace')" />
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center gap-2 sm:ml-auto">
|
||||
<Select v-model="marketplaceSortMode">
|
||||
<SelectTrigger class="h-8 min-w-0 flex-1 text-xs sm:w-40 sm:flex-none" :aria-label="t('pluginPlatform.sortBy')"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name">{{ t("pluginPlatform.sortByName") }}</SelectItem>
|
||||
<SelectItem value="recently-updated">{{ t("pluginPlatform.sortByRecentlyUpdated") }}</SelectItem>
|
||||
<SelectItem value="recently-listed">{{ t("pluginPlatform.sortByRecentlyListed") }}</SelectItem>
|
||||
<SelectItem value="updates-first">{{ t("pluginPlatform.sortByUpdatesFirst") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select v-model="marketplaceRepositoryId">
|
||||
<SelectTrigger class="h-8 min-w-0 flex-1 text-xs sm:w-52 sm:flex-none"><SelectValue :placeholder="t('pluginPlatform.allRepositories')" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -985,13 +1010,13 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div v-if="marketplaceLoading" class="flex min-h-[440px] flex-1 items-center justify-center gap-2 rounded-lg border border-dashed p-12 text-xs text-muted-foreground"><Loader2 class="size-4 animate-spin" />{{ t("pluginPlatform.loadingMarketplace") }}</div>
|
||||
<div v-else-if="!filteredMarketplaceListings.length" class="flex min-h-[440px] flex-1 flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
|
||||
<div v-else-if="!sortedMarketplaceListings.length" class="flex min-h-[440px] flex-1 flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
|
||||
<Store class="size-7 text-muted-foreground" />
|
||||
<div class="mt-3 text-sm font-medium">{{ t("pluginPlatform.noMarketplacePlugins") }}</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground">{{ t("pluginPlatform.noMarketplacePluginsDescription") }}</div>
|
||||
</div>
|
||||
<div v-else-if="marketplaceViewMode === 'grid'" class="grid w-full grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<article v-for="listing in filteredMarketplaceListings" :key="listing.key" class="group flex min-w-0 min-h-48 flex-col rounded-xl border bg-card p-4 transition-colors hover:border-primary/40">
|
||||
<article v-for="listing in sortedMarketplaceListings" :key="listing.key" class="group flex min-w-0 min-h-48 flex-col rounded-xl border bg-card p-4 transition-colors hover:border-primary/40">
|
||||
<div class="flex items-start gap-3">
|
||||
<button
|
||||
v-if="batchMode && isBatchSelectableListing(listing.status)"
|
||||
@@ -1036,6 +1061,7 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<Globe class="size-3.5" />
|
||||
</button>
|
||||
<span v-if="listing.latestVersionReleasedAt" class="shrink-0 text-[10px] text-muted-foreground">{{ formatMarketplaceReleasedDate(listing.latestVersionReleasedAt, appLocale) }}</span>
|
||||
<Badge variant="outline" class="h-5 px-1.5 text-[10px]">v{{ listing.plugin.latestVersion }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1081,7 +1107,7 @@ onBeforeUnmount(() => {
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="flex w-full flex-col gap-2">
|
||||
<article v-for="listing in filteredMarketplaceListings" :key="listing.key" class="flex items-center gap-3 rounded-xl border bg-card p-3 transition-colors hover:border-primary/40">
|
||||
<article v-for="listing in sortedMarketplaceListings" :key="listing.key" class="flex items-center gap-3 rounded-xl border bg-card p-3 transition-colors hover:border-primary/40">
|
||||
<button
|
||||
v-if="batchMode && isBatchSelectableListing(listing.status)"
|
||||
type="button"
|
||||
@@ -1118,6 +1144,7 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<Globe class="size-3.5" />
|
||||
</button>
|
||||
<span v-if="listing.latestVersionReleasedAt" class="shrink-0 text-[10px] text-muted-foreground">{{ formatMarketplaceReleasedDate(listing.latestVersionReleasedAt, appLocale) }}</span>
|
||||
<Badge variant="outline" class="h-5 shrink-0 px-1.5 text-[10px]">v{{ listing.plugin.latestVersion }}</Badge>
|
||||
</div>
|
||||
<div class="mt-0.5 flex min-w-0 items-center gap-1 text-xs text-muted-foreground">
|
||||
|
||||
+70
-1
@@ -3,7 +3,7 @@
|
||||
import { createApp, nextTick, type App, type ComponentPublicInstance } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { InstalledPlugin, PluginRepositoryCatalogResult } from "@/types/database";
|
||||
import type { MarketplacePluginListing } from "@/lib/plugins/pluginMarketplace";
|
||||
import { formatMarketplaceReleasedDate, type MarketplacePluginListing } from "@/lib/plugins/pluginMarketplace";
|
||||
import { COMPONENT_PLUGINS_UPDATED_EVENT, COMPONENT_UPDATES_CHANGED_EVENT } from "@/lib/updates/componentUpdateEvents";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -66,6 +66,7 @@ type PanelState = {
|
||||
batchRunning: boolean;
|
||||
batchMode: boolean;
|
||||
marketplaceViewMode: "grid" | "list";
|
||||
marketplaceSortMode: string;
|
||||
marketplaceRepositoryId: string;
|
||||
marketplaceListings: MarketplacePluginListing[];
|
||||
catalogResults: PluginRepositoryCatalogResult[];
|
||||
@@ -785,3 +786,71 @@ describe("PluginContributionsPanel completed batch outcomes", () => {
|
||||
expect(state.batchRunning).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginContributionsPanel marketplace sort", () => {
|
||||
const OLDEST = "2025-01-01T00:00:00Z";
|
||||
const NEWEST = "2026-06-01T00:00:00Z";
|
||||
|
||||
// Name order (a.older, b.newer) is deliberately the opposite of date order, so the
|
||||
// rendered order tells the two sort modes apart.
|
||||
function datedCatalog(): PluginRepositoryCatalogResult {
|
||||
const result = catalog("first", ["a.older", "b.newer"]);
|
||||
const [older, newer] = result.catalog!.plugins;
|
||||
older.versions[0].releasedAt = OLDEST;
|
||||
newer.versions[0].releasedAt = NEWEST;
|
||||
return result;
|
||||
}
|
||||
|
||||
function renderedOrder(): string[] {
|
||||
return [...host.querySelectorAll("article")].map((article) => (article.textContent?.includes("a.older") ? "a.older" : "b.newer"));
|
||||
}
|
||||
|
||||
// The persisted sort mode is read during setup, so restoring it can only be observed
|
||||
// by mounting again with localStorage already seeded.
|
||||
async function remount(): Promise<void> {
|
||||
app.unmount();
|
||||
host.remove();
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
app = createApp(PluginContributionsPanel, { onPluginRuntimeReplaced: mocks.refreshPluginWorkbenches });
|
||||
state = (app.mount(host) as ComponentPublicInstance & { $: { setupState: PanelState } }).$.setupState;
|
||||
await flushUi();
|
||||
state.batchMode = true;
|
||||
state.selectedPluginId = "a";
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
it("restores the persisted mode on remount, shows the release date in both views, and persists changes", async () => {
|
||||
mocks.fetchPluginMarketplaceCatalogs.mockResolvedValue([datedCatalog()]);
|
||||
localStorage.setItem("dbx-plugin-marketplace-sort-mode", "recently-updated");
|
||||
await remount();
|
||||
|
||||
expect(state.marketplaceSortMode).toBe("recently-updated");
|
||||
expect(renderedOrder()).toEqual(["b.newer", "a.older"]);
|
||||
const newestText = formatMarketplaceReleasedDate(NEWEST, "en");
|
||||
const oldestText = formatMarketplaceReleasedDate(OLDEST, "en");
|
||||
expect(newestText).not.toBe("");
|
||||
expect(host.querySelectorAll("article")[0].textContent).toContain(newestText);
|
||||
expect(host.querySelectorAll("article")[1].textContent).toContain(oldestText);
|
||||
|
||||
state.marketplaceViewMode = "list";
|
||||
await nextTick();
|
||||
expect(renderedOrder()).toEqual(["b.newer", "a.older"]);
|
||||
expect(host.querySelectorAll("article")[0].textContent).toContain(newestText);
|
||||
expect(host.querySelectorAll("article")[1].textContent).toContain(oldestText);
|
||||
|
||||
state.marketplaceSortMode = "name";
|
||||
await flushUi();
|
||||
expect(renderedOrder()).toEqual(["a.older", "b.newer"]);
|
||||
expect(localStorage.getItem("dbx-plugin-marketplace-sort-mode")).toBe("name");
|
||||
});
|
||||
|
||||
it("falls back to name order when the stored sort mode is unknown", async () => {
|
||||
mocks.fetchPluginMarketplaceCatalogs.mockResolvedValue([datedCatalog()]);
|
||||
localStorage.setItem("dbx-plugin-marketplace-sort-mode", "not-a-mode");
|
||||
await remount();
|
||||
|
||||
expect(state.marketplaceSortMode).toBe("name");
|
||||
expect(renderedOrder()).toEqual(["a.older", "b.newer"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user