diff --git a/api/src/api_graphql/graphql/short_url.gql b/api/src/api_graphql/graphql/short_url.gql index 48a5da3..5e120eb 100644 --- a/api/src/api_graphql/graphql/short_url.gql +++ b/api/src/api_graphql/graphql/short_url.gql @@ -52,7 +52,7 @@ input ShortUrlCreateInput { shortUrl: String! targetUrl: String! description: String - group: ID + groupId: ID } input ShortUrlUpdateInput { @@ -60,5 +60,5 @@ input ShortUrlUpdateInput { shortUrl: String targetUrl: String description: String - group: ID + groupId: ID } diff --git a/api/src/api_graphql/mutation.py b/api/src/api_graphql/mutation.py index 77b0f61..c745ca5 100644 --- a/api/src/api_graphql/mutation.py +++ b/api/src/api_graphql/mutation.py @@ -42,3 +42,12 @@ class Mutation(MutationABC): Permissions.groups_delete, ], ) + self.add_mutation_type( + "shortUrl", + "ShortUrl", + require_any_permission=[ + Permissions.short_urls_create, + Permissions.short_urls_update, + Permissions.short_urls_delete, + ], + ) diff --git a/api/src/api_graphql/query.py b/api/src/api_graphql/query.py index 2fe9716..ca9506c 100644 --- a/api/src/api_graphql/query.py +++ b/api/src/api_graphql/query.py @@ -85,7 +85,7 @@ class Query(QueryABC): .with_dao(groupDao) .with_filter(GroupFilter) .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 self.field( diff --git a/web/src/app/model/entities/short-url.ts b/web/src/app/model/entities/short-url.ts new file mode 100644 index 0000000..6eef085 --- /dev/null +++ b/web/src/app/model/entities/short-url.ts @@ -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; +} diff --git a/web/src/app/modules/admin/admin.module.ts b/web/src/app/modules/admin/admin.module.ts index 43e0f17..0c70f8f 100644 --- a/web/src/app/modules/admin/admin.module.ts +++ b/web/src/app/modules/admin/admin.module.ts @@ -11,6 +11,13 @@ const routes: Routes = [ (m) => m.GroupsModule, ), }, + { + path: "urls", + loadChildren: () => + import("src/app/modules/admin/short-urls/short-urls.module").then( + (m) => m.ShortUrlsModule, + ), + }, { path: "administration", loadChildren: () => diff --git a/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.html b/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.html new file mode 100644 index 0000000..3652646 --- /dev/null +++ b/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.html @@ -0,0 +1,60 @@ + + +

+ {{ 'common.group' | translate }} + {{ + (isUpdate ? 'sidebar.header.update' : 'sidebar.header.create') + | translate + }} +

+
+ + +
+

{{ 'common.id' | translate }}

+ +
+
+

{{ 'common.short_url' | translate }}

+ +
+
+

{{ 'common.target_url' | translate }}

+ +
+
+

{{ 'common.description' | translate }}

+ +
+
+

{{ 'common.group' | translate }}

+ +
+
+
diff --git a/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.scss b/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.spec.ts b/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.spec.ts new file mode 100644 index 0000000..a4a6c84 --- /dev/null +++ b/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.spec.ts @@ -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; + + 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(); + }); +}); diff --git a/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.ts b/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.ts new file mode 100644 index 0000000..9bc7d87 --- /dev/null +++ b/web/src/app/modules/admin/short-urls/form-page/short-url-form-page.component.ts @@ -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(undefined), + shortUrl: new FormControl( + undefined, + Validators.required, + ), + targetUrl: new FormControl( + undefined, + Validators.required, + ), + description: new FormControl(undefined), + groupId: new FormControl(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(); + }); + } +} diff --git a/web/src/app/modules/admin/short-urls/short-urls.columns.ts b/web/src/app/modules/admin/short-urls/short-urls.columns.ts new file mode 100644 index 0000000..6852d25 --- /dev/null +++ b/web/src/app/modules/admin/short-urls/short-urls.columns.ts @@ -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 { + get(): TableColumn[] { + 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, + ]; + } +} diff --git a/web/src/app/modules/admin/short-urls/short-urls.data.service.ts b/web/src/app/modules/admin/short-urls/short-urls.data.service.ts new file mode 100644 index 0000000..d7e47a5 --- /dev/null +++ b/web/src/app/modules/admin/short-urls/short-urls.data.service.ts @@ -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 + implements + Create, + Update, + Delete, + Restore +{ + constructor( + private spinner: SpinnerService, + private apollo: Apollo, + ) { + super(); + } + + load( + filter?: Filter[] | undefined, + sort?: Sort[] | undefined, + skip?: number | undefined, + take?: number | undefined, + ): Observable> { + return this.apollo + .query<{ shortUrls: QueryResult }>({ + 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 { + return this.apollo + .query<{ shortUrls: QueryResult }>({ + 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 { + 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 { + 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 { + 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 { + 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 }>({ + 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, + ]; + } +} diff --git a/web/src/app/modules/admin/short-urls/short-urls.module.ts b/web/src/app/modules/admin/short-urls/short-urls.module.ts new file mode 100644 index 0000000..d095c69 --- /dev/null +++ b/web/src/app/modules/admin/short-urls/short-urls.module.ts @@ -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 {} diff --git a/web/src/app/modules/admin/short-urls/short-urls.page.html b/web/src/app/modules/admin/short-urls/short-urls.page.html new file mode 100644 index 0000000..d9a1c72 --- /dev/null +++ b/web/src/app/modules/admin/short-urls/short-urls.page.html @@ -0,0 +1,19 @@ + + + diff --git a/web/src/app/modules/admin/short-urls/short-urls.page.scss b/web/src/app/modules/admin/short-urls/short-urls.page.scss new file mode 100644 index 0000000..e69de29 diff --git a/web/src/app/modules/admin/short-urls/short-urls.page.spec.ts b/web/src/app/modules/admin/short-urls/short-urls.page.spec.ts new file mode 100644 index 0000000..98eb39d --- /dev/null +++ b/web/src/app/modules/admin/short-urls/short-urls.page.spec.ts @@ -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; + + 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(); + }); +}); diff --git a/web/src/app/modules/admin/short-urls/short-urls.page.ts b/web/src/app/modules/admin/short-urls/short-urls.page.ts new file mode 100644 index 0000000..dff86d7 --- /dev/null +++ b/web/src/app/modules/admin/short-urls/short-urls.page.ts @@ -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 }, + }); + } +} diff --git a/web/src/styles.scss b/web/src/styles.scss index 2f9dad5..c823e30 100644 --- a/web/src/styles.scss +++ b/web/src/styles.scss @@ -56,10 +56,18 @@ body { } input, - .p-checkbox-box { + .p-checkbox-box, + .p-dropdown { border: 1px solid $accentColor; padding: 10px; } + + .p-dropdown { + width: 100%; + span { + padding: 0; + } + } } @layer utilities { @@ -216,19 +224,22 @@ footer { gap: 15px; .form-page-input { - display: flex; - align-items: center; + display: grid; + grid-template-columns: 1fr 2fr; gap: 5px; .label { - flex: 1; + display: flex; + align-items: center; + grid-column: 1; } .value { - flex: 2; + grid-column: 2; } } + .form-page-section { display: flex; flex-direction: column;