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 { localized, msg } from "@lit/localize";
import clsx from "clsx"; import clsx from "clsx";
import { html, nothing } from "lit"; 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 { ifDefined } from "lit/directives/if-defined.js";
import { when } from "lit/directives/when.js"; import { when } from "lit/directives/when.js";
import { CollectionThumbnail } from "./collection-thumbnail"; import { CollectionThumbnail } from "./collection-thumbnail";
import { SelectCollectionAccess } from "./select-collection-access";
import { BtrixElement } from "@/classes/BtrixElement"; import { BtrixElement } from "@/classes/BtrixElement";
import { RouteNamespace } from "@/routes"; import { RouteNamespace } from "@/routes";
@ -32,6 +38,12 @@ export class CollectionsGrid extends BtrixElement {
@property({ type: String }) @property({ type: String })
collectionRefreshing: string | null = null; collectionRefreshing: string | null = null;
@property({ type: Boolean })
showVisibility = false;
@queryAssignedNodes({ slot: "pagination" })
pagination!: Node[];
render() { render() {
const gridClassNames = tw`grid flex-1 grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4`; 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.renderDateBadge(collection)}
</div> </div>
<div class="${showActions ? "mr-9" : ""} min-h-9 leading-tight"> <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 <strong
class="text-base font-medium leading-tight text-stone-700 transition-colors group-hover:text-cyan-600" 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> </ul>
<slot
class=${clsx("justify-center flex", this.pagination.length && "mt-10")}
name="pagination"
></slot>
${when( ${when(
showActions, showActions,
() => () =>

View File

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

View File

@ -1,6 +1,10 @@
import { localized, msg } from "@lit/localize"; import { localized, msg } from "@lit/localize";
import { Task } from "@lit/task"; import { Task, TaskStatus } from "@lit/task";
import type { SlSelectEvent } from "@shoelace-style/shoelace"; import type {
SlChangeEvent,
SlRadioGroup,
SlSelectEvent,
} from "@shoelace-style/shoelace";
import { html, nothing, type PropertyValues, type TemplateResult } from "lit"; import { html, nothing, type PropertyValues, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js"; import { ifDefined } from "lit/directives/if-defined.js";
@ -10,6 +14,7 @@ import queryString from "query-string";
import type { SelectNewDialogEvent } from "."; import type { SelectNewDialogEvent } from ".";
import { BtrixElement } from "@/classes/BtrixElement"; import { BtrixElement } from "@/classes/BtrixElement";
import { type PageChangeEvent } from "@/components/ui/pagination";
import { type CollectionSavedEvent } from "@/features/collections/collection-edit-dialog"; import { type CollectionSavedEvent } from "@/features/collections/collection-edit-dialog";
import { pageHeading } from "@/layouts/page"; import { pageHeading } from "@/layouts/page";
import { pageHeader } from "@/layouts/pageHeader"; import { pageHeader } from "@/layouts/pageHeader";
@ -19,6 +24,8 @@ import { CollectionAccess, type Collection } from "@/types/collection";
import { SortDirection } from "@/types/utils"; import { SortDirection } from "@/types/utils";
import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter"; import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter";
import { tw } from "@/utils/tailwind"; import { tw } from "@/utils/tailwind";
import { timeoutCache } from "@/utils/timeoutCache";
import { cached } from "@/utils/weakCache";
type Metrics = { type Metrics = {
storageUsedBytes: number; storageUsedBytes: number;
@ -40,6 +47,13 @@ type Metrics = {
publicCollectionsCount: number; publicCollectionsCount: number;
}; };
enum CollectionGridView {
All = "all",
Public = "public",
}
const PAGE_SIZE = 16;
@customElement("btrix-dashboard") @customElement("btrix-dashboard")
@localized() @localized()
export class Dashboard extends BtrixElement { export class Dashboard extends BtrixElement {
@ -52,6 +66,15 @@ export class Dashboard extends BtrixElement {
@state() @state()
collectionRefreshing: string | null = null; collectionRefreshing: string | null = null;
@state()
collectionsView = CollectionGridView.Public;
@state()
collectionPage = 1;
// Used for busting cache when updating visible collection
cacheBust = 0;
private readonly colors = { private readonly colors = {
default: "neutral", default: "neutral",
crawls: "green", crawls: "green",
@ -60,15 +83,31 @@ export class Dashboard extends BtrixElement {
runningTime: "blue", runningTime: "blue",
}; };
private readonly publicCollections = new Task(this, { private readonly collections = new Task(this, {
task: async ([orgId]) => { task: cached(
if (!orgId) throw new Error("orgId required"); async ([orgId, collectionsView, collectionPage]) => {
if (!orgId) throw new Error("orgId required");
const collections = await this.getPublicCollections({ orgId }); const collections = await this.getCollections({
this.collectionRefreshing = null; orgId,
return collections; access:
}, collectionsView === CollectionGridView.Public
args: () => [this.orgId] as const, ? 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>) { willUpdate(changedProperties: PropertyValues<this> & Map<string, unknown>) {
@ -330,17 +369,28 @@ export class Dashboard extends BtrixElement {
<section class="mb-16"> <section class="mb-16">
<header class="mb-1.5 flex items-center justify-between"> <header class="mb-1.5 flex items-center justify-between">
${pageHeading({
content: msg("Public Collections"),
})}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
${when( ${pageHeading({
this.appState.isCrawler, content:
() => html` this.collectionsView === CollectionGridView.Public
<!-- TODO Refactor clipboard code, get URL in a nicer way? --> ? msg("Public Collections")
${when(this.org, (org) => : msg("All Collections"),
org.enablePublicProfile })}
? html` <btrix-copy-button ${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( value=${new URL(
`/${RouteNamespace.PublicOrgs}/${this.orgSlugState}`, `/${RouteNamespace.PublicOrgs}/${this.orgSlugState}`,
window.location.toString(), window.location.toString(),
@ -348,9 +398,16 @@ export class Dashboard extends BtrixElement {
content=${msg( content=${msg(
"Copy Link to Public Collections Gallery", "Copy Link to Public Collections Gallery",
)} )}
class="inline-block"
></btrix-copy-button>` ></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-tooltip content=${msg("Manage Collections")}>
<sl-icon-button <sl-icon-button
href=${`${this.navigate.orgBasePath}/collections`} href=${`${this.navigate.orgBasePath}/collections`}
@ -361,32 +418,74 @@ export class Dashboard extends BtrixElement {
</sl-tooltip> </sl-tooltip>
`, `,
)} )}
<sl-tooltip
content=${this.org?.enablePublicProfile <sl-radio-group
? msg("Visit Public Collections Gallery") value=${this.collectionsView}
: msg("Preview Public Collections Gallery")} size="small"
@sl-change=${(e: SlChangeEvent) => {
this.collectionPage = 1;
this.collectionsView = (e.target as SlRadioGroup)
.value as CollectionGridView;
}}
> >
<sl-icon-button <sl-tooltip content=${msg("Public Collections")}>
href=${`/${RouteNamespace.PublicOrgs}/${this.orgSlugState}`} <sl-radio-button pill value=${CollectionGridView.Public}>
class="size-8 text-base" <sl-icon
name="globe2" name="globe"
@click=${this.navigate.link} label=${msg("Public Collections")}
></sl-icon-button> ></sl-icon> </sl-radio-button
</sl-tooltip> ></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> </div>
</header> </header>
<div class="rounded-lg border p-10"> <div class="relative rounded-lg border p-10">
<btrix-collections-grid <btrix-collections-grid
slug=${this.orgSlugState || ""} slug=${this.orgSlugState || ""}
.collections=${this.publicCollections.value} .collections=${this.collections.value?.items}
.collectionRefreshing=${this.collectionRefreshing} .collectionRefreshing=${this.collectionRefreshing}
?showVisibility=${this.collectionsView === CollectionGridView.All}
@btrix-collection-saved=${async (e: CollectionSavedEvent) => { @btrix-collection-saved=${async (e: CollectionSavedEvent) => {
this.collectionRefreshing = e.detail.id; 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.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> </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> </div>
</section> </section>
</main> </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> & { const params: APISortQuery<Collection> & {
access: CollectionAccess; access?: CollectionAccess;
page?: number;
pageSize?: number;
} = { } = {
sortBy: "dateLatest", sortBy: "dateLatest",
sortDirection: SortDirection.Descending, sortDirection: SortDirection.Descending,
access: CollectionAccess.Public, access,
page,
pageSize: PAGE_SIZE,
}; };
const query = queryString.stringify(params); const query = queryString.stringify(params);
@ -878,6 +989,6 @@ export class Dashboard extends BtrixElement {
`/orgs/${orgId}/collections?${query}`, `/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"); export const WeakRefMapInnerValue = Symbol("inner value");
type WeakRefValue<T> = T extends object 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 * 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>>>(); readonly cacheMap = new Map<K, WeakRef<WeakRefValue<V>>>();
private readonly finalizer = new FinalizationRegistry((key: K) => { private readonly finalizer = new FinalizationRegistry((key: K) => {
this.cacheMap.delete(key); this.cacheMap.delete(key);
}); });
set(key: K, value: V): V { set(key: K, value: V): this {
const cache = this.get(key); const cache = this.get(key);
if (cache) { if (cache) {
if (cache === value) return value; if (cache === value) return this;
if (typeof cache === "object") { if (typeof cache === "object") {
this.finalizer.unregister(cache); this.finalizer.unregister(cache);
} }
@ -47,14 +58,14 @@ export class WeakRefMap<K, V> {
this.cacheMap.set(key, ref); this.cacheMap.set(key, ref);
this.finalizer.register(objVal, key, objVal); 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)); return unwrapValue(this.cacheMap.get(key));
} }
has(key: K): boolean { has(key: K) {
return this.cacheMap.has(key); return this.cacheMap.has(key);
} }
} }
@ -67,13 +78,20 @@ export function cached<
Serializer extends (args: Args) => unknown = (args: Args) => string, Serializer extends (args: Args) => unknown = (args: Args) => string,
>( >(
fn: (...args: Args) => Result, 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>; type Key = ReturnType<Serializer>;
const cache = new WeakRefMap<Key, Result>(); const cache = new cacheConstructor<Key, Result>();
const cachedFn: { const cachedFn: {
(...args: Args): Result; (...args: Args): Result;
[InnerCache]: WeakRefMap<Key, Result>; [InnerCache]: Cache<Key, Result>;
} = (...args: Args) => { } = (...args: Args) => {
let k; let k;
try { 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", "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; cachedFn[InnerCache] = cache;
return cachedFn; return cachedFn;