From 0f2da4f785c385c1644715bffff77a4db2f02bfb Mon Sep 17 00:00:00 2001 From: Emma Segal-Grossman Date: Fri, 14 Feb 2025 00:59:29 -0500 Subject: [PATCH] Allow showing all collections as well as just public ones in org dashboard (#2379) Adds a switch to switch between viewing public collections only (default) and all collections on org dashboard. Also updates the `house-fill` icon to `house` in a couple places (@Shrinks99) --------- Co-authored-by: Henry Wilkinson --- .../features/collections/collections-grid.ts | 30 ++- frontend/src/pages/org/collection-detail.ts | 7 +- frontend/src/pages/org/dashboard.ts | 191 ++++++++++++++---- frontend/src/utils/timeoutCache.ts | 39 ++++ frontend/src/utils/weakCache.ts | 44 +++- 5 files changed, 255 insertions(+), 56 deletions(-) create mode 100644 frontend/src/utils/timeoutCache.ts diff --git a/frontend/src/features/collections/collections-grid.ts b/frontend/src/features/collections/collections-grid.ts index 501759db..9237e88a 100644 --- a/frontend/src/features/collections/collections-grid.ts +++ b/frontend/src/features/collections/collections-grid.ts @@ -1,11 +1,17 @@ import { localized, msg } from "@lit/localize"; import clsx from "clsx"; import { html, nothing } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; +import { + customElement, + property, + queryAssignedNodes, + state, +} from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { when } from "lit/directives/when.js"; import { CollectionThumbnail } from "./collection-thumbnail"; +import { SelectCollectionAccess } from "./select-collection-access"; import { BtrixElement } from "@/classes/BtrixElement"; import { RouteNamespace } from "@/routes"; @@ -32,6 +38,12 @@ export class CollectionsGrid extends BtrixElement { @property({ type: String }) collectionRefreshing: string | null = null; + @property({ type: Boolean }) + showVisibility = false; + + @queryAssignedNodes({ slot: "pagination" }) + pagination!: Node[]; + render() { const gridClassNames = tw`grid flex-1 grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4`; @@ -89,6 +101,16 @@ export class CollectionsGrid extends BtrixElement { ${this.renderDateBadge(collection)}
+ ${this.showVisibility + ? html`` + : nothing} @@ -118,6 +140,12 @@ export class CollectionsGrid extends BtrixElement { `, )} + + + ${when( showActions, () => diff --git a/frontend/src/pages/org/collection-detail.ts b/frontend/src/pages/org/collection-detail.ts index 2367f17c..ce2c125f 100644 --- a/frontend/src/pages/org/collection-detail.ts +++ b/frontend/src/pages/org/collection-detail.ts @@ -257,10 +257,7 @@ export class CollectionDetail extends BtrixElement { ${!this.collection || Boolean(this.collection.crawlCount && !this.isRwpLoaded) ? html`` - : html``} + : html``} ${msg("Set Initial View")} @@ -522,7 +519,7 @@ export class CollectionDetail extends BtrixElement { ${!this.collection || Boolean(this.collection.crawlCount && !this.isRwpLoaded) ? html`` - : html``} + : html``} ${msg("Set Initial View")} diff --git a/frontend/src/pages/org/dashboard.ts b/frontend/src/pages/org/dashboard.ts index 65db52dd..eac5b9da 100644 --- a/frontend/src/pages/org/dashboard.ts +++ b/frontend/src/pages/org/dashboard.ts @@ -1,6 +1,10 @@ import { localized, msg } from "@lit/localize"; -import { Task } from "@lit/task"; -import type { SlSelectEvent } from "@shoelace-style/shoelace"; +import { Task, TaskStatus } from "@lit/task"; +import type { + SlChangeEvent, + SlRadioGroup, + SlSelectEvent, +} from "@shoelace-style/shoelace"; import { html, nothing, type PropertyValues, type TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; @@ -10,6 +14,7 @@ import queryString from "query-string"; import type { SelectNewDialogEvent } from "."; import { BtrixElement } from "@/classes/BtrixElement"; +import { type PageChangeEvent } from "@/components/ui/pagination"; import { type CollectionSavedEvent } from "@/features/collections/collection-edit-dialog"; import { pageHeading } from "@/layouts/page"; import { pageHeader } from "@/layouts/pageHeader"; @@ -19,6 +24,8 @@ import { CollectionAccess, type Collection } from "@/types/collection"; import { SortDirection } from "@/types/utils"; import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter"; import { tw } from "@/utils/tailwind"; +import { timeoutCache } from "@/utils/timeoutCache"; +import { cached } from "@/utils/weakCache"; type Metrics = { storageUsedBytes: number; @@ -40,6 +47,13 @@ type Metrics = { publicCollectionsCount: number; }; +enum CollectionGridView { + All = "all", + Public = "public", +} + +const PAGE_SIZE = 16; + @customElement("btrix-dashboard") @localized() export class Dashboard extends BtrixElement { @@ -52,6 +66,15 @@ export class Dashboard extends BtrixElement { @state() collectionRefreshing: string | null = null; + @state() + collectionsView = CollectionGridView.Public; + + @state() + collectionPage = 1; + + // Used for busting cache when updating visible collection + cacheBust = 0; + private readonly colors = { default: "neutral", crawls: "green", @@ -60,15 +83,31 @@ export class Dashboard extends BtrixElement { runningTime: "blue", }; - private readonly publicCollections = new Task(this, { - task: async ([orgId]) => { - if (!orgId) throw new Error("orgId required"); + private readonly collections = new Task(this, { + task: cached( + async ([orgId, collectionsView, collectionPage]) => { + if (!orgId) throw new Error("orgId required"); - const collections = await this.getPublicCollections({ orgId }); - this.collectionRefreshing = null; - return collections; - }, - args: () => [this.orgId] as const, + const collections = await this.getCollections({ + orgId, + access: + collectionsView === CollectionGridView.Public + ? CollectionAccess.Public + : undefined, + page: collectionPage, + }); + this.collectionRefreshing = null; + return collections; + }, + { cacheConstructor: timeoutCache(300) }, + ), + args: () => + [ + this.orgId, + this.collectionsView, + this.collectionPage, + this.cacheBust, + ] as const, }); willUpdate(changedProperties: PropertyValues & Map) { @@ -330,17 +369,28 @@ export class Dashboard extends BtrixElement {
- ${pageHeading({ - content: msg("Public Collections"), - })}
- ${when( - this.appState.isCrawler, - () => html` - - ${when(this.org, (org) => - org.enablePublicProfile - ? html` — + + ${this.org?.enablePublicProfile + ? msg("Visit public collections gallery") + : msg("Preview public collections gallery")} + + + ${this.org?.enablePublicProfile + ? html`` - : nothing, - )} + : nothing} + ` + : nothing} +
+
+ ${when( + this.appState.isCrawler, + () => html` `, )} - { + this.collectionPage = 1; + this.collectionsView = (e.target as SlRadioGroup) + .value as CollectionGridView; + }} > - - + + + + + + +
-
+
{ this.collectionRefreshing = e.detail.id; - void this.publicCollections.run([this.orgId]); + void this.collections.run([ + this.orgId, + this.collectionsView, + this.collectionPage, + ++this.cacheBust, + ]); }} > ${this.renderNoPublicCollections()} + ${this.collections.value && + this.collections.value.total > this.collections.value.items.length + ? html` + { + this.collectionPage = e.detail.page; + }} + slot="pagination" + > + + ` + : nothing} + ${this.collections.status === TaskStatus.PENDING && + this.collections.value + ? html`
+ +
` + : nothing}
@@ -864,13 +963,25 @@ export class Dashboard extends BtrixElement { } } - private async getPublicCollections({ orgId }: { orgId: string }) { + private async getCollections({ + orgId, + access, + page, + }: { + orgId: string; + access?: CollectionAccess; + page?: number; + }) { const params: APISortQuery & { - access: CollectionAccess; + access?: CollectionAccess; + page?: number; + pageSize?: number; } = { sortBy: "dateLatest", sortDirection: SortDirection.Descending, - access: CollectionAccess.Public, + access, + page, + pageSize: PAGE_SIZE, }; const query = queryString.stringify(params); @@ -878,6 +989,6 @@ export class Dashboard extends BtrixElement { `/orgs/${orgId}/collections?${query}`, ); - return data.items; + return data; } } diff --git a/frontend/src/utils/timeoutCache.ts b/frontend/src/utils/timeoutCache.ts new file mode 100644 index 00000000..4a4b2d0c --- /dev/null +++ b/frontend/src/utils/timeoutCache.ts @@ -0,0 +1,39 @@ +import { type Cache } from "./weakCache"; + +export function timeoutCache(seconds: number) { + return class implements Cache { + private readonly cache: { [key: string]: V } = Object.create(null); + set(key: K | string, value: V) { + if (typeof key !== "string") { + key = JSON.stringify(key); + } + this.cache[key] = value; + setTimeout(() => { + try { + delete this.cache[key as string]; + } catch (_) { + /* empty */ + console.debug("missing key", key); + } + }, seconds * 1000); + return this; + } + get(key: K | string) { + if (typeof key !== "string") { + key = JSON.stringify(key); + } + try { + return this.cache[key]; + } catch (_) { + console.debug("missing key", key); + /* empty */ + } + } + has(key: K | string) { + if (typeof key !== "string") { + key = JSON.stringify(key); + } + return Object.prototype.hasOwnProperty.call(this.cache, key); + } + }; +} diff --git a/frontend/src/utils/weakCache.ts b/frontend/src/utils/weakCache.ts index e2a944b8..b2429527 100644 --- a/frontend/src/utils/weakCache.ts +++ b/frontend/src/utils/weakCache.ts @@ -1,3 +1,14 @@ +export interface Cache { + set: (key: K, value: V) => this; + get: (key: K) => V | undefined; + has: (key: K) => boolean; +} + +export interface CacheConstructor { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new (...args: any[]): Cache; +} + export const WeakRefMapInnerValue = Symbol("inner value"); type WeakRefValue = T extends object @@ -28,16 +39,16 @@ const unwrapValue = (val: WeakRef> | undefined) => { * * Adapted from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_management#weakrefs_and_finalizationregistry and https://stackoverflow.com/a/72896692 */ -export class WeakRefMap { +export class WeakRefMap implements Cache { readonly cacheMap = new Map>>(); private readonly finalizer = new FinalizationRegistry((key: K) => { this.cacheMap.delete(key); }); - set(key: K, value: V): V { + set(key: K, value: V): this { const cache = this.get(key); if (cache) { - if (cache === value) return value; + if (cache === value) return this; if (typeof cache === "object") { this.finalizer.unregister(cache); } @@ -47,14 +58,14 @@ export class WeakRefMap { this.cacheMap.set(key, ref); this.finalizer.register(objVal, key, objVal); - return isWrapped(objVal) ? objVal[WeakRefMapInnerValue] : (objVal as V); + return this; } - get(key: K): V | undefined { + get(key: K) { return unwrapValue(this.cacheMap.get(key)); } - has(key: K): boolean { + has(key: K) { return this.cacheMap.has(key); } } @@ -67,13 +78,20 @@ export function cached< Serializer extends (args: Args) => unknown = (args: Args) => string, >( fn: (...args: Args) => Result, - serializer: Serializer = JSON.stringify as Serializer, + options: { + cacheConstructor?: CacheConstructor; + serializer?: Serializer; + } = {}, ) { + const { + serializer = JSON.stringify as Serializer, + cacheConstructor = WeakRefMap, + } = options; type Key = ReturnType; - const cache = new WeakRefMap(); + const cache = new cacheConstructor(); const cachedFn: { (...args: Args): Result; - [InnerCache]: WeakRefMap; + [InnerCache]: Cache; } = (...args: Args) => { let k; try { @@ -83,7 +101,13 @@ export function cached< "Unable to serialize function arguments successfully - ensure your serializer function is able to handle the args you're passing in", ); } - return cache.get(k) ?? cache.set(k, fn(...args)); + if (cache.has(k)) { + return cache.get(k)!; + } else { + const v = fn(...args); + cache.set(k, v); + return v; + } }; cachedFn[InnerCache] = cache; return cachedFn;