mirror of
https://github.com/Ombi-app/Ombi.git
synced 2025-08-20 21:33:15 -07:00
more wip on the issues
This commit is contained in:
parent
1289b840a0
commit
6683f8070c
19 changed files with 209 additions and 151 deletions
|
@ -12,7 +12,7 @@ namespace Ombi.Core.Engine.V2
|
|||
public interface IIssuesEngine
|
||||
{
|
||||
Task<IEnumerable<IssuesSummaryModel>> GetIssues(int position, int take, IssueStatus status, CancellationToken token);
|
||||
Task<IEnumerable<IssuesSummaryModel>> GetIssuesByTitle(string title, CancellationToken token);
|
||||
Task<IssuesSummaryModel> GetIssuesByProviderId(string providerId, CancellationToken token);
|
||||
}
|
||||
|
||||
public class IssuesEngine : IIssuesEngine
|
||||
|
@ -32,7 +32,7 @@ namespace Ombi.Core.Engine.V2
|
|||
|
||||
public async Task<IEnumerable<IssuesSummaryModel>> GetIssues(int position, int take, IssueStatus status, CancellationToken token)
|
||||
{
|
||||
var issues = await _issues.GetAll().Include(x => x.UserReported).Include(x => x.IssueCategory).Where(x => x.Status == status).Skip(position).Take(take).OrderBy(x => x.Title).ToListAsync(token);
|
||||
var issues = await _issues.GetAll().Where(x => x.Status == status).Skip(position).Take(take).OrderBy(x => x.Title).ToListAsync(token);
|
||||
var grouped = issues.GroupBy(x => x.Title, (key, g) => new { Title = key, Issues = g });
|
||||
|
||||
var model = new List<IssuesSummaryModel>();
|
||||
|
@ -43,32 +43,30 @@ namespace Ombi.Core.Engine.V2
|
|||
{
|
||||
Count = group.Issues.Count(),
|
||||
Title = group.Title,
|
||||
Issues = group.Issues
|
||||
ProviderId = group.Issues.FirstOrDefault()?.ProviderId
|
||||
});
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<IssuesSummaryModel>> GetIssuesByTitle(string title, CancellationToken token)
|
||||
public async Task<IssuesSummaryModel> GetIssuesByProviderId(string providerId, CancellationToken token)
|
||||
{
|
||||
var lowerTitle = title.ToLowerInvariant();
|
||||
var issues = await _issues.GetAll().Include(x => x.UserReported).Include(x => x.IssueCategory).Where(x => x.Title.ToLowerInvariant() == lowerTitle).ToListAsync(token);
|
||||
var grouped = issues.GroupBy(x => x.Title, (key, g) => new { Title = key, Issues = g });
|
||||
var issues = await _issues.GetAll().Include(x => x.Comments).ThenInclude(x => x.User).Include(x => x.UserReported).Include(x => x.IssueCategory).Where(x => x.ProviderId == providerId).ToListAsync(token);
|
||||
var grouped = issues.GroupBy(x => x.Title, (key, g) => new { Title = key, Issues = g }).FirstOrDefault();
|
||||
|
||||
var model = new List<IssuesSummaryModel>();
|
||||
|
||||
foreach (var group in grouped)
|
||||
if (grouped == null)
|
||||
{
|
||||
model.Add(new IssuesSummaryModel
|
||||
{
|
||||
Count = group.Issues.Count(),
|
||||
Title = group.Title,
|
||||
Issues = group.Issues
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return model;
|
||||
return new IssuesSummaryModel
|
||||
{
|
||||
Count = grouped.Issues.Count(),
|
||||
Title = grouped.Title,
|
||||
ProviderId = grouped.Issues.FirstOrDefault()?.ProviderId,
|
||||
Issues = grouped.Issues
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -77,6 +75,7 @@ namespace Ombi.Core.Engine.V2
|
|||
{
|
||||
public string Title { get; set; }
|
||||
public int Count { get; set; }
|
||||
public string ProviderId { get; set; }
|
||||
public IEnumerable<Issues> Issues { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -145,6 +145,7 @@ export function JwtTokenGetter() {
|
|||
MatCheckboxModule,
|
||||
MatProgressSpinnerModule,
|
||||
MDBBootstrapModule.forRoot(),
|
||||
// NbThemeModule.forRoot({ name: 'dark'}),
|
||||
JwtModule.forRoot({
|
||||
config: {
|
||||
tokenGetter: JwtTokenGetter,
|
||||
|
|
|
@ -66,5 +66,6 @@ export interface IUpdateStatus {
|
|||
export interface IIssuesSummary {
|
||||
title: string;
|
||||
count: number;
|
||||
providerId: string;
|
||||
issues: IIssues[];
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
<div *ngIf="details">
|
||||
|
||||
<h1>Issues for {{details.title}}</h1>
|
||||
<div>
|
||||
<span>Has Request {{hasRequest}}</span>
|
||||
<div class="row">
|
||||
<div class="col-6 top-spacing" *ngFor="let issue of details.issues">
|
||||
<div color="accent">
|
||||
<div>
|
||||
<div>{{issue.subject}}</div>
|
||||
<span>{{issue.userReported?.userName}} on {{issue.createdDate | date:short}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p>
|
||||
{{issue.description}}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button mat-raised-button color="accent">{{'Issues.Chat' | translate }}</button>
|
||||
<span *ngIf="isAdmin && settings">
|
||||
<button mat-raised-button color="accent"
|
||||
*ngIf="issue.status === IssueStatus.Pending && settings.enableInProgress"
|
||||
(click)="inProgress(issue)">{{'Issues.MarkInProgress' | translate }}</button>
|
||||
<button mat-raised-button color="accent"
|
||||
*ngIf="issue.status === IssueStatus.Pending && !settings.enableInProgress || issue.status == IssueStatus.InProgress"
|
||||
(click)="resolve(issue)">{{'Issues.MarkResolved' | translate}}</button>
|
||||
<button mat-raised-button color="warn" (click)="delete(issue)">{{'Issues.Delete' | translate}}</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button mat-raised-button color="accent" (click)="navToMedia()">{{'Common.ViewDetails' | translate }}</button>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,9 @@
|
|||
@import "~styles/variables.scss";
|
||||
|
||||
::ng-deep .mat-card {
|
||||
background: $ombi-background-primary-accent;
|
||||
}
|
||||
|
||||
.top-spacing {
|
||||
margin-top:2%;
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
import { Component, Inject, OnInit, ViewEncapsulation } from "@angular/core";
|
||||
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { ActivatedRoute, ActivatedRouteSnapshot, Router } from "@angular/router";
|
||||
import { TranslateService } from "@ngx-translate/core";
|
||||
import { AuthService } from "../../../auth/auth.service";
|
||||
import { IIssues, IIssueSettings, IIssuesSummary, IssueStatus, RequestType } from "../../../interfaces";
|
||||
import { IssuesService, NotificationService, SettingsService } from "../../../services";
|
||||
import { IssuesV2Service } from "../../../services/issuesv2.service";
|
||||
|
||||
|
||||
export interface IssuesDetailsGroupData {
|
||||
issues: IIssues[];
|
||||
title: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "issues-details",
|
||||
templateUrl: "details.component.html",
|
||||
styleUrls: ["details.component.scss"],
|
||||
encapsulation: ViewEncapsulation.None
|
||||
})
|
||||
export class IssuesDetailsComponent implements OnInit {
|
||||
|
||||
public details: IIssuesSummary;
|
||||
public isAdmin: boolean;
|
||||
public IssueStatus = IssueStatus;
|
||||
public settings: IIssueSettings;
|
||||
public get hasRequest(): boolean {
|
||||
return this.details.issues.some(x => x.requestId);
|
||||
}
|
||||
|
||||
private providerId: string;
|
||||
|
||||
constructor(private authService: AuthService, private settingsService: SettingsService,
|
||||
private issueServiceV2: IssuesV2Service, private route: ActivatedRoute, private router: Router,
|
||||
private issuesService: IssuesService, private translateService: TranslateService, private notificationService: NotificationService) {
|
||||
this.route.params.subscribe(async (params: any) => {
|
||||
if (typeof params.providerId === 'string' || params.providerId instanceof String) {
|
||||
this.providerId = params.providerId;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnInit() {
|
||||
this.isAdmin = this.authService.hasRole("Admin") || this.authService.hasRole("PowerUser");
|
||||
this.settingsService.getIssueSettings().subscribe(x => this.settings = x);
|
||||
this.issueServiceV2.getIssuesByProviderId(this.providerId).subscribe(x => this.details = x);
|
||||
}
|
||||
|
||||
public resolve(issue: IIssues) {
|
||||
this.issuesService.updateStatus({issueId: issue.id, status: IssueStatus.Resolved}).subscribe(x => {
|
||||
this.notificationService.success(this.translateService.instant("Issues.MarkedAsResolved"));
|
||||
issue.status = IssueStatus.Resolved;
|
||||
});
|
||||
}
|
||||
|
||||
public inProgress(issue: IIssues) {
|
||||
this.issuesService.updateStatus({issueId: issue.id, status: IssueStatus.InProgress}).subscribe(x => {
|
||||
this.notificationService.success(this.translateService.instant("Issues.MarkedAsInProgress"));
|
||||
issue.status = IssueStatus.InProgress;
|
||||
});
|
||||
}
|
||||
|
||||
public async delete(issue: IIssues) {
|
||||
await this.issuesService.deleteIssue(issue.id);
|
||||
this.notificationService.success(this.translateService.instant("Issues.DeletedIssue"));
|
||||
this.details.issues = this.details.issues.filter((el) => { return el.id !== issue.id; });
|
||||
}
|
||||
|
||||
|
||||
public navToMedia() {
|
||||
const firstIssue = this.details.issues[0];
|
||||
switch(firstIssue.requestType) {
|
||||
case RequestType.movie:
|
||||
this.router.navigate(['/details/movie/', firstIssue.providerId]);
|
||||
return;
|
||||
|
||||
case RequestType.album:
|
||||
this.router.navigate(['/details/artist/', firstIssue.providerId]);
|
||||
return;
|
||||
|
||||
case RequestType.tvShow:
|
||||
this.router.navigate(['/details/tv/', firstIssue.providerId]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,15 +1,15 @@
|
|||
import { AuthGuard } from "../../auth/auth.guard";
|
||||
import { IssuesListComponent } from "./issues-list/issues-list.component";
|
||||
import { Routes } from "@angular/router";
|
||||
import { IssuesV2Service } from "../../services/issuesv2.service";
|
||||
import { IdentityService, SearchService } from "../../services";
|
||||
import { DetailsGroupComponent } from "./details-group/details-group.component";
|
||||
import { IssuesDetailsComponent } from "./details/details.component";
|
||||
|
||||
|
||||
|
||||
export const components: any[] = [
|
||||
IssuesListComponent,
|
||||
DetailsGroupComponent,
|
||||
IssuesDetailsComponent,
|
||||
];
|
||||
|
||||
export const providers: any[] = [
|
||||
|
@ -17,7 +17,3 @@ export const providers: any[] = [
|
|||
IdentityService,
|
||||
SearchService,
|
||||
];
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: "", component: IssuesListComponent, canActivate: [AuthGuard] },
|
||||
];
|
|
@ -1,21 +0,0 @@
|
|||
<!-- <table mat-table [dataSource]="pendingIssues" multiTemplateDataRows class="mat-elevation-z8">
|
||||
<ng-container matColumnDef="{{column}}" *ngFor="let column of columnsToDisplay">
|
||||
<th mat-header-cell *matHeaderCellDef> {{column}} </th>
|
||||
<td mat-cell *matCellDef="let element"> {{element[column]}} </td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="expandedDetail">
|
||||
<td mat-cell *matCellDef="let element" [attr.colspan]="columnsToDisplay.length">
|
||||
<div class="example-element-detail" [@detailExpand]="element == expandedElement ? 'expanded' : 'collapsed'">
|
||||
<div class="example-element-diagram">
|
||||
<div class="example-element-position"> {{element.requestId}} </div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="columnsToDisplay"></tr>
|
||||
<tr mat-row *matRowDef="let element; columns: columnsToDisplay;" class="example-element-row" [class.example-expanded-row]="expandedElement === element" (click)="expandedElement = expandedElement === element ? null : element">
|
||||
</tr>
|
||||
<tr mat-row *matRowDef="let row; columns: ['expandedDetail']" class="example-detail-row"></tr>
|
||||
</table> -->
|
|
@ -1,68 +0,0 @@
|
|||
import { Component, OnInit } from "@angular/core";
|
||||
|
||||
import { IssuesService } from "../../../services";
|
||||
|
||||
import { IIssueCount, IIssues, IPagenator, IssueStatus } from "../../../interfaces";
|
||||
import { COLUMNS } from "./issues-list.constants";
|
||||
|
||||
@Component({
|
||||
selector: "issues-list",
|
||||
templateUrl: "issues-list.component.html",
|
||||
})
|
||||
export class IssuesListComponent implements OnInit {
|
||||
|
||||
public columnsToDisplay = COLUMNS
|
||||
|
||||
public pendingIssues: IIssues[];
|
||||
public inProgressIssues: IIssues[];
|
||||
public resolvedIssues: IIssues[];
|
||||
|
||||
public count: IIssueCount;
|
||||
|
||||
private takeAmount = 10;
|
||||
private pendingSkip = 0;
|
||||
private inProgressSkip = 0;
|
||||
private resolvedSkip = 0;
|
||||
|
||||
constructor(private issueService: IssuesService) { }
|
||||
|
||||
public ngOnInit() {
|
||||
this.getPending();
|
||||
this.getInProg();
|
||||
this.getResolved();
|
||||
this.issueService.getIssuesCount().subscribe(x => this.count = x);
|
||||
}
|
||||
|
||||
public changePagePending(event: IPagenator) {
|
||||
this.pendingSkip = event.first;
|
||||
this.getPending();
|
||||
}
|
||||
|
||||
public changePageInProg(event: IPagenator) {
|
||||
this.inProgressSkip = event.first;
|
||||
this.getInProg();
|
||||
}
|
||||
|
||||
public changePageResolved(event: IPagenator) {
|
||||
this.resolvedSkip = event.first;
|
||||
this.getResolved();
|
||||
}
|
||||
|
||||
private getPending() {
|
||||
this.issueService.getIssuesPage(this.takeAmount, this.pendingSkip, IssueStatus.Pending).subscribe(x => {
|
||||
this.pendingIssues = x;
|
||||
});
|
||||
}
|
||||
|
||||
private getInProg() {
|
||||
this.issueService.getIssuesPage(this.takeAmount, this.inProgressSkip, IssueStatus.InProgress).subscribe(x => {
|
||||
this.inProgressIssues = x;
|
||||
});
|
||||
}
|
||||
|
||||
private getResolved() {
|
||||
this.issueService.getIssuesPage(this.takeAmount, this.resolvedSkip, IssueStatus.Resolved).subscribe(x => {
|
||||
this.resolvedIssues = x;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
export const COLUMNS = [
|
||||
"title"
|
||||
]
|
|
@ -1,19 +1,28 @@
|
|||
<div class="small-middle-container">
|
||||
<div class="row" *ngIf="count">
|
||||
<div *ngIf="count">
|
||||
|
||||
<div *ngIf="pendingIssues.length > 0" class="col-4">
|
||||
<h2>{{'Issues.PendingTitle' | translate}}</h2>
|
||||
<mat-tab-group>
|
||||
<mat-tab label="{{'Issues.PendingTitle' | translate}}">
|
||||
<ng-template matTabContent>
|
||||
<div *ngIf="pendingIssues.length > 0">
|
||||
<issues-table [issues]="pendingIssues" (changePage)="changePagePending($event)" [totalRecords]="count.pending"></issues-table>
|
||||
</div>
|
||||
|
||||
<div *ngIf="inProgressIssues.length > 0" class="col-4">
|
||||
<h2>{{'Issues.InProgressTitle' | translate}}</h2>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
<mat-tab *ngIf="inProgressIssues.length > 0" label="{{'Issues.InProgressTitle' | translate}}">
|
||||
<ng-template matTabContent>
|
||||
<div *ngIf="inProgressIssues">
|
||||
<issues-table [issues]="inProgressIssues" (changePage)="changePageInProg($event)" [totalRecords]="count.inProgress"></issues-table>
|
||||
</div>
|
||||
|
||||
<div *ngIf="resolvedIssues.length > 0" class="col-4">
|
||||
<h2>{{'Issues.ResolvedTitle' | translate}}</h2>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
<mat-tab label="{{'Issues.ResolvedTitle' | translate}}">
|
||||
<ng-template matTabContent>
|
||||
<div *ngIf="resolvedIssues.length > 0">
|
||||
<issues-table [issues]="resolvedIssues" (changePage)="changePageResolved($event)" [totalRecords]="count.resolved"></issues-table>
|
||||
</div>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</div>
|
||||
</div>
|
|
@ -1,5 +1,6 @@
|
|||
import { NgModule } from "@angular/core";
|
||||
import { RouterModule, Routes } from "@angular/router";
|
||||
// import { NbChatModule, NbThemeModule } from '@nebular/theme';
|
||||
|
||||
import { OrderModule } from "ngx-order-pipe";
|
||||
|
||||
|
@ -10,6 +11,7 @@ import { SharedModule as OmbiShared } from "../shared/shared.module";
|
|||
import { IssueDetailsComponent } from "./issueDetails.component";
|
||||
import { IssuesComponent } from "./issues.component";
|
||||
import { IssuesTableComponent } from "./issuestable.component";
|
||||
import { IssuesDetailsComponent } from "./components/details/details.component";
|
||||
|
||||
import { PipeModule } from "../pipes/pipe.module";
|
||||
|
||||
|
@ -17,7 +19,7 @@ import * as fromComponents from "./components";
|
|||
|
||||
const routes: Routes = [
|
||||
{ path: "", component: IssuesComponent, canActivate: [AuthGuard] },
|
||||
{ path: ":id", component: IssueDetailsComponent, canActivate: [AuthGuard] },
|
||||
{ path: ":providerId", component: IssuesDetailsComponent, canActivate: [AuthGuard] },
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
@ -25,7 +27,8 @@ const routes: Routes = [
|
|||
RouterModule.forChild(routes),
|
||||
OrderModule,
|
||||
PipeModule,
|
||||
OmbiShared
|
||||
OmbiShared,
|
||||
// NbChatModule,
|
||||
],
|
||||
declarations: [
|
||||
IssuesComponent,
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef> </th>
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<button mat-raised-button color="accent" (click)="openDetails(element)">{{ 'Issues.Details' | translate}}</button>
|
||||
<button mat-raised-button color="accent" [routerLink]="[element.providerId]">{{ 'Issues.Details' | translate}}</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||
import { MatDialog } from "@angular/material/dialog";
|
||||
|
||||
import { IIssues, IIssuesSummary, IPagenator, IssueStatus } from "../interfaces";
|
||||
import { DetailsGroupComponent, IssuesDetailsGroupData } from "./components/details-group/details-group.component";
|
||||
import { IIssuesSummary, IPagenator, IssueStatus } from "../interfaces";
|
||||
|
||||
@Component({
|
||||
selector: "issues-table",
|
||||
|
@ -52,12 +51,4 @@ export class IssuesTableComponent {
|
|||
public paginate(event: IPagenator) {
|
||||
this.changePage.emit(event);
|
||||
}
|
||||
|
||||
public openDetails(summary: IIssuesSummary) {
|
||||
const dialogRef = this.dialog.open(DetailsGroupComponent, {
|
||||
width: "50vw",
|
||||
data: <IssuesDetailsGroupData>{ issues: summary.issues, title: summary.title },
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -113,9 +113,13 @@ export class MovieDetailsComponent {
|
|||
}
|
||||
|
||||
public async issue() {
|
||||
let provider = this.movie.id.toString();
|
||||
if (this.movie.imdbId) {
|
||||
provider = this.movie.imdbId;
|
||||
}
|
||||
const dialogRef = this.dialog.open(NewIssueComponent, {
|
||||
width: '500px',
|
||||
data: { requestId: this.movieRequest ? this.movieRequest.id : null, requestType: RequestType.movie, providerId: this.movie.imdbId ? this.movie.imdbId : this.movie.id, title: this.movie.title }
|
||||
data: { requestId: this.movieRequest ? this.movieRequest.id : null, requestType: RequestType.movie, providerId: provider, title: this.movie.title }
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -6,7 +6,8 @@ import { TranslateService } from "@ngx-translate/core";
|
|||
@Component({
|
||||
selector: "issues-panel",
|
||||
templateUrl: "./issues-panel.component.html",
|
||||
styleUrls: ["./issues-panel.component.scss"]
|
||||
styleUrls: ["./issues-panel.component.scss"],
|
||||
|
||||
})
|
||||
export class IssuesPanelComponent implements OnInit {
|
||||
|
||||
|
@ -22,7 +23,6 @@ export class IssuesPanelComponent implements OnInit {
|
|||
|
||||
constructor(private issuesService: IssuesService, private notificationService: NotificationService,
|
||||
private translateService: TranslateService, private settingsService: SettingsService) {
|
||||
|
||||
}
|
||||
|
||||
public async ngOnInit() {
|
||||
|
|
|
@ -16,4 +16,8 @@ export class IssuesV2Service extends ServiceHelpers {
|
|||
public getIssues(position: number, take: number, status: IssueStatus): Observable<IIssuesSummary[]> {
|
||||
return this.http.get<IIssuesSummary[]>(`${this.url}${position}/${take}/${status}`, {headers: this.headers});
|
||||
}
|
||||
|
||||
public getIssuesByProviderId(providerId: string): Observable<IIssuesSummary> {
|
||||
return this.http.get<IIssuesSummary>(`${this.url}details/${providerId}`, {headers: this.headers});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,5 +20,12 @@ namespace Ombi.Controllers.V2
|
|||
{
|
||||
return _engine.GetIssues(position, take, status, HttpContext.RequestAborted);
|
||||
}
|
||||
|
||||
|
||||
[HttpGet("details/{providerId}")]
|
||||
public Task<IssuesSummaryModel> GetIssueDetails(string providerId)
|
||||
{
|
||||
return _engine.GetIssuesByProviderId(providerId, HttpContext.RequestAborted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue