Editable short urls

This commit is contained in:
Sven Heidemann 2024-12-14 13:26:23 +01:00
parent bb44e59a14
commit 7b8e818339
17 changed files with 815 additions and 8 deletions

View File

@ -52,7 +52,7 @@ input ShortUrlCreateInput {
shortUrl: String! shortUrl: String!
targetUrl: String! targetUrl: String!
description: String description: String
group: ID groupId: ID
} }
input ShortUrlUpdateInput { input ShortUrlUpdateInput {
@ -60,5 +60,5 @@ input ShortUrlUpdateInput {
shortUrl: String shortUrl: String
targetUrl: String targetUrl: String
description: String description: String
group: ID groupId: ID
} }

View File

@ -42,3 +42,12 @@ class Mutation(MutationABC):
Permissions.groups_delete, Permissions.groups_delete,
], ],
) )
self.add_mutation_type(
"shortUrl",
"ShortUrl",
require_any_permission=[
Permissions.short_urls_create,
Permissions.short_urls_update,
Permissions.short_urls_delete,
],
)

View File

@ -85,7 +85,7 @@ class Query(QueryABC):
.with_dao(groupDao) .with_dao(groupDao)
.with_filter(GroupFilter) .with_filter(GroupFilter)
.with_sort(Sort[Group]) .with_sort(Sort[Group])
.with_require_any_permission([Permissions.groups]) .with_require_any_permission([Permissions.groups, Permissions.short_urls_create, Permissions.short_urls_update])
) )
# partially public to load redirect if not resolved/redirected by api # partially public to load redirect if not resolved/redirected by api
self.field( self.field(

View File

@ -0,0 +1,25 @@
import { DbModel } from "src/app/model/entities/db-model";
import { Permission } from "src/app/model/entities/role";
import { Group } from "src/app/model/entities/group";
export interface ShortUrl extends DbModel {
shortUrl: string;
targetUrl: string;
description: string;
group?: Group;
}
export interface ShortUrlCreateInput {
shortUrl: string;
targetUrl: string;
description: string;
groupId: number;
}
export interface ShortUrlUpdateInput {
id: number;
shortUrl: string;
targetUrl: string;
description: string;
groupId: number;
}

View File

@ -11,6 +11,13 @@ const routes: Routes = [
(m) => m.GroupsModule, (m) => m.GroupsModule,
), ),
}, },
{
path: "urls",
loadChildren: () =>
import("src/app/modules/admin/short-urls/short-urls.module").then(
(m) => m.ShortUrlsModule,
),
},
{ {
path: "administration", path: "administration",
loadChildren: () => loadChildren: () =>

View File

@ -0,0 +1,60 @@
<app-form-page
*ngIf="node"
[formGroup]="form"
[isUpdate]="isUpdate"
(onSave)="save()"
(onClose)="close()">
<ng-template formPageHeader let-isUpdate>
<h2>
{{ 'common.group' | translate }}
{{
(isUpdate ? 'sidebar.header.update' : 'sidebar.header.create')
| translate
}}
</h2>
</ng-template>
<ng-template formPageContent>
<div class="form-page-input">
<p class="label">{{ 'common.id' | translate }}</p>
<input pInputText class="value" type="number" formControlName="id"/>
</div>
<div class="form-page-input">
<p class="label">{{ 'common.short_url' | translate }}</p>
<input
pInputText
class="value"
type="text"
formControlName="shortUrl"/>
</div>
<div class="form-page-input">
<p class="label">{{ 'common.target_url' | translate }}</p>
<input
pInputText
class="value"
type="text"
formControlName="targetUrl"/>
</div>
<div class="form-page-input">
<p class="label">{{ 'common.description' | translate }}</p>
<input
pInputText
class="value"
type="text"
formControlName="description"/>
</div>
<div class="form-page-input">
<p class="label">{{ 'common.group' | translate }}</p>
<p-dropdown
class="value"
[options]="groups"
formControlName="groupId"
[showClear]="true"
[filter]="true"
filterBy="name"
optionLabel="name"
optionValue="id"
></p-dropdown>
</div>
</ng-template>
</app-form-page>

View File

@ -0,0 +1,50 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RoleFormPageComponent } from 'src/app/modules/admin/administration/roles/form-page/role-form-page.component';
import { SharedModule } from 'src/app/modules/shared/shared.module';
import { TranslateModule } from '@ngx-translate/core';
import { AuthService } from 'src/app/service/auth.service';
import { ErrorHandlingService } from 'src/app/service/error-handling.service';
import { ToastService } from 'src/app/service/toast.service';
import { ConfirmationService, MessageService } from 'primeng/api';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
import { ApiKeysDataService } from 'src/app/modules/admin/administration/api-keys/api-keys.data.service';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
describe('ApiKeyFormpageComponent', () => {
let component: RoleFormPageComponent;
let fixture: ComponentFixture<RoleFormPageComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [RoleFormPageComponent],
imports: [
BrowserAnimationsModule,
SharedModule,
TranslateModule.forRoot(),
],
providers: [
AuthService,
ErrorHandlingService,
ToastService,
MessageService,
ConfirmationService,
{
provide: ActivatedRoute,
useValue: {
snapshot: { params: of({}) },
},
},
ApiKeysDataService,
],
}).compileComponents();
fixture = TestBed.createComponent(RoleFormPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,130 @@
import { Component } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { ToastService } from "src/app/service/toast.service";
import { FormPageBase } from "src/app/core/base/form-page-base";
import {
ShortUrl,
ShortUrlCreateInput,
ShortUrlUpdateInput,
} from "src/app/model/entities/short-url";
import { ShortUrlsDataService } from "src/app/modules/admin/short-urls/short-urls.data.service";
import { Group } from "src/app/model/entities/group";
@Component({
selector: "app-short-url-form-page",
templateUrl: "./short-url-form-page.component.html",
styleUrl: "./short-url-form-page.component.scss",
})
export class ShortUrlFormPageComponent extends FormPageBase<
ShortUrl,
ShortUrlCreateInput,
ShortUrlUpdateInput,
ShortUrlsDataService
> {
groups: Group[] = [];
constructor(private toast: ToastService) {
super();
this.dataService.getAllGroups().subscribe((groups) => {
this.groups = groups;
});
if (!this.nodeId) {
this.node = this.new();
this.setForm(this.node);
return;
}
this.dataService
.load([{ id: { equal: this.nodeId } }])
.subscribe((apiKey) => {
this.node = apiKey.nodes[0];
this.setForm(this.node);
});
}
new(): ShortUrl {
return {} as ShortUrl;
}
buildForm() {
this.form = new FormGroup({
id: new FormControl<number | undefined>(undefined),
shortUrl: new FormControl<string | undefined>(
undefined,
Validators.required,
),
targetUrl: new FormControl<string | undefined>(
undefined,
Validators.required,
),
description: new FormControl<string | undefined>(undefined),
groupId: new FormControl<number | undefined>(undefined),
});
this.form.controls["id"].disable();
}
setForm(node?: ShortUrl) {
this.form.controls["id"].setValue(node?.id);
this.form.controls["shortUrl"].setValue(node?.shortUrl);
this.form.controls["targetUrl"].setValue(node?.targetUrl);
this.form.controls["description"].setValue(node?.description);
this.form.controls["groupId"].setValue(node?.group?.id);
}
getCreateInput(): ShortUrlCreateInput {
return {
shortUrl: this.form.controls["shortUrl"].pristine
? undefined
: (this.form.controls["shortUrl"].value ?? undefined),
targetUrl: this.form.controls["targetUrl"].pristine
? undefined
: (this.form.controls["targetUrl"].value ?? undefined),
description: this.form.controls["description"].pristine
? undefined
: (this.form.controls["description"].value ?? undefined),
groupId: this.form.controls["groupId"].pristine
? undefined
: (this.form.controls["groupId"].value ?? undefined),
};
}
getUpdateInput(): ShortUrlUpdateInput {
if (!this.node?.id) {
throw new Error("Node id is missing");
}
return {
id: this.form.controls["id"].value,
shortUrl: this.form.controls["shortUrl"].pristine
? undefined
: (this.form.controls["shortUrl"].value ?? undefined),
targetUrl: this.form.controls["targetUrl"].pristine
? undefined
: (this.form.controls["targetUrl"].value ?? undefined),
description: this.form.controls["description"].pristine
? undefined
: (this.form.controls["description"].value ?? undefined),
groupId: this.form.controls["groupId"].pristine
? undefined
: (this.form.controls["groupId"].value ?? undefined),
};
}
create(apiKey: ShortUrlCreateInput): void {
this.dataService.create(apiKey).subscribe(() => {
this.spinner.hide();
this.toast.success("action.created");
this.close();
});
}
update(apiKey: ShortUrlUpdateInput): void {
this.dataService.update(apiKey).subscribe(() => {
this.spinner.hide();
this.toast.success("action.created");
this.close();
});
}
}

View File

@ -0,0 +1,56 @@
import { Injectable, Provider } from "@angular/core";
import {
DB_MODEL_COLUMNS,
ID_COLUMN,
PageColumns,
} from "src/app/core/base/page.columns";
import { TableColumn } from "src/app/modules/shared/components/table/table.model";
import { ShortUrl } from "src/app/model/entities/short-url";
@Injectable()
export class ShortUrlsColumns extends PageColumns<ShortUrl> {
get(): TableColumn<ShortUrl>[] {
return [
ID_COLUMN,
{
name: "short_url",
label: "common.short_url",
type: "text",
filterable: true,
value: (row: ShortUrl) => row.shortUrl,
},
{
name: "target_url",
label: "common.target_url",
type: "text",
filterable: true,
value: (row: ShortUrl) => row.targetUrl,
},
{
name: "description",
label: "common.description",
type: "text",
filterable: true,
value: (row: ShortUrl) => row.description,
},
{
name: "group",
label: "common.group",
type: "text",
filterable: true,
value: (row: ShortUrl) => row.group?.name,
},
...DB_MODEL_COLUMNS,
];
}
static provide(): Provider[] {
return [
{
provide: PageColumns,
useClass: ShortUrlsColumns,
},
ShortUrlsColumns,
];
}
}

View File

@ -0,0 +1,274 @@
import { Injectable, Provider } from "@angular/core";
import { Observable } from "rxjs";
import {
Create,
Delete,
PageDataService,
Restore,
Update,
} from "src/app/core/base/page.data.service";
import { Filter } from "src/app/model/graphql/filter/filter.model";
import { Sort } from "src/app/model/graphql/filter/sort.model";
import { Apollo, gql } from "apollo-angular";
import { QueryResult } from "src/app/model/entities/query-result";
import { DB_MODEL_FRAGMENT } from "src/app/model/graphql/db-model.query";
import { catchError, map } from "rxjs/operators";
import { SpinnerService } from "src/app/service/spinner.service";
import {
ShortUrl,
ShortUrlCreateInput,
ShortUrlUpdateInput,
} from "src/app/model/entities/short-url";
import { Group } from "src/app/model/entities/group";
@Injectable()
export class ShortUrlsDataService
extends PageDataService<ShortUrl>
implements
Create<ShortUrl, ShortUrlCreateInput>,
Update<ShortUrl, ShortUrlUpdateInput>,
Delete<ShortUrl>,
Restore<ShortUrl>
{
constructor(
private spinner: SpinnerService,
private apollo: Apollo,
) {
super();
}
load(
filter?: Filter[] | undefined,
sort?: Sort[] | undefined,
skip?: number | undefined,
take?: number | undefined,
): Observable<QueryResult<ShortUrl>> {
return this.apollo
.query<{ shortUrls: QueryResult<ShortUrl> }>({
query: gql`
query getShortUrls(
$filter: [ShortUrlFilter]
$sort: [ShortUrlSort]
$skip: Int
$take: Int
) {
shortUrls(filter: $filter, sort: $sort, skip: $skip, take: $take) {
count
totalCount
nodes {
id
shortUrl
targetUrl
description
group {
id
name
}
...DB_MODEL
}
}
}
${DB_MODEL_FRAGMENT}
`,
variables: {
filter: filter,
sort: sort,
skip: skip,
take: take,
},
})
.pipe(
catchError((err) => {
this.spinner.hide();
throw err;
}),
)
.pipe(map((result) => result.data.shortUrls));
}
loadById(id: number): Observable<ShortUrl> {
return this.apollo
.query<{ shortUrls: QueryResult<ShortUrl> }>({
query: gql`
query getShortUrl($id: Int) {
shortUrl(filter: { id: { equal: $id } }) {
id
shortUrl
targetUrl
description
group {
id
name
}
...DB_MODEL
}
}
${DB_MODEL_FRAGMENT}
`,
variables: {
id: id,
},
})
.pipe(
catchError((err) => {
this.spinner.hide();
throw err;
}),
)
.pipe(map((result) => result.data.shortUrls.nodes[0]));
}
create(object: ShortUrlCreateInput): Observable<ShortUrl | undefined> {
return this.apollo
.mutate<{ shortUrl: { create: ShortUrl } }>({
mutation: gql`
mutation createShortUrl($input: ShortUrlCreateInput!) {
shortUrl {
create(input: $input) {
id
description
...DB_MODEL
}
}
}
${DB_MODEL_FRAGMENT}
`,
variables: {
input: {
shortUrl: object.shortUrl,
targetUrl: object.targetUrl,
description: object.description,
groupId: object.groupId,
},
},
})
.pipe(
catchError((err) => {
this.spinner.hide();
throw err;
}),
)
.pipe(map((result) => result.data?.shortUrl.create));
}
update(object: ShortUrlUpdateInput): Observable<ShortUrl | undefined> {
return this.apollo
.mutate<{ shortUrl: { update: ShortUrl } }>({
mutation: gql`
mutation updateShortUrl($input: ShortUrlUpdateInput!) {
shortUrl {
update(input: $input) {
id
description
...DB_MODEL
}
}
}
${DB_MODEL_FRAGMENT}
`,
variables: {
input: {
id: object.id,
shortUrl: object.shortUrl,
targetUrl: object.targetUrl,
description: object.description,
groupId: object.groupId,
},
},
})
.pipe(
catchError((err) => {
this.spinner.hide();
throw err;
}),
)
.pipe(map((result) => result.data?.shortUrl.update));
}
delete(object: ShortUrl): Observable<boolean> {
return this.apollo
.mutate<{ shortUrl: { delete: boolean } }>({
mutation: gql`
mutation deleteShortUrl($id: ID!) {
shortUrl {
delete(id: $id)
}
}
`,
variables: {
id: object.id,
},
})
.pipe(
catchError((err) => {
this.spinner.hide();
throw err;
}),
)
.pipe(map((result) => result.data?.shortUrl.delete ?? false));
}
restore(object: ShortUrl): Observable<boolean> {
return this.apollo
.mutate<{ shortUrl: { restore: boolean } }>({
mutation: gql`
mutation restoreShortUrl($id: ID!) {
shortUrl {
restore(id: $id)
}
}
`,
variables: {
id: object.id,
},
})
.pipe(
catchError((err) => {
this.spinner.hide();
throw err;
}),
)
.pipe(map((result) => result.data?.shortUrl.restore ?? false));
}
getAllGroups() {
return this.apollo
.query<{ groups: QueryResult<Group> }>({
query: gql`
query getGroups {
groups {
nodes {
id
name
}
}
}
`,
})
.pipe(
catchError((err) => {
this.spinner.hide();
throw err;
}),
)
.pipe(map((result) => result.data.groups.nodes));
}
static provide(): Provider[] {
return [
{
provide: PageDataService,
useClass: ShortUrlsDataService,
},
ShortUrlsDataService,
];
}
}

View File

@ -0,0 +1,43 @@
import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { SharedModule } from "src/app/modules/shared/shared.module";
import { RouterModule, Routes } from "@angular/router";
import { PermissionGuard } from "src/app/core/guard/permission.guard";
import { PermissionsEnum } from "src/app/model/auth/permissionsEnum";
import { ShortUrlsPage } from "src/app/modules/admin/short-urls/short-urls.page";
import { ShortUrlFormPageComponent } from "src/app/modules/admin/short-urls/form-page/short-url-form-page.component";
import { ShortUrlsDataService } from "src/app/modules/admin/short-urls/short-urls.data.service";
import { ShortUrlsColumns } from "src/app/modules/admin/short-urls/short-urls.columns";
const routes: Routes = [
{
path: "",
title: "ShortUrls",
component: ShortUrlsPage,
children: [
{
path: "create",
component: ShortUrlFormPageComponent,
canActivate: [PermissionGuard],
data: {
permissions: [PermissionsEnum.apiKeysCreate],
},
},
{
path: "edit/:id",
component: ShortUrlFormPageComponent,
canActivate: [PermissionGuard],
data: {
permissions: [PermissionsEnum.apiKeysUpdate],
},
},
],
},
];
@NgModule({
declarations: [ShortUrlsPage, ShortUrlFormPageComponent],
imports: [CommonModule, SharedModule, RouterModule.forChild(routes)],
providers: [ShortUrlsDataService.provide(), ShortUrlsColumns.provide()],
})
export class ShortUrlsModule {}

View File

@ -0,0 +1,19 @@
<app-table
[rows]="result.nodes"
[columns]="columns"
[rowsPerPageOptions]="rowsPerPageOptions"
[totalCount]="result.totalCount"
[requireAnyPermissions]="requiredPermissions"
countHeaderTranslation="group.count_header"
[loading]="loading"
[(filter)]="filter"
[(sort)]="sort"
[(skip)]="skip"
[(take)]="take"
(load)="load()"
[create]="true"
[update]="true"
(delete)="delete($event)"
(restore)="restore($event)"></app-table>
<router-outlet></router-outlet>

View File

@ -0,0 +1,51 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ApiKeysPage } from 'src/app/modules/admin/administration/api-keys/api-keys.page';
import { SharedModule } from 'src/app/modules/shared/shared.module';
import { TranslateModule } from '@ngx-translate/core';
import { AuthService } from 'src/app/service/auth.service';
import { KeycloakService } from 'keycloak-angular';
import { ErrorHandlingService } from 'src/app/service/error-handling.service';
import { ToastService } from 'src/app/service/toast.service';
import { ConfirmationService, MessageService } from 'primeng/api';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
import { PageDataService } from 'src/app/core/base/page.data.service';
import { ApiKeysDataService } from 'src/app/modules/admin/administration/api-keys/api-keys.data.service';
describe('ApiKeysComponent', () => {
let component: ApiKeysPage;
let fixture: ComponentFixture<ApiKeysPage>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ApiKeysPage],
imports: [SharedModule, TranslateModule.forRoot()],
providers: [
AuthService,
KeycloakService,
ErrorHandlingService,
ToastService,
MessageService,
ConfirmationService,
{
provide: ActivatedRoute,
useValue: {
snapshot: { params: of({}) },
},
},
{
provide: PageDataService,
useClass: ApiKeysDataService,
},
],
}).compileComponents();
fixture = TestBed.createComponent(ApiKeysPage);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,72 @@
import { Component } from "@angular/core";
import { PageBase } from "src/app/core/base/page-base";
import { ToastService } from "src/app/service/toast.service";
import { ConfirmationDialogService } from "src/app/service/confirmation-dialog.service";
import { PermissionsEnum } from "src/app/model/auth/permissionsEnum";
import { ShortUrl } from "src/app/model/entities/short-url";
import { ShortUrlsDataService } from "src/app/modules/admin/short-urls/short-urls.data.service";
import { ShortUrlsColumns } from "src/app/modules/admin/short-urls/short-urls.columns";
@Component({
selector: "app-short-urls",
templateUrl: "./short-urls.page.html",
styleUrl: "./short-urls.page.scss",
})
export class ShortUrlsPage extends PageBase<
ShortUrl,
ShortUrlsDataService,
ShortUrlsColumns
> {
constructor(
private toast: ToastService,
private confirmation: ConfirmationDialogService,
) {
super(true, {
read: [PermissionsEnum.shortUrls],
create: [PermissionsEnum.shortUrlsCreate],
update: [PermissionsEnum.shortUrlsUpdate],
delete: [PermissionsEnum.shortUrlsDelete],
restore: [PermissionsEnum.shortUrlsDelete],
});
}
load(): void {
this.loading = true;
this.dataService
.load(this.filter, this.sort, this.skip, this.take)
.subscribe((result) => {
this.result = result;
this.loading = false;
});
}
delete(group: ShortUrl): void {
this.confirmation.confirmDialog({
header: "dialog.delete.header",
message: "dialog.delete.message",
accept: () => {
this.loading = true;
this.dataService.delete(group).subscribe(() => {
this.toast.success("action.deleted");
this.load();
});
},
messageParams: { entity: group.shortUrl },
});
}
restore(group: ShortUrl): void {
this.confirmation.confirmDialog({
header: "dialog.restore.header",
message: "dialog.restore.message",
accept: () => {
this.loading = true;
this.dataService.restore(group).subscribe(() => {
this.toast.success("action.restored");
this.load();
});
},
messageParams: { entity: group.shortUrl },
});
}
}

View File

@ -56,10 +56,18 @@ body {
} }
input, input,
.p-checkbox-box { .p-checkbox-box,
.p-dropdown {
border: 1px solid $accentColor; border: 1px solid $accentColor;
padding: 10px; padding: 10px;
} }
.p-dropdown {
width: 100%;
span {
padding: 0;
}
}
} }
@layer utilities { @layer utilities {
@ -216,19 +224,22 @@ footer {
gap: 15px; gap: 15px;
.form-page-input { .form-page-input {
display: flex; display: grid;
align-items: center; grid-template-columns: 1fr 2fr;
gap: 5px; gap: 5px;
.label { .label {
flex: 1; display: flex;
align-items: center;
grid-column: 1;
} }
.value { .value {
flex: 2; grid-column: 2;
} }
} }
.form-page-section { .form-page-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;