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 <henry@wilkinson.graphics>
This commit is contained in:
Emma Segal-Grossman 2025-02-14 00:59:29 -05:00 committed by GitHub
parent 4516268a70
commit 0f2da4f785
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 255 additions and 56 deletions

View File

@ -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)}
</div>
<div class="${showActions ? "mr-9" : ""} min-h-9 leading-tight">
${this.showVisibility
? html`<sl-icon
class="mr-[5px] align-[-1px] text-sm"
name=${SelectCollectionAccess.Options[collection.access]
.icon}
label=${SelectCollectionAccess.Options[
collection.access
].label}
></sl-icon>`
: nothing}
<strong
class="text-base font-medium leading-tight text-stone-700 transition-colors group-hover:text-cyan-600"
>
@ -118,6 +140,12 @@ export class CollectionsGrid extends BtrixElement {
`,
)}
</ul>
<slot
class=${clsx("justify-center flex", this.pagination.length && "mt-10")}
name="pagination"
></slot>
${when(
showActions,
() =>

View File

@ -257,10 +257,7 @@ export class CollectionDetail extends BtrixElement {
${!this.collection ||
Boolean(this.collection.crawlCount && !this.isRwpLoaded)
? html`<sl-spinner slot="prefix"></sl-spinner>`
: html`<sl-icon
name="house-fill"
slot="prefix"
></sl-icon>`}
: html`<sl-icon name="house" slot="prefix"></sl-icon>`}
${msg("Set Initial View")}
</sl-button>
</sl-tooltip>
@ -522,7 +519,7 @@ export class CollectionDetail extends BtrixElement {
${!this.collection ||
Boolean(this.collection.crawlCount && !this.isRwpLoaded)
? html`<sl-spinner slot="prefix"></sl-spinner>`
: html`<sl-icon name="house-fill" slot="prefix"></sl-icon>`}
: html`<sl-icon name="house" slot="prefix"></sl-icon>`}
${msg("Set Initial View")}
</sl-menu-item>
</sl-tooltip>

View File

@ -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<this> & Map<string, unknown>) {
@ -330,17 +369,28 @@ export class Dashboard extends BtrixElement {
<section class="mb-16">
<header class="mb-1.5 flex items-center justify-between">
${pageHeading({
content: msg("Public Collections"),
})}
<div class="flex items-center gap-2">
${when(
this.appState.isCrawler,
() => html`
<!-- TODO Refactor clipboard code, get URL in a nicer way? -->
${when(this.org, (org) =>
org.enablePublicProfile
? html` <btrix-copy-button
${pageHeading({
content:
this.collectionsView === CollectionGridView.Public
? msg("Public Collections")
: msg("All Collections"),
})}
${this.collectionsView === CollectionGridView.Public
? html` <span class="text-sm text-neutral-400"
>
<a
href=${`/${RouteNamespace.PublicOrgs}/${this.orgSlugState}`}
class="inline-flex h-8 items-center text-sm font-medium text-primary-500 transition hover:text-primary-600"
@click=${this.navigate.link}
>
${this.org?.enablePublicProfile
? msg("Visit public collections gallery")
: msg("Preview public collections gallery")}
</a>
<!-- TODO Refactor clipboard code, get URL in a nicer way? -->
${this.org?.enablePublicProfile
? html`<btrix-copy-button
value=${new URL(
`/${RouteNamespace.PublicOrgs}/${this.orgSlugState}`,
window.location.toString(),
@ -348,9 +398,16 @@ export class Dashboard extends BtrixElement {
content=${msg(
"Copy Link to Public Collections Gallery",
)}
class="inline-block"
></btrix-copy-button>`
: nothing,
)}
: nothing}
</span>`
: nothing}
</div>
<div class="flex items-center gap-2">
${when(
this.appState.isCrawler,
() => html`
<sl-tooltip content=${msg("Manage Collections")}>
<sl-icon-button
href=${`${this.navigate.orgBasePath}/collections`}
@ -361,32 +418,74 @@ export class Dashboard extends BtrixElement {
</sl-tooltip>
`,
)}
<sl-tooltip
content=${this.org?.enablePublicProfile
? msg("Visit Public Collections Gallery")
: msg("Preview Public Collections Gallery")}
<sl-radio-group
value=${this.collectionsView}
size="small"
@sl-change=${(e: SlChangeEvent) => {
this.collectionPage = 1;
this.collectionsView = (e.target as SlRadioGroup)
.value as CollectionGridView;
}}
>
<sl-icon-button
href=${`/${RouteNamespace.PublicOrgs}/${this.orgSlugState}`}
class="size-8 text-base"
name="globe2"
@click=${this.navigate.link}
></sl-icon-button>
</sl-tooltip>
<sl-tooltip content=${msg("Public Collections")}>
<sl-radio-button pill value=${CollectionGridView.Public}>
<sl-icon
name="globe"
label=${msg("Public Collections")}
></sl-icon> </sl-radio-button
></sl-tooltip>
<sl-tooltip content=${msg("All Collections")}>
<sl-radio-button pill value=${CollectionGridView.All}>
<sl-icon
name="asterisk"
label=${msg("All Collections")}
></sl-icon> </sl-radio-button
></sl-tooltip>
</sl-radio-group>
</div>
</header>
<div class="rounded-lg border p-10">
<div class="relative rounded-lg border p-10">
<btrix-collections-grid
slug=${this.orgSlugState || ""}
.collections=${this.publicCollections.value}
.collections=${this.collections.value?.items}
.collectionRefreshing=${this.collectionRefreshing}
?showVisibility=${this.collectionsView === CollectionGridView.All}
@btrix-collection-saved=${async (e: CollectionSavedEvent) => {
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`
<btrix-pagination
page=${this.collectionPage}
size=${PAGE_SIZE}
totalCount=${this.collections.value.total}
@page-change=${(e: PageChangeEvent) => {
this.collectionPage = e.detail.page;
}}
slot="pagination"
>
</btrix-pagination>
`
: nothing}
</btrix-collections-grid>
${this.collections.status === TaskStatus.PENDING &&
this.collections.value
? html`<div
class="absolute inset-0 rounded-lg bg-stone-50/75 p-24 text-center text-4xl"
>
<sl-spinner></sl-spinner>
</div>`
: nothing}
</div>
</section>
</main>
@ -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<Collection> & {
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;
}
}

View File

@ -0,0 +1,39 @@
import { type Cache } from "./weakCache";
export function timeoutCache(seconds: number) {
return class<K, V> implements Cache<K, V> {
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);
}
};
}

View File

@ -1,3 +1,14 @@
export interface Cache<K, V> {
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 <K, V>(...args: any[]): Cache<K, V>;
}
export const WeakRefMapInnerValue = Symbol("inner value");
type WeakRefValue<T> = T extends object
@ -28,16 +39,16 @@ const unwrapValue = <V>(val: WeakRef<WeakRefValue<V>> | 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<K, V> {
export class WeakRefMap<K, V> implements Cache<K, V> {
readonly cacheMap = new Map<K, WeakRef<WeakRefValue<V>>>();
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<K, V> {
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<Serializer>;
const cache = new WeakRefMap<Key, Result>();
const cache = new cacheConstructor<Key, Result>();
const cachedFn: {
(...args: Args): Result;
[InnerCache]: WeakRefMap<Key, Result>;
[InnerCache]: Cache<Key, Result>;
} = (...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;