diff --git a/src/components/dialogs/hacs-lists-dialog.ts b/src/components/dialogs/hacs-lists-dialog.ts new file mode 100644 index 000000000..f08299d71 --- /dev/null +++ b/src/components/dialogs/hacs-lists-dialog.ts @@ -0,0 +1,485 @@ +import "@material/mwc-button/mwc-button"; +import "@material/mwc-icon-button/mwc-icon-button"; +import "@material/mwc-linear-progress/mwc-linear-progress"; +import { mdiDelete, mdiOpenInNew, mdiPencil } from "@mdi/js"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../../homeassistant-frontend/src/common/dom/fire_event"; +import { navigate } from "../../../homeassistant-frontend/src/common/navigate"; +import { showConfirmationDialog } from "../../../homeassistant-frontend/src/dialogs/generic/show-dialog-box"; +import "../../../homeassistant-frontend/src/components/ha-dialog"; +import { createCloseHeading } from "../../../homeassistant-frontend/src/components/ha-dialog"; +import "../../../homeassistant-frontend/src/components/ha-svg-icon"; +import { mainWindow } from "../../../homeassistant-frontend/src/common/dom/get_main_window"; +import type { HomeAssistant } from "../../../homeassistant-frontend/src/types"; +import type { HacsList } from "../../data/lists"; +import { + createList, + deleteList, + getLists, + renameList, + setRepositoryLists, +} from "../../data/websocket"; +import type { RepositoryBase } from "../../data/repository"; +import { + showHacsFormDialog, + type HacsListsDialogParams, +} from "./show-hacs-dialog"; + +@customElement("hacs-lists-dialog") +export class HacsListsDialog extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _dialogParams?: HacsListsDialogParams; + @state() private _lists: HacsList[] = []; + @state() private _selectedListId?: string; + @state() private _waiting = false; + + public async showDialog(dialogParams: HacsListsDialogParams): Promise { + this._dialogParams = dialogParams; + await this._loadLists(); + await this.updateComplete; + } + + public closeDialog(): void { + this._dialogParams = undefined; + this._lists = []; + this._selectedListId = undefined; + this._waiting = false; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + private async _loadLists(): Promise { + this._waiting = true; + try { + this._lists = await getLists(this.hass); + + if ( + !this._selectedListId || + !this._lists.some((list) => list.id === this._selectedListId) + ) { + this._selectedListId = this._lists[0]?.id; + } + } finally { + this._waiting = false; + } + } + + private _selectedList(): HacsList | undefined { + return this._lists.find((list) => list.id === this._selectedListId); + } + + private _createList(): void { + if (!this._dialogParams) { + return; + } + + showHacsFormDialog(this, { + hacs: this._dialogParams.hacs, + title: this._dialogParams.hacs.localize("dialog_lists.create"), + schema: [ + { + name: "name", + required: true, + selector: { + text: {}, + }, + }, + ], + data: { + name: "", + }, + saveAction: async (data) => { + const lists = await createList(this.hass, String(data.name || "")); + this._lists = lists; + this._selectedListId = lists[lists.length - 1]?.id; + }, + }); + } + + private _renameList(list: HacsList): void { + if (!this._dialogParams || list.builtin) { + return; + } + + showHacsFormDialog(this, { + hacs: this._dialogParams.hacs, + title: this._dialogParams.hacs.localize("dialog_lists.rename"), + schema: [ + { + name: "name", + required: true, + selector: { + text: {}, + }, + }, + ], + data: { + name: list.name, + }, + saveAction: async (data) => { + this._lists = await renameList( + this.hass, + list.id, + String(data.name || ""), + ); + }, + }); + } + + private async _deleteList(list: HacsList): Promise { + if (list.builtin || !this._dialogParams) { + return; + } + + await showConfirmationDialog(this, { + title: this._dialogParams.hacs.localize("dialog_lists.delete"), + text: this._dialogParams.hacs.localize("dialog_lists.delete_confirm", { name: list.name }), + confirmText: this._dialogParams.hacs.localize("dialog_lists.delete"), + dismissText: this._dialogParams.hacs.localize("common.cancel"), + confirm: async () => { + this._waiting = true; + try { + this._lists = await deleteList(this.hass, list.id); + this._selectedListId = this._lists[0]?.id; + } finally { + this._waiting = false; + } + }, + }); + } + + private async _removeRepository(repositoryId: string): Promise { + const selectedList = this._selectedList(); + if (!selectedList) { + return; + } + + const remainingListIds = this._lists + .filter((list) => + list.repositories.some( + (repository) => String(repository.id) === String(repositoryId), + ), + ) + .map((list) => list.id) + .filter((listId) => listId !== selectedList.id); + + this._waiting = true; + try { + this._lists = await setRepositoryLists( + this.hass, + String(repositoryId), + remainingListIds, + ); + } finally { + this._waiting = false; + } + } + + private _openRepository(repository: RepositoryBase | undefined, fullName: string): void { + if (repository) { + this.closeDialog(); + navigate(`/hacs/repository/${repository.id}`); + return; + } + + mainWindow.open( + `https://github.com/${fullName}`, + "_blank", + "noreferrer=true", + ); + } + + protected render() { + if (!this._dialogParams) { + return nothing; + } + + const selectedList = this._selectedList(); + + return html` + +
+
+
+ ${this._dialogParams.hacs.localize("dialog_lists.title")} + + ${this._dialogParams.hacs.localize("dialog_lists.create")} +
+ +
+ ${this._lists.map( + (list) => html` +
(this._selectedListId = list.id)} + > +
+ ${list.name}${list.builtin ? " ⭐" : ""} + ${list.repositories.length} +
+ + ${list.builtin + ? nothing + : html` +
+ { + event.stopPropagation(); + this._renameList(list); + }} + > + + + { + event.stopPropagation(); + this._deleteList(list); + }} + > + + +
+ `} +
+ `, + )} +
+
+ +
+ ${selectedList + ? html` +
+ ${selectedList.name} + ${this._dialogParams.hacs.localize("dialog_lists.saved", { count: selectedList.repositories.length })} +
+ +
+ ${selectedList.repositories.length + ? selectedList.repositories.map((savedRepository) => { + const repository = this._dialogParams!.hacs.repositories.find( + (candidate) => + String(candidate.id) === String(savedRepository.id), + ); + + return html` +
+
+ + ${repository?.name || savedRepository.full_name} + +
+ ${savedRepository.full_name} + ${repository + ? repository.installed + ? ` · ${this._dialogParams!.hacs.localize("dialog_lists.installed")}` + : ` · ${this._dialogParams!.hacs.localize("dialog_lists.not_installed")}` + : ` · ${this._dialogParams!.hacs.localize("dialog_lists.unavailable")}`} +
+
+ +
+ + this._openRepository( + repository, + savedRepository.full_name, + )} + > + + + + ${repository + ? html` + + this._removeRepository(String(repository.id))} + .disabled=${this._waiting} + > + ${this._dialogParams!.hacs.localize("common.remove")} + + ` + : nothing} +
+
+ `; + }) + : html`
${this._dialogParams.hacs.localize("dialog_lists.empty")}
`} +
+ ` + : html`
${this._dialogParams.hacs.localize("dialog_lists.no_lists")}
`} +
+
+ + ${this._waiting + ? html`` + : nothing} + + + ${this._dialogParams.hacs.localize("dialog_lists.close")} + +
+ `; + } + + static get styles() { + return css` + .layout { + display: grid; + grid-template-columns: minmax(180px, 240px) minmax(360px, 1fr); + gap: 24px; + min-width: min(760px, 85vw); + max-width: 1000px; + max-height: 65vh; + } + + .lists-column, + .repositories-column { + min-width: 0; + } + + .lists-column { + border-right: 1px solid var(--divider-color); + padding-right: 16px; + } + + .column-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 12px; + } + + .lists, + .repositories { + overflow: auto; + max-height: calc(65vh - 70px); + } + + .list-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 48px; + padding: 4px 8px; + border-radius: 8px; + cursor: pointer; + } + + .list-row:hover, + .list-row.selected { + background: var(--secondary-background-color); + } + + .list-name { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex: 1; + } + + .list-name small, + .column-header span { + color: var(--secondary-text-color); + } + + .list-actions { + display: flex; + align-items: center; + } + + .repository-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 0; + border-bottom: 1px solid var(--divider-color); + } + + .repository-info { + min-width: 0; + } + + .secondary { + color: var(--secondary-text-color); + overflow-wrap: anywhere; + margin-top: 4px; + } + + .repository-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + } + + .empty { + color: var(--secondary-text-color); + padding: 24px 0; + } + + mwc-linear-progress { + margin: 8px -8px -8px; + } + + @media all and (max-width: 700px) { + .layout { + grid-template-columns: 1fr; + min-width: 0; + max-width: 100%; + } + + .lists-column { + border-right: 0; + border-bottom: 1px solid var(--divider-color); + padding-right: 0; + padding-bottom: 16px; + } + + .lists { + max-height: 180px; + } + + .repositories { + max-height: 40vh; + } + + .repository-row { + align-items: flex-start; + flex-direction: column; + } + + .repository-actions { + width: 100%; + justify-content: flex-end; + } + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hacs-lists-dialog": HacsListsDialog; + } +} diff --git a/src/components/dialogs/hacs-repository-lists-dialog.ts b/src/components/dialogs/hacs-repository-lists-dialog.ts new file mode 100644 index 000000000..a46eece59 --- /dev/null +++ b/src/components/dialogs/hacs-repository-lists-dialog.ts @@ -0,0 +1,228 @@ +import "@material/mwc-button/mwc-button"; +import "@material/mwc-linear-progress/mwc-linear-progress"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../../homeassistant-frontend/src/common/dom/fire_event"; +import "../../../homeassistant-frontend/src/components/ha-dialog"; +import { createCloseHeading } from "../../../homeassistant-frontend/src/components/ha-dialog"; +import type { HomeAssistant } from "../../../homeassistant-frontend/src/types"; +import type { HacsList } from "../../data/lists"; +import { + createList, + getLists, + setRepositoryLists, +} from "../../data/websocket"; +import { showHacsFormDialog, type HacsRepositoryListsDialogParams } from "./show-hacs-dialog"; + +@customElement("hacs-repository-lists-dialog") +export class HacsRepositoryListsDialog extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _dialogParams?: HacsRepositoryListsDialogParams; + @state() private _lists: HacsList[] = []; + @state() private _selectedListIds = new Set(); + @state() private _waiting = false; + + public async showDialog(dialogParams: HacsRepositoryListsDialogParams): Promise { + this._dialogParams = dialogParams; + await this._loadLists(); + await this.updateComplete; + } + + public closeDialog(): void { + this._dialogParams = undefined; + this._lists = []; + this._selectedListIds = new Set(); + this._waiting = false; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + private async _loadLists(): Promise { + if (!this._dialogParams) { + return; + } + + this._waiting = true; + try { + const lists = await getLists(this.hass); + this._lists = lists; + + const repositoryId = String(this._dialogParams.repositoryId); + this._selectedListIds = new Set( + lists + .filter((list) => + list.repositories.some( + (repository) => String(repository.id) === repositoryId, + ), + ) + .map((list) => list.id), + ); + } finally { + this._waiting = false; + } + } + + private _toggleList(listId: string): void { + const selected = new Set(this._selectedListIds); + if (selected.has(listId)) { + selected.delete(listId); + } else { + selected.add(listId); + } + this._selectedListIds = selected; + } + + private _createList(): void { + if (!this._dialogParams) { + return; + } + + showHacsFormDialog(this, { + hacs: this._dialogParams.hacs, + title: this._dialogParams.hacs.localize("dialog_lists.create"), + schema: [ + { + name: "name", + required: true, + selector: { + text: {}, + }, + }, + ], + data: { + name: "", + }, + saveAction: async (data) => { + const lists = await createList(this.hass, String(data.name || "")); + this._lists = lists; + + const created = lists[lists.length - 1]; + if (created) { + this._selectedListIds = new Set([ + ...this._selectedListIds, + created.id, + ]); + } + }, + }); + } + + private async _save(): Promise { + if (!this._dialogParams) { + return; + } + + this._waiting = true; + + try { + await setRepositoryLists( + this.hass, + String(this._dialogParams.repositoryId), + [...this._selectedListIds], + ); + this.closeDialog(); + } finally { + this._waiting = false; + } + } + + protected render() { + if (!this._dialogParams) { + return nothing; + } + + return html` + +
+
+ + ${this._dialogParams.hacs.localize("dialog_lists.create")} +
+ +
+ ${this._lists.map( + (list) => html` + + `, + )} +
+ + ${this._waiting + ? html`` + : nothing} +
+ + + ${this._dialogParams.hacs.localize("common.cancel")} + + + + ${this._dialogParams.hacs.localize("common.save")} + +
+ `; + } + + static get styles() { + return css` + .content { + min-width: 320px; + max-width: 560px; + } + + .actions { + margin-bottom: 16px; + } + + .list { + max-height: 50vh; + overflow: auto; + } + + .list-row { + display: flex; + align-items: center; + gap: 12px; + min-height: 48px; + padding: 4px 0; + cursor: pointer; + } + + .list-row input { + width: 20px; + height: 20px; + } + + mwc-linear-progress { + margin-bottom: -8px; + margin-top: 8px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hacs-repository-lists-dialog": HacsRepositoryListsDialog; + } +} diff --git a/src/components/dialogs/show-hacs-dialog.ts b/src/components/dialogs/show-hacs-dialog.ts index c3ac9affd..8def90d69 100644 --- a/src/components/dialogs/show-hacs-dialog.ts +++ b/src/components/dialogs/show-hacs-dialog.ts @@ -31,9 +31,15 @@ export interface HacsDownloadDialogParams extends BaseHacsDialogParams { export interface HacsCustomRepositoriesDialogParams extends BaseHacsDialogParams {} +export interface HacsListsDialogParams extends BaseHacsDialogParams {} + +export interface HacsRepositoryListsDialogParams extends BaseHacsDialogParams { + repositoryId: string; +} + export const showHacsFormDialog = ( element: HTMLElement, - dialogParams: HacsFormDialogParams + dialogParams: HacsFormDialogParams, ): void => { fireEvent(element, "show-dialog", { dialogTag: "hacs-form-dialog", @@ -44,7 +50,7 @@ export const showHacsFormDialog = ( export const showHacsDownloadDialog = ( element: HTMLElement, - dialogParams: HacsDownloadDialogParams + dialogParams: HacsDownloadDialogParams, ): void => { fireEvent(element, "show-dialog", { dialogTag: "hacs-download-dialog", @@ -55,7 +61,7 @@ export const showHacsDownloadDialog = ( export const showHacsCustomRepositoriesDialog = ( element: HTMLElement, - dialogParams: HacsCustomRepositoriesDialogParams + dialogParams: HacsCustomRepositoriesDialogParams, ): void => { fireEvent(element, "show-dialog", { dialogTag: "hacs-custom-repositories-dialog", @@ -63,3 +69,25 @@ export const showHacsCustomRepositoriesDialog = ( dialogParams, }); }; + +export const showHacsListsDialog = ( + element: HTMLElement, + dialogParams: HacsListsDialogParams, +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "hacs-lists-dialog", + dialogImport: () => import("./hacs-lists-dialog"), + dialogParams, + }); +}; + +export const showHacsRepositoryListsDialog = ( + element: HTMLElement, + dialogParams: HacsRepositoryListsDialogParams, +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "hacs-repository-lists-dialog", + dialogImport: () => import("./hacs-repository-lists-dialog"), + dialogParams, + }); +}; diff --git a/src/components/hacs-repository-owerflow-menu.ts b/src/components/hacs-repository-owerflow-menu.ts index 548b8ad21..397449cb6 100644 --- a/src/components/hacs-repository-owerflow-menu.ts +++ b/src/components/hacs-repository-owerflow-menu.ts @@ -4,6 +4,7 @@ import { mdiArrowDownCircle, mdiClose, mdiDownload, + mdiFormatListBulleted, mdiGithub, mdiInformation, mdiLanguageJavascript, @@ -23,7 +24,11 @@ import { } from "../data/websocket"; import type { HacsDashboard } from "../dashboards/hacs-dashboard"; import type { HacsRepositoryDashboard } from "../dashboards/hacs-repository-dashboard"; -import { showHacsDownloadDialog, showHacsFormDialog } from "./dialogs/show-hacs-dialog"; +import { + showHacsDownloadDialog, + showHacsFormDialog, + showHacsRepositoryListsDialog, +} from "./dialogs/show-hacs-dialog"; import { LocalizeFunc } from "../../homeassistant-frontend/src/common/translations/localize"; import { HacsLocalizeKeys } from "../data/localize"; @@ -42,6 +47,15 @@ export const repositoryMenuItems = memoizeOne( }, ] : []), + { + path: mdiFormatListBulleted, + label: localize("repository_card.add_to_lists"), + action: () => + showHacsRepositoryListsDialog(element, { + hacs: element.hacs, + repositoryId: String(repository.id), + }), + }, { path: mdiGithub, label: localize("common.repository"), diff --git a/src/dashboards/hacs-dashboard.ts b/src/dashboards/hacs-dashboard.ts index a42c4bb4c..2e7133d24 100644 --- a/src/dashboards/hacs-dashboard.ts +++ b/src/dashboards/hacs-dashboard.ts @@ -5,6 +5,7 @@ import { mdiAlertCircleOutline, mdiDotsVertical, mdiFileDocument, + mdiFormatListBulleted, mdiGit, mdiGithub, mdiInformation, @@ -24,14 +25,12 @@ import type { SortingDirection, } from "../../homeassistant-frontend/src/components/data-table/ha-data-table"; import "../../homeassistant-frontend/src/layouts/hass-tabs-subpage-data-table"; - import "../../homeassistant-frontend/src/components/ha-button-menu"; import "../../homeassistant-frontend/src/components/ha-fab"; import "../../homeassistant-frontend/src/components/ha-form/ha-form"; import "../../homeassistant-frontend/src/components/ha-markdown"; import "../../homeassistant-frontend/src/components/ha-menu"; import "../../homeassistant-frontend/src/components/ha-md-menu-item"; - import { LocalizeFunc } from "../../homeassistant-frontend/src/common/translations/localize"; import { HaFormSchema } from "../../homeassistant-frontend/src/components/ha-form/types"; import { HaMenu } from "../../homeassistant-frontend/src/components/ha-menu"; @@ -43,6 +42,7 @@ import { brandsUrl } from "../../homeassistant-frontend/src/util/brands-url"; import { showHacsCustomRepositoriesDialog, showHacsFormDialog, + showHacsListsDialog, } from "../components/dialogs/show-hacs-dialog"; import { repositoryMenuItems } from "../components/hacs-repository-owerflow-menu"; import { aboutHacsmarkdownContent } from "../data/about"; @@ -76,7 +76,6 @@ export class HacsDashboard extends LitElement { @property({ attribute: false }) public hacs!: Hacs; @property({ attribute: false }) public hass!: HomeAssistant; - @property({ attribute: false }) public route!: Route; @property({ type: Boolean, reflect: true }) @@ -89,7 +88,6 @@ export class HacsDashboard extends LitElement { @storage({ key: "hacs-dashboard-table-sorting", state: false, subscribe: false }) private _activeSorting?: { column: string; direction: SortingDirection }; - @storage({ key: "hacs-dashboard-table-grouping", state: true, subscribe: false }) private _activeGrouping?: string; @@ -101,7 +99,6 @@ export class HacsDashboard extends LitElement { @storage({ key: "hacs-dashboard-table-hidden-columns", state: true, subscribe: false }) private _hiddenTableColumns?: string[]; - @storage({ key: "hacs-dashboard-table-columns-ordering", state: true, subscribe: false }) private _orderTableColumns?: string[]; @@ -117,6 +114,7 @@ export class HacsDashboard extends LitElement { protected render = (): TemplateResult | void => { const repositories = this._filterRepositories( this.hacs.repositories, + this.hacs.lists, this.hacs.localize, this._activeFilters, ); @@ -166,12 +164,18 @@ export class HacsDashboard extends LitElement { .data=${{ status: this._activeFilters?.find((filter) => filter.startsWith("status_")) || "", type: this._activeFilters?.find((filter) => filter.startsWith("type_")) || "", + list: this._activeFilters?.find((filter) => filter.startsWith("list_")) || "", }} - .schema=${this._filterSchema(this.hacs.localize, this.hacs.info.categories)} + .schema=${this._filterSchema( + this.hacs.localize, + this.hacs.info.categories, + this.hacs.lists, + )} .computeLabel=${this._computeFilterFormLabel} @value-changed=${this._handleFilterChanged} > + ${this._overflowMenuRepository ? repositoryMenuItems(this, this._overflowMenuRepository, this.hacs.localize).map( @@ -192,7 +196,19 @@ export class HacsDashboard extends LitElement { ) : nothing} + + { + showHacsListsDialog(this, { + hacs: this.hacs, + }); + }} + > + +
${this.hacs.localize("menu.lists")}
+
+ { mainWindow.open(documentationUrl({}), "_blank", "noreferrer=true"); @@ -201,6 +217,7 @@ export class HacsDashboard extends LitElement {
${this.hacs.localize("menu.documentation")}
+ { mainWindow.open("https://github.com/hacs", "_blank", "noreferrer=true"); @@ -209,6 +226,7 @@ export class HacsDashboard extends LitElement {
GitHub
+ { mainWindow.open( @@ -223,6 +241,7 @@ export class HacsDashboard extends LitElement {
${this.hacs.localize("menu.open_issue")}
+ { if (!this.hacs.info.disabled_reason) { @@ -240,6 +259,7 @@ export class HacsDashboard extends LitElement {
${this.hacs.localize("menu.custom_repositories")}
+ ${repositoriesContainsNew ? html` { @@ -250,6 +270,7 @@ export class HacsDashboard extends LitElement {
${this.hacs.localize("menu.dismiss")}
` : nothing} + { showHacsFormDialog(this, { @@ -270,6 +291,7 @@ export class HacsDashboard extends LitElement { private _filterRepositories = memoize( ( repositories: RepositoryBase[], + lists: Hacs["lists"], localizeFunc: LocalizeFunc, activeFilters?: string[], ): DataTableRowData[] => @@ -281,24 +303,51 @@ export class HacsDashboard extends LitElement { ) { return false; } + if ( activeFilters?.filter((filter) => filter.startsWith("type_")).length && !activeFilters.includes(`type_${repository.category}`) ) { return false; } + + const activeListFilter = activeFilters?.find((filter) => + filter.startsWith("list_"), + ); + + if (activeListFilter) { + const listId = activeListFilter.substring("list_".length); + const list = lists?.find((candidate) => candidate.id === listId); + + if (!list) { + return false; + } + + if ( + !list.repositories.some( + (savedRepository) => + String(savedRepository.id) === String(repository.id), + ) + ) { + return false; + } + } + return true; }) .sort((a, b) => { if (a.installed !== b.installed) { return a.installed ? -1 : 1; } + if (a.new !== b.new) { return a.new ? -1 : 1; } + if (a.stars !== b.stars) { return a.stars > b.stars ? -1 : 1; } + return a.name.localeCompare(b.name); }) .map((repository) => ({ @@ -344,6 +393,7 @@ export class HacsDashboard extends LitElement { > `, }, + name: { ...defaultKeyData, title: localizeFunc("column.name"), @@ -354,6 +404,7 @@ export class HacsDashboard extends LitElement { extraTemplate: (repository: RepositoryBase) => !narrow ? html`
${repository.description}
` : nothing, }, + downloads: { ...defaultKeyData, title: localizeFunc("column.downloads"), @@ -361,12 +412,14 @@ export class HacsDashboard extends LitElement { hidden: false, template: (repository: RepositoryBase) => html`${repository.downloads || "-"}`, }, + stars: { ...defaultKeyData, title: localizeFunc("column.stars"), sortable: true, hidden: false, }, + last_updated: { ...defaultKeyData, title: localizeFunc("column.last_updated"), @@ -376,6 +429,7 @@ export class HacsDashboard extends LitElement { if (!repository.last_updated) { return "-"; } + try { return relativeTime(new Date(repository.last_updated), this.hass.locale); } catch (e) { @@ -383,6 +437,7 @@ export class HacsDashboard extends LitElement { } }, }, + installed_version: { ...defaultKeyData, title: localizeFunc("column.installed_version"), @@ -392,6 +447,7 @@ export class HacsDashboard extends LitElement { template: (repository: RepositoryBase) => repository.installed ? repository.installed_version : "-", }, + available_version: { ...defaultKeyData, title: localizeFunc("column.available_version"), @@ -401,6 +457,7 @@ export class HacsDashboard extends LitElement { template: (repository: RepositoryBase) => repository.installed ? repository.available_version : "-", }, + translated_status: { ...defaultKeyData, title: localizeFunc("column.status"), @@ -409,6 +466,7 @@ export class HacsDashboard extends LitElement { hidden: false, defaultHidden: true, }, + translated_category: { ...defaultKeyData, title: localizeFunc("column.type"), @@ -416,12 +474,14 @@ export class HacsDashboard extends LitElement { groupable: true, hidden: false, }, + description: defaultKeyData, authors: defaultKeyData, domain: defaultKeyData, full_name: defaultKeyData, id: defaultKeyData, topics: defaultKeyData, + actions: { title: "", label: localizeFunc("column.actions"), @@ -449,6 +509,7 @@ export class HacsDashboard extends LitElement { this._repositoryOverflowMenu.close(); return; } + this._repositoryOverflowMenu.anchorElement = ev.target; this._overflowMenuRepository = ev.target.repository; this._repositoryOverflowMenu.show(); @@ -459,6 +520,7 @@ export class HacsDashboard extends LitElement { this._overflowMenu.close(); return; } + this._overflowMenu.anchorElement = ev.target; this._overflowMenu.show(); }; @@ -476,7 +538,11 @@ export class HacsDashboard extends LitElement { ); private _filterSchema = memoize( - (localizeFunc: LocalizeFunc, types: string[]) => + ( + localizeFunc: LocalizeFunc, + types: string[], + lists: Hacs["lists"], + ) => [ { name: "filters", @@ -512,7 +578,20 @@ export class HacsDashboard extends LitElement { }, }, }, - ] as const satisfies readonly HaFormSchema[], + { + name: "list", + selector: { + select: { + options: (lists || []).map((list) => ({ + label: list.builtin ? `⭐ ${list.name}` : list.name, + value: `list_${list.id}`, + })), + mode: "dropdown", + sort: false, + }, + }, + }, + ] as const satisfies readonly HaFormSchema[] ); get _scrollerTarget() { @@ -530,7 +609,9 @@ export class HacsDashboard extends LitElement { } private _computeFilterFormLabel = (schema, _) => - this.hacs.localize( + schema.name === "list" + ? this.hacs.localize("menu.lists") + : this.hacs.localize( // @ts-ignore `dialog_overview.${schema.name}`, ) || @@ -550,7 +631,7 @@ export class HacsDashboard extends LitElement { const updatedFilters: string[] = Object.entries(data) .filter( ([key, value]) => - ["status", "type"].includes(key) && ![undefined, null, ""].includes(value), + ["status", "type", "list"].includes(key) && ![undefined, null, ""].includes(value), ) .map(([_, value]) => value); this._activeFilters = updatedFilters.length ? updatedFilters : undefined; diff --git a/src/data/common.ts b/src/data/common.ts index 00ab9d932..72a5271c3 100644 --- a/src/data/common.ts +++ b/src/data/common.ts @@ -16,6 +16,7 @@ export interface LocationChangedEvent { export enum HacsDispatchEvent { CONFIG = "hacs_dispatch_config", ERROR = "hacs_dispatch_error", + LISTS = "hacs_dispatch_lists", RELOAD = "hacs_dispatch_reload", REPOSITORY = "hacs_dispatch_repository", STAGE = "hacs_dispatch_stage", diff --git a/src/data/hacs.ts b/src/data/hacs.ts index 7f6a4c4f5..ea098a040 100644 --- a/src/data/hacs.ts +++ b/src/data/hacs.ts @@ -1,6 +1,7 @@ import type { LocalizeFunc } from "../../homeassistant-frontend/src/common/translations/localize"; import type { HacsLocalizeKeys } from "../data/localize"; import type { RepositoryBase, RepositoryType } from "./repository"; +import type { HacsList } from "./lists"; export const APP_FULL_NAME = "Home Assistant Community Store"; @@ -19,6 +20,7 @@ export interface HacsInfo { export interface Hacs { language: string; repositories: RepositoryBase[]; + lists: HacsList[]; info: HacsInfo; localize: LocalizeFunc; log: any; diff --git a/src/data/lists.ts b/src/data/lists.ts new file mode 100644 index 000000000..c17b4dce8 --- /dev/null +++ b/src/data/lists.ts @@ -0,0 +1,11 @@ +export interface HacsListRepository { + id: string; + full_name: string; +} + +export interface HacsList { + id: string; + name: string; + builtin: boolean; + repositories: HacsListRepository[]; +} diff --git a/src/data/websocket.ts b/src/data/websocket.ts index 3757c19d6..cb101d7b6 100644 --- a/src/data/websocket.ts +++ b/src/data/websocket.ts @@ -1,6 +1,7 @@ import type { HomeAssistant } from "../../homeassistant-frontend/src/types"; import type { Hacs, HacsInfo } from "./hacs"; import type { HacsDispatchEvent } from "./common"; +import type { HacsList } from "./lists"; import type { RepositoryBase } from "./repository"; export const fetchHacsInfo = async (hass: HomeAssistant) => @@ -13,6 +14,41 @@ export const getRepositories = async (hass: HomeAssistant) => type: "hacs/repositories/list", }); +export const getLists = async (hass: HomeAssistant) => + hass.connection.sendMessagePromise({ + type: "hacs/lists/list", + }); + +export const createList = async (hass: HomeAssistant, name: string) => + hass.connection.sendMessagePromise({ + type: "hacs/lists/create", + name, + }); + +export const renameList = async (hass: HomeAssistant, listId: string, name: string) => + hass.connection.sendMessagePromise({ + type: "hacs/lists/rename", + list_id: listId, + name, + }); + +export const deleteList = async (hass: HomeAssistant, listId: string) => + hass.connection.sendMessagePromise({ + type: "hacs/lists/delete", + list_id: listId, + }); + +export const setRepositoryLists = async ( + hass: HomeAssistant, + repository: string, + lists: string[], +) => + hass.connection.sendMessagePromise({ + type: "hacs/lists/set_repository", + repository, + lists, + }); + export const repositoryUninstall = async (hass: HomeAssistant, repository: string) => hass.connection.sendMessagePromise({ type: "hacs/repository/remove", @@ -22,7 +58,7 @@ export const repositoryUninstall = async (hass: HomeAssistant, repository: strin export const repositoryAdd = async (hass: HomeAssistant, repository: string, category: string) => hass.connection.sendMessagePromise>({ type: "hacs/repositories/add", - repository: repository, + repository, category, }); diff --git a/src/hacs.ts b/src/hacs.ts index adbd16b6f..00406d4cf 100644 --- a/src/hacs.ts +++ b/src/hacs.ts @@ -7,7 +7,12 @@ import { ProvideHassLitMixin } from "../homeassistant-frontend/src/mixins/provid import type { HomeAssistant } from "../homeassistant-frontend/src/types"; import { computeLocalize } from "../homeassistant-frontend/src/common/translations/localize"; import { getTranslation } from "../homeassistant-frontend/src/util/common-translation"; -import { fetchHacsInfo, getRepositories, websocketSubscription } from "./data/websocket"; +import { + fetchHacsInfo, + getLists, + getRepositories, + websocketSubscription, +} from "./data/websocket"; import { HacsDispatchEvent } from "./data/common"; export class HacsElement extends ProvideHassLitMixin(LitElement) { @@ -27,6 +32,7 @@ export class HacsElement extends ProvideHassLitMixin(LitElement) { if (!this.hasUpdated) { this._initHacs(); } + if (changedProperties.has("hass")) { const oldHass = changedProperties.get("hass") as HomeAssistant | undefined; if (oldHass?.language !== this.hass.language) { @@ -43,40 +49,47 @@ export class HacsElement extends ProvideHassLitMixin(LitElement) { websocketSubscription( this.hass, () => this._updateProperties("configuration"), - HacsDispatchEvent.CONFIG + HacsDispatchEvent.CONFIG, ); websocketSubscription( this.hass, () => this._updateProperties("status"), - HacsDispatchEvent.STATUS + HacsDispatchEvent.STATUS, ); websocketSubscription( this.hass, () => this._updateProperties("status"), - HacsDispatchEvent.STAGE + HacsDispatchEvent.STAGE, ); websocketSubscription( this.hass, () => this._updateProperties("repositories"), - HacsDispatchEvent.REPOSITORY + HacsDispatchEvent.REPOSITORY, + ); + + websocketSubscription( + this.hass, + () => this._updateProperties("lists"), + HacsDispatchEvent.LISTS, ); this.hass.connection.subscribeEvents( async () => this._updateProperties("lovelace"), - "lovelace_updated" + "lovelace_updated", ); this._updateHacs({ log: new HacsLogger(), + lists: [], }); this._updateProperties(); this.addEventListener("update-hacs", (e) => - this._updateHacs((e as any).detail as Partial) + this._updateHacs((e as any).detail as Partial), ); } @@ -94,14 +107,17 @@ export class HacsElement extends ProvideHassLitMixin(LitElement) { const _fetch: any = {}; if (prop === "all") { - [_fetch.repositories, _fetch.info] = await Promise.all([ + [_fetch.repositories, _fetch.info, _fetch.lists] = await Promise.all([ getRepositories(this.hass), fetchHacsInfo(this.hass), + getLists(this.hass), ]); } else if (prop === "info") { _fetch.info = await fetchHacsInfo(this.hass); } else if (prop === "repositories") { _fetch.repositories = await getRepositories(this.hass); + } else if (prop === "lists") { + _fetch.lists = await getLists(this.hass); } Object.keys(_fetch).forEach((update) => { @@ -109,6 +125,7 @@ export class HacsElement extends ProvideHassLitMixin(LitElement) { _updates[update] = _fetch[update]; } }); + if (_updates) { this._updateHacs(_updates); this.requestUpdate(); diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index d0fa65d5f..0fbb214e2 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -109,7 +109,8 @@ "pending_update": "Pending update", "redownload": "Redownload", "report": "Request for removal", - "update_information": "Update information" + "update_information": "Update information", + "add_to_lists": "Add to lists..." }, "repository_status": { "pending-restart": "Pending restart", @@ -123,7 +124,8 @@ "custom_repositories": "Custom repositories", "open_issue": "Open issue", "dismiss": "Dismiss new repositories", - "documentation": "Documentation" + "documentation": "Documentation", + "lists": "Lists" }, "dialog": { "reload": { @@ -170,5 +172,21 @@ } } } + }, + "dialog_lists": { + "title": "HACS Lists", + "create": "Create list", + "rename": "Rename list", + "delete": "Delete list", + "delete_confirm": "Delete \"{name}\"? The repositories will remain in HACS.", + "saved": "{count} saved", + "installed": "Installed", + "not_installed": "Not installed", + "unavailable": "No longer available in HACS", + "open": "Open", + "add_to_lists": "Add to lists", + "empty": "This list is empty.", + "no_lists": "No lists available.", + "close": "Close" } -} \ No newline at end of file +}