Skip to content

Commit

Permalink
improve plugin sorting, with actionable plugins at the top
Browse files Browse the repository at this point in the history
  • Loading branch information
bwp91 committed Dec 28, 2023
1 parent 3c40357 commit fa82d59
Show file tree
Hide file tree
Showing 8 changed files with 78 additions and 46 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ All notable changes to homebridge-config-ui-x will be documented in this file.
- added more `aria-label` attributes to buttons to improve accessibility
- add link to homebridge wiki in logs on plugin action error
- allow fan/fanv2 rotation speed to be a different unit than percentage
- improve plugin sorting, with actionable plugins at the top

### Translation Changes

Expand Down
58 changes: 25 additions & 33 deletions src/modules/plugins/plugins.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,11 +169,11 @@ export class PluginsService {
await Promise.all(homebridgePlugins.map(async (pkg) => {
return limit(async () => {
try {
const pjson: IPackageJson = await readJson(join(pkg.installPath, 'package.json'));
const pkgJson: IPackageJson = await readJson(join(pkg.installPath, 'package.json'));
// check each plugin has the 'homebridge-plugin' keyword
if (pjson.keywords && pjson.keywords.includes('homebridge-plugin')) {
if (pkgJson.keywords && pkgJson.keywords.includes('homebridge-plugin')) {
// parse the package.json for each plugin
const plugin = await this.parsePackageJson(pjson, pkg.path);
const plugin = await this.parsePackageJson(pkgJson, pkg.path);

// check if the plugin has been disabled
plugin.disabled = disabledPlugins.includes(plugin.name);
Expand All @@ -193,15 +193,7 @@ export class PluginsService {
}));

this.installedPlugins = plugins;
return orderBy(
plugins,
[
(resultItem: HomebridgePlugin) => resultItem.name === this.configService.name,
'updateAvailable',
'name',
],
['desc', 'desc', 'asc'],
);
return plugins;
}

/**
Expand Down Expand Up @@ -513,10 +505,10 @@ export class PluginsService {
public async getHomebridgePackage() {
// try load from the "homebridgePackagePath" option first
if (this.configService.ui.homebridgePackagePath) {
const pjsonPath = join(this.configService.ui.homebridgePackagePath, 'package.json');
if (await pathExists(pjsonPath)) {
const pkgJsonPath = join(this.configService.ui.homebridgePackagePath, 'package.json');
if (await pathExists(pkgJsonPath)) {
try {
return await this.parsePackageJson(await readJson(pjsonPath), this.configService.ui.homebridgePackagePath);
return await this.parsePackageJson(await readJson(pkgJsonPath), this.configService.ui.homebridgePackagePath);
} catch (err) {
throw err;
}
Expand All @@ -543,8 +535,8 @@ export class PluginsService {
}

const homebridgeModule = homebridgeInstalls[0];
const pjson: IPackageJson = await readJson(join(homebridgeModule.installPath, 'package.json'));
const homebridge = await this.parsePackageJson(pjson, homebridgeModule.path);
const pkgJson: IPackageJson = await readJson(join(homebridgeModule.installPath, 'package.json'));
const homebridge = await this.parsePackageJson(pkgJson, homebridgeModule.path);

if (!homebridge.latestVersion) {
return homebridge;
Expand Down Expand Up @@ -631,8 +623,8 @@ export class PluginsService {
throw new Error('Could not find npm package');
}

const pjson: IPackageJson = await readJson(join(npmPkg.installPath, 'package.json'));
const npm = await this.parsePackageJson(pjson, npmPkg.path) as HomebridgePlugin & { showUpdateWarning?: boolean };
const pkgJson: IPackageJson = await readJson(join(npmPkg.installPath, 'package.json'));
const npm = await this.parsePackageJson(pkgJson, npmPkg.path) as HomebridgePlugin & { showUpdateWarning?: boolean };

// show the update warning if the installed version is below the minimum recommended
// (bwp91) I set this to 9.5.0 to match a minimum node version of 18.15.0
Expand Down Expand Up @@ -1227,31 +1219,31 @@ export class PluginsService {

/**
* Convert the package.json into a HomebridgePlugin
* @param pjson
* @param pkgJson
* @param installPath
*/
private async parsePackageJson(pjson: IPackageJson, installPath: string): Promise<HomebridgePlugin> {
private async parsePackageJson(pkgJson: IPackageJson, installPath: string): Promise<HomebridgePlugin> {
const plugin: HomebridgePlugin = {
name: pjson.name,
private: pjson.private || false,
displayName: pjson.displayName,
description: (pjson.description) ?
pjson.description.replace(/(?:https?|ftp):\/\/[\n\S]+/g, '').trim() : pjson.name,
verifiedPlugin: this.verifiedPlugins.includes(pjson.name),
icon: this.verifiedPluginsIcons[pjson.name]
? `${this.verifiedPluginsIconsPrefix}${this.verifiedPluginsIcons[pjson.name]}`
name: pkgJson.name,
private: pkgJson.private || false,
displayName: pkgJson.displayName,
description: (pkgJson.description) ?
pkgJson.description.replace(/(?:https?|ftp):\/\/[\n\S]+/g, '').trim() : pkgJson.name,
verifiedPlugin: this.verifiedPlugins.includes(pkgJson.name),
icon: this.verifiedPluginsIcons[pkgJson.name]
? `${this.verifiedPluginsIconsPrefix}${this.verifiedPluginsIcons[pkgJson.name]}`
: null,
installedVersion: installPath ? (pjson.version || '0.0.1') : null,
installedVersion: installPath ? (pkgJson.version || '0.0.1') : null,
globalInstall: (installPath !== this.configService.customPluginPath),
settingsSchema: await pathExists(resolve(installPath, pjson.name, 'config.schema.json')) || this.miscSchemas[pjson.name],
settingsSchema: await pathExists(resolve(installPath, pkgJson.name, 'config.schema.json')) || this.miscSchemas[pkgJson.name],
installPath,
};

// only verified plugins can show donation links
plugin.funding = plugin.verifiedPlugin ? pjson.funding : undefined;
plugin.funding = plugin.verifiedPlugin ? pkgJson.funding : undefined;

// if the plugin is private, do not attempt to query npm
if (pjson.private) {
if (pkgJson.private) {
plugin.publicPackage = false;
plugin.latestVersion = null;
plugin.updateAvailable = false;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<div class="card card-body mb-3">
<div class="d-flex flex-row justify-content-between">
<div class="d-flex flex-column mr-3 align-items-center justify-content-between">
<img [src]="plugin.icon" (error)="handleIconError()" alt="" class="plugin-icon-card mb-3">
<img [src]="plugin.icon" (error)="handleIconError()" alt="Plugin Icon" class="plugin-icon-card mb-3">
</div>
<div class="d-flex flex-column justify-content-between" style="min-width: calc(100% - 80px)">
<div class="d-flex flex-row align-items-end">
Expand Down Expand Up @@ -36,7 +36,7 @@ <h5 class="card-title mb-2 text-truncate">
<a href="javascript:void(0)" class="card-link" (click)="$plugin.bridgeSettings(plugin)" [ngbTooltip]="'child_bridge.label_bridge_connect_to_homekit' | translate" container="body"
*ngIf="plugin.installedVersion && !plugin.updateAvailable && plugin.isConfigured && plugin.hasChildBridges && !childBridgeRestartInProgress && hasUnpairedChildBridges && childBridgeStatus === 'ok' && !plugin.disabled"
[attr.aria-label]="'child_bridge.label_bridge_connect_to_homekit' | translate">
<i class="icon-button fas fa-fw fa-qrcode primary-text fa-lg fa-fad ml-3" style="--fa-animation-duration: 2s;"></i>
<i class="icon-button fas fa-fw fa-qrcode primary-text fa-lg fa-fade ml-3" style="--fa-animation-duration: 2s;"></i>
</a>
<!-- // -->
<!-- restart in progress spinner -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@ export class PluginCardComponent implements OnInit {
}

ngOnInit(): void {
this.isMobile = this.$md.detect.mobile();
this.isMobile = this.$md.detect.mobile();
this.prettyDisplayName = this.prettifyName();
this.io = this.$ws.getExistingNamespace('child-bridges');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ <h5 class="modal-title">{{ 'plugins.manage.information' | translate }}</h5>
</button>
</div>
<div class="modal-body w-100 text-center">
<img [src]="plugin.icon" (error)="handleIconError()" alt="" class="plugin-icon-card mb-3"
style="height: 75px; width: 75px;">
<img [src]="plugin.icon" (error)="handleIconError()" alt="Plugin Icon" class="mb-3 plugin-icon">
<h4>{{ plugin.displayName || plugin.name }}</h4>
<p class="my-3">{{ plugin.description }}</p>
<p class="m-0 grey-text">{{ plugin.name }}</p>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.plugin-icon {
height: 75px;
width: 75px;
border-radius: 15px;
border: 1px solid #222222;
}
1 change: 0 additions & 1 deletion ui/src/app/modules/plugins/plugins.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
padding: 10px;
margin-bottom: 15px;


@media (hover: hover) {
&:hover {
border: 1px solid #000000;
Expand Down
50 changes: 43 additions & 7 deletions ui/src/app/modules/plugins/plugins.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { TranslateService } from '@ngx-translate/core';
import { ToastrService } from 'ngx-toastr';
import { Subscription } from 'rxjs';
import { ApiService } from '@/app/core/api.service';
import { SettingsService } from '@/app/core/settings.service';
import { IoNamespace, WsService } from '@/app/core/ws.service';

@Component({
Expand All @@ -27,6 +28,7 @@ export class PluginsComponent implements OnInit, OnDestroy {
private $api: ApiService,
private $ws: WsService,
private $router: Router,
private $settings: SettingsService,
private $toastr: ToastrService,
private $translate: TranslateService,
) {}
Expand All @@ -36,6 +38,9 @@ export class PluginsComponent implements OnInit, OnDestroy {
this.io.connected.subscribe(async () => {
this.getChildBridgeMetadata();
this.io.socket.emit('monitor-child-bridge-status');

// load list of installed plugins
await this.loadInstalledPlugins();
});

this.io.socket.on('child-bridge-status-update', (data) => {
Expand All @@ -53,9 +58,6 @@ export class PluginsComponent implements OnInit, OnDestroy {
this.loadInstalledPlugins();
}
});

// load list of installed plugins
await this.loadInstalledPlugins();
}

async loadInstalledPlugins() {
Expand All @@ -67,8 +69,41 @@ export class PluginsComponent implements OnInit, OnDestroy {
try {
const installedPlugins = await this.$api.get('/plugins').toPromise();
this.installedPlugins = installedPlugins.filter((x) => x.name !== 'homebridge-config-ui-x');
this.appendMetaInfo();
await this.appendMetaInfo();
this.loading = false;

// The backend used to sort this only by plugins with updates first
// I removed this sorting since now we want the frontend to do more of the work
// We have more things that we want to bring to the top of the list
return this.installedPlugins.sort((a, b) =>{
// Priority 1: updateAvailable (true first, sorted alphabetically by 'name')
if (a.updateAvailable !== b.updateAvailable) {
return a.updateAvailable ? -1 : 1;
}

// Priority 2: isConfigured (false first, sorted alphabetically by 'name')
if (a.isConfigured !== b.isConfigured) {
return a.isConfigured ? 1 : -1;
}

// Priority 3: hasChildBridgesUnpaired (true first, sorted alphabetically by 'name')
if (a.hasChildBridgesUnpaired !== b.hasChildBridgesUnpaired) {
return a.hasChildBridgesUnpaired ? -1 : 1;
}

// Priority 4: hasChildBridges (false first, sorted alphabetically by 'name', only when recommendChildBridges is true)
if (a.hasChildBridges !== b.hasChildBridges && this.$settings.env.recommendChildBridges) {
return a.hasChildBridges ? 1 : -1;
}

// Priority 5: disabled (false first, sorted alphabetically by 'name')
if (a.disabled !== b.disabled) {
return a.disabled ? -1 : 1;
}

// If all criteria are equal, sort alphabetically by 'name'
return a.name.localeCompare(b.name);
});
} catch (err) {
this.$toastr.error(
`${this.$translate.instant('plugins.toast_failed_to_load_plugins')}: ${err.message}`,
Expand All @@ -85,8 +120,9 @@ export class PluginsComponent implements OnInit, OnDestroy {
try {
const configBlocks = await this.$api.get(`/config-editor/plugin/${encodeURIComponent(plugin.name)}`).toPromise();
plugin.isConfigured = configBlocks.length > 0;
// eslint-disable-next-line no-underscore-dangle
plugin.hasChildBridges = plugin.isConfigured && configBlocks.some((x) => x._bridge && x._bridge.username);
const pluginChildBridges = this.getPluginChildBridges(plugin);
plugin.hasChildBridges = pluginChildBridges.length > 0;
plugin.hasChildBridgesUnpaired = pluginChildBridges.some((x) => !x.paired);
} catch (err) {
// may not be technically correct, but if we can't load the config, assume it is configured
plugin.isConfigured = true;
Expand Down Expand Up @@ -134,7 +170,7 @@ export class PluginsComponent implements OnInit, OnDestroy {
}

getPluginChildBridges(plugin) {
return this.childBridges.filter(x => x.plugin === plugin.name);
return this.childBridges.filter((x) => x.plugin === plugin.name);
}

ngOnDestroy() {
Expand Down

0 comments on commit fa82d59

Please sign in to comment.