Compare commits

...
10 changed files with 398 additions and 14 deletions

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@ import {
// The filters of the Marketplace tables. Search, sorting and grouping are kept
// by the data table, see data-table-query-params.
export const marketplaceFilterQueryParamConfig = {
list: ["status", "type", "source"],
list: ["status", "type", "source", "author"],
} as const satisfies QueryParamConfig;
export type MarketplaceFilterQueryParams = QueryParamValues<
@@ -0,0 +1,219 @@
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../../../common/controllers/filter-panel-controller";
import {
fireEvent,
type HASSDomTargetEvent,
} from "../../../common/dom/fire_event";
import "../../../components/ha-check-list-item";
import "../../../components/ha-expansion-panel";
import "../../../components/ha-icon-button";
import "../../../components/ha-list";
import "../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../components/input/ha-input-search";
import { haStyleScrollbar } from "../../../resources/styles";
import { loadVirtualizer } from "../../../resources/virtualizer";
import type { AuthorOption } from "../dashboards/dashboard-repositories";
// There can be thousands of authors, only the ones in view are rendered
@customElement("ha-marketplace-filter-authors")
export class HaMarketplaceFilterAuthors extends LitElement {
@property() public label?: string;
@property({ attribute: false }) public value?: string[];
@property({ attribute: false }) public authors: AuthorOption[] = [];
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _search = "";
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
if (!this.hasUpdated) {
loadVirtualizer();
}
}
protected render() {
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.label}
${
this.value?.length
? html`<div class="badge">${this.value.length}</div>
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
></ha-icon-button>`
: nothing
}
</div>
</ha-expansion-panel>
${
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
<ha-input-search
appearance="outlined"
.value=${this._search}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list class="ha-scrollbar" multi>
<lit-virtualizer
.items=${this._filteredAuthors(this.authors, this._search)}
.keyFunction=${this._keyFunction}
.renderItem=${this._renderItem}
@click=${this._handleItemClick}
@keydown=${this._handleItemKeydown}
>
</lit-virtualizer>
</ha-list>
</div>
`
: nothing
}
`;
}
private _filteredAuthors = memoizeOne(
(authors: AuthorOption[], search: string): AuthorOption[] => {
const query = search.trim().toLowerCase();
return query
? authors.filter((author) => author.value.includes(query))
: authors;
}
);
private _keyFunction = (author?: AuthorOption) => author?.value;
private _renderItem = (author?: AuthorOption) =>
author
? html`<ha-check-list-item
tabindex="0"
.value=${author.value}
.selected=${this.value?.includes(author.value) ?? false}
>
${author.label}
</ha-check-list-item>`
: nothing;
private _handleItemKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._handleItemClick(ev);
}
}
private _handleItemClick(ev: Event) {
if (!(ev.target instanceof Element)) {
return;
}
const listItem = ev.target.closest("ha-check-list-item");
const author = listItem?.value;
if (!listItem || !author) {
return;
}
const value = this.value?.includes(author)
? this.value.filter((picked) => picked !== author)
: [...(this.value ?? []), author];
this.value = value;
listItem.selected = value.includes(author);
fireEvent(this, "data-table-filter-changed", {
value,
items: undefined,
});
}
private _expandedChanged(ev: CustomEvent<{ expanded: boolean }>) {
this.expanded = ev.detail.expanded;
}
private _handleSearchChange(ev: HASSDomTargetEvent<HaInputSearch>) {
this._search = ev.target.value ?? "";
}
private _clearFilter(ev: Event) {
ev.preventDefault();
this.value = undefined;
fireEvent(this, "data-table-filter-changed", {
value: undefined,
items: undefined,
});
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
flex: 1;
min-height: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-check-list-item {
width: 100%;
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-marketplace-filter-authors": HaMarketplaceFilterAuthors;
}
}
@@ -1,5 +1,6 @@
import type { HassEntities } from "home-assistant-js-websocket";
import { computeDomain } from "../../../common/entity/compute_domain";
import { stringCompare } from "../../../common/string/compare";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { dataTableQueryParamConfig } from "../../../common/url/data-table-query-params";
import {
@@ -16,6 +17,7 @@ import {
import type { DataTableRowData } from "../../../components/data-table/ha-data-table";
import type { RepositoryBase } from "../../../data/marketplace/repository";
import type { HomeAssistant } from "../../../types";
import { repositoryMakers } from "../tools/authors";
import type { MarketplaceTab } from "./ha-marketplace-dashboard";
export const STATUS_ORDER = [
@@ -38,6 +40,8 @@ export const TYPE_FILTER = "type";
export const SOURCE_FILTER = "source";
export const AUTHOR_FILTER = "author";
// Where a repository comes from, the community catalog or added from a link
export const SOURCE_ORDER = ["catalog", "custom"] as const;
@@ -46,10 +50,16 @@ type RepositorySource = (typeof SOURCE_ORDER)[number];
const repositorySource = (repository: RepositoryBase): RepositorySource =>
repository.custom ? "custom" : "catalog";
// GitHub names are the same person whatever their case
const authorKey = (author: string): string => author.toLowerCase();
// What the filter panes picked, nothing picked in a pane shows everything
export type RepositoryFilters = Partial<
Record<
typeof STATUS_FILTER | typeof TYPE_FILTER | typeof SOURCE_FILTER,
| typeof STATUS_FILTER
| typeof TYPE_FILTER
| typeof SOURCE_FILTER
| typeof AUTHOR_FILTER,
string[]
>
>;
@@ -185,9 +195,47 @@ const matchesFilters = (
return false;
}
const authors = filters[AUTHOR_FILTER];
if (
authors?.length &&
!repositoryMakers(repository).some((author) =>
authors.includes(authorKey(author))
)
) {
return false;
}
return true;
};
export interface AuthorOption {
value: string;
label: string;
}
// Everyone who made something, once each, in the order of their names
export const repositoryAuthorOptions = (
repositories: RepositoryBase[],
language?: string
): AuthorOption[] => {
const options = new Map<string, AuthorOption>();
for (const repository of repositories) {
for (const author of repositoryMakers(repository)) {
const value = authorKey(author);
if (!options.has(value)) {
options.set(value, { value, label: author });
}
}
}
return [...options.values()].sort((a, b) =>
stringCompare(a.label, b.label, language)
);
};
// Downloaded first, then new ones, then the most starred.
const compareRepositories = (a: RepositoryBase, b: RepositoryBase): number => {
if (a.installed !== b.installed) {
@@ -226,6 +274,7 @@ export const filterRepositories = (
translated_source: localize(
`ui.panel.marketplace.repository_source.${repositorySource(repository)}`
),
author: repositoryMakers(repository).join(", "),
// A date as text or 0 without one, only numbers sort among each other
last_updated_timestamp: timestamp(repository.last_updated),
}));
@@ -289,6 +338,7 @@ export const browseLinkFromUrl = (
[STATUS_FILTER]: lists.status?.filter(Boolean),
[TYPE_FILTER]: lists.type?.filter(Boolean),
[SOURCE_FILTER]: lists.source?.filter(Boolean),
[AUTHOR_FILTER]: lists.author?.filter(Boolean).map(authorKey),
},
};
};
@@ -57,6 +57,7 @@ import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-
import type { HomeAssistantUI, Route } from "../../../types";
import { showMarketplaceAddFromLink } from "../tools/add-from-link";
import "../components/ha-marketplace-discover";
import "../components/ha-marketplace-filter-authors";
import type { MarketplaceRepositoryMenuItem } from "../components/ha-marketplace-repository-overflow-menu";
import {
renderRepositoryMenuEntry,
@@ -88,10 +89,12 @@ import {
import { haStyle } from "../../../resources/styles";
import {
applySkippedUpdates,
AUTHOR_FILTER,
browseLinkFromUrl,
filterRepositories,
filtersOfTab,
marketplaceUpdateEntityIds,
repositoryAuthorOptions,
skippedUpdateRepositories,
SOURCE_FILTER,
SOURCE_ORDER,
@@ -467,6 +470,17 @@ export class HaMarketplaceDashboard extends LitElement {
.narrow=${this.narrow}
@data-table-filter-changed=${this._sourceFilterChanged}
></ha-filter-states>
<ha-marketplace-filter-authors
slot="filter-pane"
.label=${this._i18n.localize("ui.panel.marketplace.filters.author")}
.value=${this._filters[AUTHOR_FILTER]}
.authors=${this._authorOptions(
this.marketplace.repositories,
this._i18n.locale.language
)}
.narrow=${this.narrow}
@data-table-filter-changed=${this._authorFilterChanged}
></ha-marketplace-filter-authors>
</hass-tabs-subpage-data-table>
<ha-dropdown
id="repository-overflow-menu"
@@ -599,6 +613,14 @@ export class HaMarketplaceDashboard extends LitElement {
? html`<div class="secondary">${repository.description}</div>`
: nothing,
},
author: {
...defaultKeyData,
title: localizeFunc("ui.panel.marketplace.column.author"),
sortable: true,
defaultHidden: true,
hidden: false,
flex: 2,
},
downloads: {
...defaultKeyData,
title: localizeFunc("ui.panel.marketplace.column.downloads"),
@@ -698,7 +720,6 @@ export class HaMarketplaceDashboard extends LitElement {
defaultHidden: true,
},
description: defaultKeyData,
authors: defaultKeyData,
domain: defaultKeyData,
full_name: defaultKeyData,
id: defaultKeyData,
@@ -979,6 +1000,9 @@ export class HaMarketplaceDashboard extends LitElement {
}))
);
// From every repository, so an author picked on one tab can be unpicked on another
private _authorOptions = memoize(repositoryAuthorOptions);
// Statuses and sources group in the order of their filters, not alphabetically
private _groupOrder = memoize(
(grouping: string | undefined, localize: LocalizeFunc) => {
@@ -1025,6 +1049,11 @@ export class HaMarketplaceDashboard extends LitElement {
this._writeFilters();
}
private _authorFilterChanged(ev: CustomEvent<{ value?: string[] }>) {
this._filters = { ...this._filters, [AUTHOR_FILTER]: ev.detail.value };
this._writeFilters();
}
private _handleSearchFilterChanged(ev: CustomEvent) {
this._activeSearch = ev.detail.value;
}
@@ -89,7 +89,7 @@ import { marketplaceBrandsUrl } from "../../../util/brands-url";
import { generateFrontendResourceURL } from "../tools/frontend-resource";
import { installBlockedReason } from "../tools/install-blocked-reason";
import { typeIcon } from "../tools/type-icon";
import { isCommunityOrganization, repositoryAuthors } from "../tools/authors";
import { repositoryMakers } from "../tools/authors";
import {
markdownWithRepositoryContext,
repositoryUrl,
@@ -429,11 +429,7 @@ export class HaMarketplaceRepositoryDashboard extends LitElement {
// The same function while the repository stays, a new one renders it again
private _readmeUrl = memoizeOne(repositoryUrl);
private _getAuthors = memoizeOne((repository: RepositoryInfo) =>
repositoryAuthors(repository).filter(
(author) => !isCommunityOrganization(author)
)
);
private _getAuthors = memoizeOne(repositoryMakers);
protected render(): TemplateResult {
if (this._error) {
+6
View File
@@ -18,3 +18,9 @@ export const repositoryAuthors = (repository: RepositoryBase): string[] => {
export const isCommunityOrganization = (author: string): boolean =>
COMMUNITY_ORGANIZATIONS.includes(author);
// The people who made it, a community organization's name says nothing
export const repositoryMakers = (repository: RepositoryBase): string[] =>
repositoryAuthors(repository).filter(
(author) => !isCommunityOrganization(author)
);
+1
View File
@@ -35,6 +35,7 @@ const marketplaceTableParams = {
type: "string?",
status: "string?",
source: "string?",
author: "string?",
sort: "string?",
direction: "string?",
group: "string?",
+3 -1
View File
@@ -12671,6 +12671,7 @@
},
"column": {
"name": "Repository name",
"author": "Author",
"downloads": "Downloads",
"stars": "Stars",
"icon": "Icon",
@@ -12693,7 +12694,8 @@
"filters": {
"status": "Status",
"type": "Type",
"source": "Source"
"source": "Source",
"author": "Author"
},
"dialog_custom_repositories": {
"title": "Add from a GitHub link",
@@ -11,6 +11,7 @@ import {
filterRepositories,
filtersOfTab,
marketplaceUpdateEntityIds,
repositoryAuthorOptions,
skippedUpdateRepositories,
statusesOfTab,
statusFilterOfTab,
@@ -30,6 +31,8 @@ const repository = (
({
id: name,
name,
full_name: `owner/${name}`,
authors: [],
category: "integration",
status: "default",
installed: false,
@@ -91,6 +94,29 @@ describe("filterRepositories", () => {
).toEqual(expected);
});
it.each([
[{ author: ["alice"] }, ["Shared", "Theirs"]],
[{ author: ["bob"] }, ["Shared"]],
[{ author: ["owner"] }, ["Owned"]],
[{ author: ["custom-cards"] }, []],
[{ author: ["alice"], type: ["theme"] }, ["Theirs"]],
])("keeps what is made by %j", (activeFilters, expected) => {
expect(
names(
filterRepositories(
[
repository("Shared", { authors: ["@Alice", "bob"] }),
repository("Theirs", { authors: ["alice"], category: "theme" }),
repository("Owned"),
repository("Community", { full_name: "custom-cards/card" }),
],
localize,
activeFilters
)
).sort()
).toEqual(expected);
});
it("puts installed first, then new, then the most starred, then by name", () => {
const sorted = filterRepositories(
[
@@ -159,6 +185,20 @@ describe("filterRepositories", () => {
expect(row.translated_status).toBe("installed");
});
it.each([
[["@alice", "@bob"], "owner/Plain", "alice, bob"],
[[], "owner/Plain", "owner"],
[[], "custom-cards/Plain", ""],
[["@custom-cards", "@alice"], "custom-cards/Plain", "alice"],
])("shows %j of %s as made by %j", (authors, fullName, author) => {
const [row] = filterRepositories(
[repository("Plain", { authors, full_name: fullName })],
localize
);
expect(row.author).toBe(author);
});
it("leaves the list it was handed alone", () => {
const unsorted = [repository("Beta"), repository("Alpha")];
@@ -168,13 +208,38 @@ describe("filterRepositories", () => {
});
});
describe("repositoryAuthorOptions", () => {
it("lists everyone who made something once, by name", () => {
expect(
repositoryAuthorOptions(
[
repository("One", { authors: ["@bob", "Alice"] }),
repository("Two", { authors: ["alice"] }),
repository("Three", { full_name: "custom-cards/three" }),
repository("Four", { full_name: "carol/four" }),
],
"en"
)
).toEqual([
{ value: "alice", label: "Alice" },
{ value: "bob", label: "bob" },
{ value: "carol", label: "carol" },
]);
});
});
describe("browseUrl", () => {
it("links to what browseLinkFromUrl and the table read back", () => {
const url = new URL(
browseUrl({
search: "mushroom card",
sorting: { column: "stars", direction: "desc" },
filters: { status: ["new"], type: ["theme"], source: ["custom"] },
filters: {
status: ["new"],
type: ["theme"],
source: ["custom"],
author: ["clooos"],
},
}),
"http://localhost"
);
@@ -184,10 +249,21 @@ describe("browseUrl", () => {
expect(url.searchParams.get("direction")).toBe("desc");
expect(browseLinkFromUrl(url.search)).toEqual({
search: "mushroom card",
filters: { status: ["new"], type: ["theme"], source: ["custom"] },
filters: {
status: ["new"],
type: ["theme"],
source: ["custom"],
author: ["clooos"],
},
});
});
it("reads an author of a link whatever its case", () => {
expect(browseLinkFromUrl("?author=Clooos")?.filters.author).toEqual([
"clooos",
]);
});
it("leaves the direction out when sorting ascending", () => {
expect(
browseUrl({ sorting: { column: "name", direction: "asc" }, filters: {} })
+7 -2
View File
@@ -79,8 +79,8 @@ const resetBulkUninstallMocks = () => {
beforeEach(resetBulkUninstallMocks);
const openDashboard = async (
repositories: unknown[] = [],
const openDashboard = async <T extends { id: string; full_name?: string }>(
repositories: T[] = [],
tab: "discover" | "browse" | "installed" = "browse"
) => {
host = document.createElement("div");
@@ -88,6 +88,11 @@ const openDashboard = async (
document.body.append(host);
const dashboard = document.createElement("ha-marketplace-dashboard");
dashboard.tab = tab;
// Every repository has a full name, the fixtures only set what they test
for (const repository of repositories) {
repository.full_name ??= "owner/repository";
}
dashboard.marketplace = {
repositories,
info: { categories: [] },