Skip to content

Commit

Permalink
feat: Schema Editor (#24)
Browse files Browse the repository at this point in the history
* feat: Add schema editor module

* feat: Add schema editor selection

* feat: Add schema editor component

* feat: Preview and reorder schema sections

* feat: Add, edit and remove fields

* feat: Reorder fields

* fix: Don't allow reordering or deleting section while expanded

And expand when adding a field.

* feat: Add schema section

* wip: Edit schema section page

* feat: Edit schema name

* fix: More consistent Add buttons

* feat: Add field editor

* feat: Change field validations to use if

* feat: Edit field validation

* feat: Edit subfields

* fix(schema-editor): Default input type for text and number

* fix(forms): Properly reset dependent fields

* fix: Adjust endpoints for schemas

* refactor: Add SchemaService and generic endpoint

* feat: Add schema edit endpoints

* feat: Implement schema section create

* feat: Implement schema section delete

* feat: Implement save field

* fix(forms): Don't touch localStorage if formId is unset

* feat(schema-editor): Save sections

* feat(schema-editor): Edit section conditions

* feat(schema-editor): Schema condition syntax check

* feat(forms): Minor fixes

- Forms no longer initialize subelements whose dependentKeyValue is not active
- Subelements with the same key no longer create Angular warnings

* feat(forms): Add new gridSize property

* feat(schema-editor): Clearly mark unsaved sections

* fix(schema-editor): Wait for load before editing field or section

* feat(schema-editor): Edit section copy buttons

* feat(schema-editor): Edit copy button mappings in offcanvas

* feat(schema-editor): Autocomplete for new copy field mapping

* fix(schema-editor): Don't disable field key

* fix(schema-editor): Disable section reorder

* fix(schema-editor): Better copy button label placeholder

* fix(schema-editor): Move add buttons into headers

* fix(forms): Mark section dirty when changing fields

* fix(schema-editor): Respect field type for subfield dependentKeyValue

* fix(schema-editor): Comma-separated values field

* fix(forms): Disabled property for radio fields

* feat(schema-editor): Show parent section when editing fields

* feat(schema-editor): Change layout of special editors to read like a sentence

* fix(schema-editor): Prevent dragging forms
  • Loading branch information
Clashsoft authored Dec 2, 2024
1 parent d534a2b commit 13a384b
Show file tree
Hide file tree
Showing 40 changed files with 1,398 additions and 129 deletions.
5 changes: 5 additions & 0 deletions src/app/app-routing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ const routes: Routes = [
path: 'auth',
loadChildren: () => import('./auth/auth.module').then((m) => m.AuthModule),
},
{
path: 'schema-editor',
loadChildren: () => import('./schema-editor/schema-editor.module').then((m) => m.SchemaEditorModule),
canActivate: [AuthGuard],
},
{path: '', pathMatch: 'full', redirectTo: '/auth/login'},
{path: '**', component: PageNotFoundComponent},
];
Expand Down
6 changes: 4 additions & 2 deletions src/app/audit/clean-energy-hub/clean-energy-hub.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {ToastService} from '@mean-stream/ngbx';
import {switchMap, tap} from 'rxjs';
import {environment} from '../../../environments/environment';
import {AuthService} from '../../shared/services/auth.service';
import {SchemaService} from '../../shared/services/schema.service';

@Component({
selector: 'app-clean-energy-hub',
Expand All @@ -26,15 +27,16 @@ export class CleanEnergyHubComponent implements OnInit {
constructor(
private route: ActivatedRoute,
private auditService: AuditService,
private schemaService: SchemaService,
private toastService: ToastService,
authService: AuthService,
) {
this.authToken = authService.getAuthToken() ?? '';
}

ngOnInit() {
this.auditService.getCleanEnergyHubJsonSchema().subscribe((schema: any) => {
this.typeSchema = schema;
this.schemaService.getSchema('ceh').subscribe(({data}) => {
this.typeSchema = data;
});
this.route.params.pipe(
tap(({aid}) => this.auditId = +aid),
Expand Down
4 changes: 3 additions & 1 deletion src/app/audit/grants/grants.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {ActivatedRoute} from '@angular/router';
import {AuditService} from '../../shared/services/audit.service';
import {ToastService} from '@mean-stream/ngbx';
import {switchMap, tap} from 'rxjs';
import {SchemaService} from '../../shared/services/schema.service';

@Component({
selector: 'app-grants',
Expand All @@ -21,12 +22,13 @@ export class GrantsComponent implements OnInit {
constructor(
private route: ActivatedRoute,
private auditService: AuditService,
private schemaService: SchemaService,
private toastService: ToastService,
) {
}

ngOnInit() {
this.auditService.getGrantsJsonSchema().subscribe(schema => this.typeSchema = schema);
this.schemaService.getSchema('grants').subscribe(({data}) => this.typeSchema = data);
this.route.params.pipe(
tap(({aid}) => this.auditId = +aid),
switchMap(({aid}) => this.auditService.getGrantsData(aid)),
Expand Down
4 changes: 3 additions & 1 deletion src/app/audit/preaudit-form/preaudit-form.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {AuditService} from '../../shared/services/audit.service';
import {SchemaSection} from '../../shared/model/schema.interface';
import {switchMap, tap} from 'rxjs';
import {ToastService} from '@mean-stream/ngbx';
import {SchemaService} from '../../shared/services/schema.service';

@Component({
selector: 'app-preaudit-form',
Expand All @@ -21,12 +22,13 @@ export class PreauditFormComponent implements OnInit {
constructor(
private route: ActivatedRoute,
private auditService: AuditService,
private schemaService: SchemaService,
private toastService: ToastService,
) {
}

ngOnInit() {
this.auditService.getPreAuditJsonSchema().subscribe(res => this.typeSchema = res.data);
this.schemaService.getSchema('preAudit').subscribe(res => this.typeSchema = res.data);

this.route.params.pipe(
tap(({aid}) => this.auditId = +aid),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export class CreateEquipmentComponent implements OnInit {

ngOnInit() {
this.route.params.pipe(
switchMap(({eid}) => this.equipmentService.getEquipmentType(eid)),
switchMap(({eid}) => this.equipmentService.getEquipmentTypes(eid)),
).subscribe(res => {
this.types = res.data;
if (this.types.length === 1) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {PercentageCompletion} from '../../shared/model/percentage-completion.int
import {SchemaSection} from '../../shared/model/schema.interface';
import {ZoneData} from '../../shared/model/zone.interface';
import {Equipment} from '../../shared/model/equipment.interface';
import {SchemaService} from '../../shared/services/schema.service';


@Component({
Expand All @@ -26,6 +27,7 @@ export class EquipmentDetailComponent implements OnInit {
constructor(
public equipmentService: EquipmentService,
public auditService: AuditService,
private schemaService: SchemaService,
private route: ActivatedRoute,
private toastService: ToastService,
) { }
Expand All @@ -34,9 +36,9 @@ export class EquipmentDetailComponent implements OnInit {
this.route.params.pipe(
switchMap(({tid}) => this.equipmentService.getEquipment(+tid)),
tap(equipment => this.equipment = equipment),
switchMap(equipment => this.equipmentService.getEquipmentTypeSchema(equipment.type?.id ?? equipment.typeId)),
).subscribe(schema => {
this.typeSchema = schema;
switchMap(equipment => this.schemaService.getSchema(`equipment/${equipment.type?.id ?? equipment.typeId}`)),
).subscribe(({data}) => {
this.typeSchema = data;
});

this.route.params.pipe(
Expand Down
8 changes: 8 additions & 0 deletions src/app/nav-bar/nav-bar.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ <h4 class="mb-0 fw-semibold icon-conserve">
Home
</a>
}
@if (authService.currentLoginUser?.role?.role === 'superAdmin') {
<h6 class="nav-item navbar-text text-muted fw-light">
Superadmin Tools
</h6>
<a class="nav-item nav-link bi-ui-checks" routerLinkActive="active" routerLink="/schema-editor">
Schema Editor
</a>
}
<div class="flex-grow-1"></div>
<div ngbDropdown class="d-inline-block">
<button class="nav-link bi-moon-stars" ngbDropdownToggle>
Expand Down
140 changes: 140 additions & 0 deletions src/app/schema-editor/edit-field/edit-field.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<div class="container">
<div class="h3 text-muted bi-card-list">
{{ section?.name }}
</div>
<h1>
Edit Field: {{ field.title }}
</h1>
<h2>
Basic Information
</h2>
<app-form [typeSchema]="metaSchema" [formData]="{data: field}" (saved)="save()" [dirty]="!!section?._dirty" (dirtyChange)="setDirty($event)"></app-form>
<h2 class="d-flex align-items-center">
Validation
<i class="small bi-info-circle ms-2" ngbTooltip="About Validation" [ngbPopover]="popup" popoverTitle="About Validation"></i>
<button class="btn btn-success bi-plus-lg ms-auto" (click)="addValidation()">
Add Validation
</button>
</h2>
<ng-template #popup>
<dl>
<dt>Message</dt>
<dd>
Validation messages are displayed when the condition is met.
</dd>
<dt>Severity</dt>
<dd>
The severity (error or warning) determines how the color of the message and whether the form is considered valid.
</dd>
<dt>Conditions</dt>
<dd>
Conditions are written in Jexl, a simple expression language.
You can refer to any field in the form by its key.
<br>
<a class="bi-info-circle me-2" target="_blank" href="https://github.com/TomFrost/Jexl#all-the-details">
Learn more
</a>
</dd>
<dt>
Example
</dt>
<dd>
To display an error message when a field named <code>year</code> is before 1900:
<ul>
<li>Severity: Error</li>
<li>Condition: <code>year < 1900</code></li>
<li>Message: Year must be after 1900</li>
</ul>
</dl>
</ng-template>
<div class="list-group mb-3">
@for (validation of field.validations; track validation) {
<div class="list-group-item">
<div class="row g-2 align-items-center">
<div class="col-auto">
When
</div>
<div class="col">
<label class="visually-hidden">Condition</label>
<input class="form-control font-monospace" [(ngModel)]="validation['if']" ngbTooltip="The condition that must be met for the validation to be triggered."/>
</div>
<div class="col-auto">
then display
</div>
<div class="col-auto">
<label class="visually-hidden">Severity</label>
<select class="form-select" ngbTooltip="The severity of the validation" [(ngModel)]="validation.level">
<option value="error">Error</option>
<option value="warning">Warning</option>
</select>
</div>
<div class="col-auto">
with message
</div>
<div class="col">
<label class="visually-hidden">Message</label>
<input class="form-control" [(ngModel)]="validation.message" ngbTooltip="The message to display if the validation is triggered."/>
</div>
<div class="col-auto">
<button class="btn btn-danger bi-trash" ngbTooltip="Remove Validation" (click)="removeValidation($index)"></button>
</div>
</div>
</div>
} @empty {
<div class="list-group-item text-muted">
No validations.
</div>
}
</div>
<h2 class="d-flex align-items-center">
Subfields
<button class="btn btn-success bi-plus-lg ms-auto" (click)="addSubfield()">
Add Subfield
</button>
</h2>
<div class="list-group mb-3" cdkDropList (cdkDropListDropped)="dropSubfield($event)">
@for (subField of field.inputList; track subField) {
<div class="list-group-item" cdkDrag>
<div class="row g-2 align-items-center">
<div class="col-auto">
When <span class="font-monospace text-info">{{ field.key }}</span> equals
</div>
<div class="col">
<label class="visually-hidden">Dependent Value</label>
@if (field.dataType === 'bool') {
<select class="form-select" [(ngModel)]="subField.dependentKeyValue" ngbTooltip="The value of '{{ field.key }}' that triggers this subfield">
<option [value]="true">True</option>
<option [value]="false">False</option>
</select>
} @else {
<input class="form-control font-monospace" [placeholder]="field.dataType" [type]="field.dataType" [(ngModel)]="subField.dependentKeyValue" ngbTooltip="The value of '{{ field.key }}' that triggers this subfield"/>
}
</div>
<div class="col-auto">
show field with key
</div>
<div class="col">
<label class="visually-hidden">Key</label>
<input class="form-control font-monospace" placeholder="Key" [(ngModel)]="subField.key" [ngbTooltip]="'The key of this subfield'"/>
</div>
<div class="col-auto">
titled
</div>
<div class="col">
<label class="visually-hidden">Title</label>
<input class="form-control" placeholder="Title" [(ngModel)]="subField.title" [ngbTooltip]="'The title of this subfield'"/>
</div>
<div class="col-auto">
<a class="btn btn-primary bi-pencil me-2" ngbTooltip="Edit Subfield" [routerLink]="['..', subField.key]"></a>
<div class="btn btn-secondary bi-grip-vertical me-2" cdkDragHandle ngbTooltip="Drag to Reorder"></div>
<button class="btn btn-danger bi-trash" ngbTooltip="Remove Subfield" (click)="removeSubfield($index)"></button>
</div>
</div>
</div>
} @empty {
<div class="list-group-item text-muted">
No subfields.
</div>
}
</div>
</div>
Empty file.
23 changes: 23 additions & 0 deletions src/app/schema-editor/edit-field/edit-field.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { EditFieldComponent } from './edit-field.component';

describe('EditFieldComponent', () => {
let component: EditFieldComponent;
let fixture: ComponentFixture<EditFieldComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EditFieldComponent]
})
.compileComponents();

fixture = TestBed.createComponent(EditFieldComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});
Loading

0 comments on commit 13a384b

Please sign in to comment.