Support custom widgets in the frontend

Adds #91
This commit is contained in:
turt2live 2017-08-28 22:08:32 -06:00
parent f1c12a2ea6
commit c9571576fe
12 changed files with 311 additions and 4 deletions

View File

@ -22,6 +22,7 @@ import { RssConfigComponent } from "./configs/rss/rss-config.component";
import { IrcConfigComponent } from "./configs/irc/irc-config.component";
import { IrcApiService } from "./shared/irc-api.service";
import { TravisCiConfigComponent } from "./configs/travisci/travisci-config.component";
import { CustomWidgetConfigComponent } from "./configs/widget/custom_widget/custom_widget-config.component";
@NgModule({
imports: [
@ -45,6 +46,7 @@ import { TravisCiConfigComponent } from "./configs/travisci/travisci-config.comp
RssConfigComponent,
IrcConfigComponent,
TravisCiConfigComponent,
CustomWidgetConfigComponent,
// Vendor
],
@ -61,6 +63,7 @@ import { TravisCiConfigComponent } from "./configs/travisci/travisci-config.comp
RssConfigComponent,
TravisCiConfigComponent,
IrcConfigComponent,
CustomWidgetConfigComponent,
]
})
export class AppModule {

View File

@ -0,0 +1,65 @@
<div class="config-wrapper">
<img src="/img/close.svg" (click)="dialog.close()" class="close-icon">
<div class="config-header">
<img src="/img/avatars/customwidget.png">
<h4>Configure custom widgets</h4>
</div>
<div class="config-content" *ngIf="isLoading">
<div class="row">
<div class="col-md-12">
<p><i class="fa fa-circle-notch fa-spin"></i> Loading widgets...</p>
</div>
</div>
</div>
<div class="config-content" *ngIf="!isLoading">
<form (submit)="addWidget()" novalidate name="addForm">
<div class="row">
<div class="col-md-8" style="margin-bottom: 12px;">
<div class="input-group input-group-sm">
<input type="text" class="form-control"
placeholder="Custom widget URL"
[(ngModel)]="widgetUrl" name="widgetUrl"
[disabled]="isUpdating">
<span class="input-group-btn">
<button type="submit" class="btn btn-success" [disabled]="isUpdating">
<i class="fa fa-plus-circle"></i> Add Widget
</button>
</span>
</div>
</div>
<div class="col-md-12 removable" *ngFor="let widget of widgets trackById">
{{ widget.name || widget.url }} <span class="text-muted" *ngIf="widget.ownerId">(added by {{ widget.ownerId }})</span>
<button type="button" class="btn btn-outline-info btn-sm" (click)="editWidget(widget)"
style="margin-top: -5px;" [disabled]="isUpdating">
<i class="fa fa-pencil"></i> Edit Widget
</button>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="removeWidget(widget)"
style="margin-top: -5px;" [disabled]="isUpdating">
<i class="fa fa-times"></i> Remove Widget
</button>
<div *ngIf="isWidgetToggled(widget)">
<label>
Widget Name
<input type="text" class="form-control"
placeholder="Custom Widget"
[(ngModel)]="widget.newName" name="widget-name-{{widget.id}}"
[disabled]="isUpdating">
</label>
<label>
Widget URL
<input type="text" class="form-control"
placeholder="Custom widget URL"
[(ngModel)]="widget.newUrl" name="widget-url-{{widget.id}}"
[disabled]="isUpdating">
</label>
<button type="button" class="btn btn-primary btn-sm" (click)="saveWidget(widget)">Save
</button>
<button type="button" class="btn btn-outline btn-sm" (click)="toggleWidget(widget)">
Cancel
</button>
</div>
</div>
</div>
</form>
</div>
</div>

View File

@ -0,0 +1 @@
// component styles are encapsulated and only applied to their components

View File

@ -0,0 +1,113 @@
import { Component } from "@angular/core";
import { ModalComponent, DialogRef } from "ngx-modialog";
import { WidgetComponent } from "../widget.component";
import { ScalarService } from "../../../shared/scalar.service";
import { ConfigModalContext } from "../../../integration/integration.component";
import { ToasterService } from "angular2-toaster";
import { Widget, WIDGET_DIM_CUSTOM, WIDGET_SCALAR_CUSTOM } from "../../../shared/models/widget";
// TODO: A lot of this can probably be abstracted out for other widgets (even the UI), possibly even for other integrations
@Component({
selector: "my-customwidget-config",
templateUrl: "custom_widget-config.component.html",
styleUrls: ["custom_widget-config.component.scss", "./../../config.component.scss"],
})
export class CustomWidgetConfigComponent extends WidgetComponent implements ModalComponent<ConfigModalContext> {
public isLoading = true;
public isUpdating = false;
public widgets: Widget[];
public widgetUrl = "";
private toggledWidgets: string[] = [];
constructor(public dialog: DialogRef<ConfigModalContext>,
private toaster: ToasterService,
scalarService: ScalarService) {
super(scalarService, dialog.context.roomId);
this.getWidgetsOfType(WIDGET_DIM_CUSTOM, WIDGET_SCALAR_CUSTOM).then(widgets => {
this.widgets = widgets;
this.isLoading = false;
this.isUpdating = false;
});
}
public addWidget() {
let constructedWidget: Widget = {
id: "dimension-" + (new Date().getTime()),
url: this.widgetUrl,
type: WIDGET_DIM_CUSTOM,
name: "Custom Widget",
};
this.isUpdating = true;
this.scalarApi.setWidget(this.roomId, constructedWidget)
.then(() => this.widgets.push(constructedWidget))
.then(() => {
this.isUpdating = false;
this.widgetUrl = "";
this.toaster.pop("success", "Widget added!");
})
.catch(err => {
this.toaster.pop("error", err.json().error);
console.error(err);
this.isUpdating = false;
});
}
public saveWidget(widget: Widget) {
if (widget.newUrl.trim().length === 0) {
this.toaster.pop("warning", "Please enter a URL for the widget");
return;
}
widget.name = widget.newName || "Custom Widget";
widget.url = widget.newUrl;
this.isUpdating = true;
this.scalarApi.setWidget(this.roomId, widget)
.then(() => this.toggleWidget(widget))
.then(() => {
this.isUpdating = false;
this.toaster.pop("success", "Widget updated!");
})
.catch(err => {
this.toaster.pop("error", err.json().error);
console.error(err);
this.isUpdating = false;
});
}
public removeWidget(widget: Widget) {
this.isUpdating = true;
this.scalarApi.deleteWidget(this.roomId, widget)
.then(() => this.widgets.splice(this.widgets.indexOf(widget), 1))
.then(() => {
this.isUpdating = false;
this.toaster.pop("success", "Widget deleted!");
})
.catch(err => {
this.toaster.pop("error", err.json().error);
console.error(err);
this.isUpdating = false;
});
}
public editWidget(widget: Widget) {
widget.newName = widget.name || "Custom Widget";
widget.newUrl = widget.url;
this.toggleWidget(widget);
}
public toggleWidget(widget: Widget) {
let idx = this.toggledWidgets.indexOf(widget.id);
if (idx === -1) this.toggledWidgets.push(widget.id);
else this.toggledWidgets.splice(idx, 1);
}
public isWidgetToggled(widget: Widget) {
return this.toggledWidgets.indexOf(widget.id) !== -1;
}
}

View File

@ -0,0 +1,23 @@
import { ScalarService } from "../../shared/scalar.service";
import { Widget, ScalarToWidgets } from "../../shared/models/widget";
export class WidgetComponent {
constructor(protected scalarApi: ScalarService, protected roomId: string) {
}
protected getWidgetsOfType(type: string, altType: string = null): Promise<Widget[]> {
return this.scalarApi.getWidgets(this.roomId)
.then(resp => ScalarToWidgets(resp))
.then(widgets => {
let filtered: Widget[] = [];
for (let widget of widgets) {
if (widget.type === type || (altType && widget.type === altType))
filtered.push(widget);
}
return filtered;
});
}
}

View File

@ -3,7 +3,7 @@
<div class="title">
<b>{{ integration.name }}</b>
<div style="display: flex;">
<div class="switch" *ngIf="integration.type !== 'bridge'">
<div class="switch" *ngIf="integration.type !== 'bridge' && integration.type !== 'widget'">
<ui-switch [checked]="integration.isEnabled" size="small" [disabled]="integration.isBroken" (change)="update()"></ui-switch>
</div>
<div class="switch" *ngIf="integration.type == 'bridge' && !integration.isEnabled">

View File

@ -54,6 +54,12 @@ export class RiotComponent {
private updateIntegrationState(integration: Integration) {
integration.hasConfig = IntegrationService.hasConfig(integration);
if (integration.type === "widget") {
integration.isEnabled = true;
integration.isBroken = false;
return Promise.resolve();
}
if (integration.requirements) {
let keys = _.keys(integration.requirements);
let promises = [];

View File

@ -4,6 +4,7 @@ import { RssConfigComponent } from "../configs/rss/rss-config.component";
import { ContainerContent } from "ngx-modialog";
import { IrcConfigComponent } from "../configs/irc/irc-config.component";
import { TravisCiConfigComponent } from "../configs/travisci/travisci-config.component";
import { CustomWidgetConfigComponent } from "../configs/widget/custom_widget/custom_widget-config.component";
@Injectable()
export class IntegrationService {
@ -16,7 +17,10 @@ export class IntegrationService {
},
"bridge": {
"irc": true,
}
},
"widget": {
"customwidget": true
},
};
private static components = {
@ -26,7 +30,10 @@ export class IntegrationService {
},
"bridge": {
"irc": IrcConfigComponent,
}
},
"widget": {
"customwidget": CustomWidgetConfigComponent,
},
};
static isSupported(integration: Integration): boolean {

View File

@ -30,4 +30,19 @@ export interface JoinRuleStateResponse extends ScalarRoomResponse {
response: {
join_rule: string;
};
}
export interface WidgetsResponse extends ScalarRoomResponse {
response: {
type: "im.vector.modular.widgets";
state_key: string;
sender: string;
room_id: string;
content: {
type: string;
url: string;
name?: string;
data?: any;
}
}[];
}

View File

@ -0,0 +1,42 @@
import { WidgetsResponse } from "./scalar_responses";
// Scalar's widget types (known)
export const WIDGET_SCALAR_CUSTOM = "customwidget";
export const WIDGET_SCALAR_ETHERPAD = "etherpad";
export const WIDGET_SCALAR_GOOGLEDOCS = "googledocs";
export const WIDGET_SCALAR_JITSI = "jitsi";
export const WIDGET_SCALAR_YOUTUBE = "youtube";
export const WIDGET_SCALAR_GRAFANA = "grafana";
// Dimension has its own set of types to ensure that we don't conflict with Scalar
export const WIDGET_DIM_CUSTOM = "dimension-customwidget";
export interface Widget {
id: string;
type: string;
url: string;
name?: string;
data?: any;
ownerId?: string;
// used only in ui
newName?: string;
newUrl?: string;
}
export function ScalarToWidgets(scalarResponse: WidgetsResponse): Widget[] {
let widgets = [];
for (let event of scalarResponse.response) {
widgets.push({
id: event.state_key,
type: event.content.type,
url: event.content.url,
name: event.content.name,
data: event.content.data,
ownerId: event.sender,
});
}
return widgets;
}

View File

@ -1,6 +1,12 @@
import { Injectable } from "@angular/core";
import * as randomString from "random-string";
import { MembershipStateResponse, ScalarSuccessResponse, JoinRuleStateResponse } from "./models/scalar_responses";
import {
MembershipStateResponse,
ScalarSuccessResponse,
JoinRuleStateResponse,
WidgetsResponse
} from "./models/scalar_responses";
import { Widget } from "./models/widget";
@Injectable()
export class ScalarService {
@ -36,6 +42,32 @@ export class ScalarService {
});
}
public getWidgets(roomId: string): Promise<WidgetsResponse> {
return this.callAction("get_widgets", {
room_id: roomId
});
}
public setWidget(roomId: string, widget: Widget): Promise<ScalarSuccessResponse> {
return this.callAction("set_widget", {
room_id: roomId,
widget_id: widget.id,
type: widget.type,
url: widget.url,
name: widget.name,
data: widget.data
});
}
public deleteWidget(roomId: string, widget: Widget): Promise<ScalarSuccessResponse> {
return this.callAction("set_widget", {
room_id: roomId,
widget_id: widget.id,
type: widget.type, // required for some reason
url: ""
});
}
public close(): void {
this.callAction("close_scalar", {});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB