Add configuration editor for messages, monaco editor to string type

This commit is contained in:
2024-07-18 22:07:16 +09:30
parent 8517f99438
commit b78d4aea69
15 changed files with 479 additions and 19 deletions

View File

@@ -17,11 +17,14 @@ import {
MapMessage, MapMessage,
MessageConfiguration, MessageConfiguration,
MessageTypeEnum, MessageTypeEnum,
NumericMessage,
ObjectMessage, ObjectMessage,
StringMessage,
} from '../../model/proto-message.model'; } from '../../model/proto-message.model';
import { ListFieldComponent } from '../list-field/list-field.component'; import { ListFieldComponent } from '../list-field/list-field.component';
import { MapFieldComponent } from '../map-field/map-field.component'; import { MapFieldComponent } from '../map-field/map-field.component';
import { ObjectFieldComponent } from '../object-field/object-field.component'; import { ObjectFieldComponent } from '../object-field/object-field.component';
import { StringFieldComponent } from '../string-field/string-field.component';
@Component({ @Component({
selector: 'app-proto-field', selector: 'app-proto-field',
@@ -36,16 +39,33 @@ import { ObjectFieldComponent } from '../object-field/object-field.component';
MatSelectModule, MatSelectModule,
MatInputModule, MatInputModule,
ObjectFieldComponent, ObjectFieldComponent,
StringFieldComponent,
], ],
template: `@switch (configuration().type) { @case (MessageTypeEnum.String) { template: `@switch (configuration().type) { @case (MessageTypeEnum.String) {
<mat-form-field> <app-string-field
<mat-label>{{ label() }}</mat-label> [label]="label()"
<input matInput [(ngModel)]="value" /> [configuration]="stringConfiguration()"
</mat-form-field> [(value)]="value"
></app-string-field>
} @case (MessageTypeEnum.Numeric) { } @case (MessageTypeEnum.Numeric) {
<mat-form-field> <mat-form-field>
<mat-label>{{ label() }}</mat-label> <mat-label>{{ label() }}</mat-label>
<input matInput type="number" [(ngModel)]="value" /> <input
#number="ngModel"
matInput
type="number"
[(ngModel)]="value"
[min]="numericConfiguration().min ?? null"
[max]="numericConfiguration().max ?? null"
/>
<mat-hint *ngIf="number.hasError('min')"
>Number should not be less than
{{ numericConfiguration().min }}</mat-hint
>
<mat-hint *ngIf="number.hasError('max')"
>Number should not greater than
{{ numericConfiguration().max }}</mat-hint
>
</mat-form-field> </mat-form-field>
} @case (MessageTypeEnum.Boolean) { } @case (MessageTypeEnum.Boolean) {
<p> <p>
@@ -89,6 +109,14 @@ export class ProtoFieldComponent {
configuration = input.required<MessageConfiguration>(); configuration = input.required<MessageConfiguration>();
value = model<any>(); value = model<any>();
protected stringConfiguration = computed(
() => this.configuration() as StringMessage
);
protected numericConfiguration = computed(
() => this.configuration() as NumericMessage
);
protected enumConfiguration = computed( protected enumConfiguration = computed(
() => this.configuration() as EnumMessage () => this.configuration() as EnumMessage
); );

View File

@@ -0,0 +1,3 @@
mat-form-field {
width: 100%;
}

View File

@@ -0,0 +1,57 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
input,
model,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { StringMessage } from '../../model/proto-message.model';
import { MatInputModule } from '@angular/material/input';
import { MonacoEditorModule } from 'ngx-monaco-editor-v2';
@Component({
selector: 'app-string-field',
standalone: true,
imports: [
CommonModule,
FormsModule,
MatFormFieldModule,
MatInputModule,
MonacoEditorModule,
],
template: ` @if(configuration().textType === 'text') {
<mat-form-field>
<mat-label>{{ label() }}</mat-label>
<input
#text="ngModel"
matInput
[(ngModel)]="value"
[maxlength]="configuration().maxLength ?? null"
/>
<mat-hint *ngIf="text.hasError('maxlength')"
>Text should be less than
{{ configuration().maxLength }} characters</mat-hint
>
</mat-form-field>
} @else {
<ngx-monaco-editor
[(ngModel)]="value"
[options]="editorOptions"
></ngx-monaco-editor>
}`,
styleUrl: './string-field.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StringFieldComponent {
label = input();
configuration = input.required<StringMessage>();
value = model<string>();
protected editorOptions = {
theme: 'vs-dark',
language: 'sql',
automaticLayout: true,
};
}

View File

@@ -9,8 +9,11 @@ export enum MessageTypeEnum {
Enum = 'enum', Enum = 'enum',
} }
export type StringMessageTextType = 'text' | 'sql';
export interface StringMessage extends MessageConfiguration { export interface StringMessage extends MessageConfiguration {
maxLength?: number; maxLength?: number;
textType: StringMessageTextType;
} }
export interface BooleanMessage extends MessageConfiguration {} export interface BooleanMessage extends MessageConfiguration {}
@@ -43,9 +46,13 @@ export interface MessageConfiguration {
type: MessageTypeEnum; type: MessageTypeEnum;
} }
export const StringMessage = (maxLength?: number): StringMessage => ({ export const StringMessage = (
maxLength?: number,
textType: StringMessageTextType = 'text'
): StringMessage => ({
type: MessageTypeEnum.String, type: MessageTypeEnum.String,
maxLength, maxLength,
textType,
}); });
export const BooleanMessage = (): BooleanMessage => ({ export const BooleanMessage = (): BooleanMessage => ({
@@ -114,3 +121,11 @@ export const UnknownProto = (
fullName, fullName,
values: [{ name: 'Raw JSON', configuration: RawMessage() }], values: [{ name: 'Raw JSON', configuration: RawMessage() }],
}); });
export const EDITABLE_MESSAGE_TYPES = [
MessageTypeEnum.String,
MessageTypeEnum.Numeric,
MessageTypeEnum.List,
MessageTypeEnum.Map,
MessageTypeEnum.Object,
];

View File

@@ -0,0 +1,3 @@
mat-form-field {
display: block;
}

View File

@@ -0,0 +1,109 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import {
ListMessage,
MapMessage,
MessageConfiguration,
MessageTypeEnum,
NumericMessage,
ObjectMessage,
ProtoMessageField,
StringMessage,
} from '../../../model/proto-message.model';
import { FormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatInputModule } from '@angular/material/input';
import { ListEditorFieldComponent } from '../list-editor-field/list-editor-field.component';
import { MapEditorFieldComponent } from '../map-editor-field/map-editor-field.component';
import { ObjectEditorFieldComponent } from '../object-editor-field/object-editor-field.component';
import { StringEditorFieldComponent } from '../string-editor-field/string-editor-field.component';
@Component({
selector: 'app-definition-editor-field',
standalone: true,
imports: [
CommonModule,
FormsModule,
ListEditorFieldComponent,
MapEditorFieldComponent,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
ObjectEditorFieldComponent,
StringEditorFieldComponent,
],
template: ` @switch (fieldConfiguration().type) {
@case(MessageTypeEnum.String) {
<app-string-editor-field
[configuration]="stringConfiguration(fieldConfiguration())"
></app-string-editor-field>
} @case (MessageTypeEnum.Numeric) { @let configuration =
numericConfiguration(fieldConfiguration());
<mat-form-field>
<mat-label>Min</mat-label>
<input
#min="ngModel"
matInput
type="number"
[(ngModel)]="configuration.min"
[max]="configuration.max ?? null"
/>
<mat-hint *ngIf="min.hasError('max')"
>Min should not be greater than {{ configuration.max }}</mat-hint
>
</mat-form-field>
<mat-form-field>
<mat-label>Max</mat-label>
<input
#max="ngModel"
matInput
type="number"
[(ngModel)]="configuration.max"
[min]="configuration.min ?? null"
/>
<mat-hint *ngIf="max.hasError('min')"
>Max should not be less than {{ configuration.min }}</mat-hint
>
</mat-form-field>
} @case (MessageTypeEnum.List) {
<app-list-editor-field
[field]="listConfiguration(fieldConfiguration())"
></app-list-editor-field>
} @case (MessageTypeEnum.Map) {
<app-map-editor-field
[field]="mapConfiguration(fieldConfiguration())"
></app-map-editor-field>
} @case(MessageTypeEnum.Object) {
<app-object-editor-field
[field]="objectConfiguration(fieldConfiguration())"
></app-object-editor-field>
} }`,
styleUrl: './definition-editor-field.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DefinitionEditorFieldComponent {
fieldConfiguration = input.required<MessageConfiguration>();
protected stringConfiguration(configuration: MessageConfiguration) {
return configuration as StringMessage;
}
protected numericConfiguration(configuration: MessageConfiguration) {
return configuration as NumericMessage;
}
protected listConfiguration(configuration: MessageConfiguration) {
return configuration as ListMessage;
}
protected mapConfiguration(configuration: MessageConfiguration) {
return configuration as MapMessage;
}
protected objectConfiguration(configuration: MessageConfiguration) {
return configuration as ObjectMessage;
}
protected readonly MessageTypeEnum = MessageTypeEnum;
}

View File

@@ -0,0 +1,66 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import {
EDITABLE_MESSAGE_TYPES,
ListMessage,
MessageConfiguration,
MessageTypeEnum,
ObjectMessage,
ProtoMessage,
} from '../../model/proto-message.model';
import { DefinitionEditorFieldComponent } from './definition-editor-field/definition-editor-field.component';
@Component({
selector: 'app-definition-editor',
standalone: true,
imports: [
CommonModule,
MatButtonModule,
MatDialogModule,
DefinitionEditorFieldComponent,
],
template: `
<h2 mat-dialog-title>{{ protoMessage.name }}</h2>
<mat-dialog-content>
@for (field of editableMessages; track $index) {
<h3>{{ field.name }}</h3>
<app-definition-editor-field
[fieldConfiguration]="field.configuration"
></app-definition-editor-field>
}
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button mat-dialog-close>Close</button>
</mat-dialog-actions>
`,
styleUrl: './definition-editor.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DefinitionEditorComponent {
protected editableMessages = this.protoMessage.values.filter((message) =>
this.filterMessageConfiguration(message.configuration)
);
private filterMessageConfiguration(
configuration: MessageConfiguration
): boolean {
if (configuration.type === MessageTypeEnum.List) {
return this.filterMessageConfiguration(
(configuration as ListMessage).subConfiguration
);
}
if (configuration.type === MessageTypeEnum.Object) {
// Ensure at least one nested message can be configured
return !!(configuration as ObjectMessage).messageDefinition.values.find(
(message) => this.filterMessageConfiguration(message.configuration)
);
}
// Note: Map can always be configured, as key needs to be a string or numeric type
return EDITABLE_MESSAGE_TYPES.includes(configuration.type);
}
constructor(@Inject(MAT_DIALOG_DATA) protected protoMessage: ProtoMessage) {}
}

View File

@@ -0,0 +1,22 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
forwardRef,
input,
} from '@angular/core';
import { DefinitionEditorFieldComponent } from '../definition-editor-field/definition-editor-field.component';
import { ListMessage } from '../../../model/proto-message.model';
@Component({
selector: 'app-list-editor-field',
standalone: true,
imports: [CommonModule, forwardRef(() => DefinitionEditorFieldComponent)],
template: `<app-definition-editor-field
[fieldConfiguration]="field().subConfiguration"
></app-definition-editor-field>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ListEditorFieldComponent {
field = input.required<ListMessage>();
}

View File

@@ -0,0 +1,39 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
forwardRef,
input,
} from '@angular/core';
import { DefinitionEditorFieldComponent } from '../definition-editor-field/definition-editor-field.component';
import {
EDITABLE_MESSAGE_TYPES,
MapMessage,
MessageConfiguration,
} from '../../../model/proto-message.model';
@Component({
selector: 'app-map-editor-field',
standalone: true,
imports: [CommonModule, forwardRef(() => DefinitionEditorFieldComponent)],
template: `
<h4>Key Configuration</h4>
<app-definition-editor-field
[fieldConfiguration]="field().keyConfiguration"
></app-definition-editor-field>
@if(isEditable(field().valueConfiguration)) {
<h4>Value Configuration</h4>
<app-definition-editor-field
[fieldConfiguration]="field().valueConfiguration"
></app-definition-editor-field>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MapEditorFieldComponent {
field = input.required<MapMessage>();
protected isEditable(configuration: MessageConfiguration) {
return EDITABLE_MESSAGE_TYPES.includes(configuration.type);
}
}

View File

@@ -0,0 +1,37 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
forwardRef,
input,
} from '@angular/core';
import {
EDITABLE_MESSAGE_TYPES,
ObjectMessage,
} from '../../../model/proto-message.model';
import { DefinitionEditorFieldComponent } from '../definition-editor-field/definition-editor-field.component';
@Component({
selector: 'app-object-editor-field',
standalone: true,
imports: [CommonModule, forwardRef(() => DefinitionEditorFieldComponent)],
template: `
@for (field of editableFields(); track $index) {
<h4>{{ field.name }}</h4>
<app-definition-editor-field
[fieldConfiguration]="field.configuration"
></app-definition-editor-field>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ObjectEditorFieldComponent {
field = input.required<ObjectMessage>();
protected editableFields = computed(() =>
this.field().messageDefinition.values.filter((field) =>
EDITABLE_MESSAGE_TYPES.includes(field.configuration.type)
)
);
}

View File

@@ -0,0 +1,3 @@
mat-form-field {
display: block;
}

View File

@@ -0,0 +1,35 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { StringMessage } from '../../../model/proto-message.model';
import { MatInputModule } from '@angular/material/input';
@Component({
selector: 'app-string-editor-field',
standalone: true,
imports: [
CommonModule,
FormsModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
],
template: ` <mat-form-field>
<mat-label>Max Length</mat-label>
<input matInput type="number" [(ngModel)]="configuration().maxLength" />
</mat-form-field>
<mat-form-field>
<mat-label>Field Type</mat-label>
<mat-select [(value)]="configuration().textType">
<mat-option value="text">Text</mat-option>
<mat-option value="sql">SQL</mat-option>
</mat-select>
</mat-form-field>`,
styleUrl: './string-editor-field.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StringEditorFieldComponent {
configuration = input.required<StringMessage>();
}

View File

@@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef,
Component, Component,
ElementRef, ElementRef,
HostBinding, HostBinding,
@@ -11,20 +12,29 @@ import {
viewChild, viewChild,
} from '@angular/core'; } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatListModule } from '@angular/material/list'; import { MatListModule, MatSelectionList } from '@angular/material/list';
import { ProtoMessage } from '../model/proto-message.model'; import { ProtoMessage } from '../model/proto-message.model';
import { ProtoDefinitionService } from './proto-definition.service'; import { ProtoDefinitionService } from './proto-definition.service';
import { MatTreeModule } from '@angular/material/tree'; import { MatTreeModule } from '@angular/material/tree';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { writeTextFile } from '@tauri-apps/api/fs'; import { writeTextFile } from '@tauri-apps/api/fs';
import { save } from '@tauri-apps/api/dialog'; import { save } from '@tauri-apps/api/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatDialog } from '@angular/material/dialog';
import { DefinitionEditorComponent } from './definition-editor/definition-editor.component';
declare const __TAURI__: any; declare const __TAURI__: any;
@Component({ @Component({
selector: 'app-proto-definition-selector', selector: 'app-proto-definition-selector',
standalone: true, standalone: true,
imports: [CommonModule, MatButtonModule, MatListModule, MatTreeModule], imports: [
CommonModule,
MatButtonModule,
MatIconModule,
MatListModule,
MatTreeModule,
],
template: ` template: `
<h2>Protobuf Definitions</h2> <h2>Protobuf Definitions</h2>
<button mat-button (click)="protoSelector.click()"> <button mat-button (click)="protoSelector.click()">
@@ -38,26 +48,38 @@ declare const __TAURI__: any;
<button mat-button (click)="configurationSelector.click()"> <button mat-button (click)="configurationSelector.click()">
Import Configuration Import Configuration
</button> </button>
<!-- TODO: Make this a selection list to indicate which file is selected, and allow deselecting a file --> <mat-selection-list [multiple]="false">
<mat-action-list>
@for (item of currentFiles(); track $index) { @for (item of currentFiles(); track $index) {
<button mat-list-item (click)="selectProtoDefinition(item)"> <mat-list-option
[value]="item"
(selectedChange)="selectProtoDefinition(item)"
>
{{ item }} {{ item }}
</button> </mat-list-option>
} }
</mat-action-list> </mat-selection-list>
@if(selectedDefinition().length > 0) { @if(selectedDefinition().length > 0) {
<h3> <h3>
@if(selectedProtoFile()) { Messages in {{ selectedProtoFile() }} } @else @if(selectedProtoFile()) { Messages in {{ selectedProtoFile() }} } @else
{Showing all messages} {Showing all messages}
</h3> </h3>
<mat-action-list> <mat-selection-list #messageSelector [multiple]="false">
@for (item of selectedDefinition(); track $index) { @for (item of selectedDefinition(); track $index) {
<button mat-list-item (click)="messageSelected.emit(item)"> <mat-list-option
{{ item.name }} [value]="item"
</button> (selectedChange)="messageSelected.emit(item)"
>
<button
matListItemIcon
mat-icon-button
(click)="editProtoMessage($event, item)"
>
<mat-icon>edit</mat-icon>
</button>
<div matListItemLine>{{ item.name }}</div>
</mat-list-option>
} }
</mat-action-list> </mat-selection-list>
} }
<input <input
@@ -86,6 +108,7 @@ export class ProtoDefinitionSelectorComponent {
'configurationSelector' 'configurationSelector'
); );
protected exporter = viewChild<ElementRef<HTMLAnchorElement>>('exporter'); protected exporter = viewChild<ElementRef<HTMLAnchorElement>>('exporter');
protected messageSelector = viewChild<MatSelectionList>('messageSelector');
protected selectedDefinition = signal<ProtoMessage[]>([]); protected selectedDefinition = signal<ProtoMessage[]>([]);
protected selectedProtoFile = signal<string | null>(null); protected selectedProtoFile = signal<string | null>(null);
@@ -112,7 +135,8 @@ export class ProtoDefinitionSelectorComponent {
constructor( constructor(
private protoDefinitionService: ProtoDefinitionService, private protoDefinitionService: ProtoDefinitionService,
private snackBar: MatSnackBar private snackBar: MatSnackBar,
private dialog: MatDialog
) {} ) {}
protected async addDefinitionFiles() { protected async addDefinitionFiles() {
@@ -209,6 +233,24 @@ export class ProtoDefinitionSelectorComponent {
} }
} }
protected editProtoMessage(event: MouseEvent, protoMessage: ProtoMessage) {
event.stopPropagation();
this.dialog
.open(DefinitionEditorComponent, {
data: protoMessage,
width: '40vw',
})
.afterClosed()
.subscribe(() => {
if (
protoMessage ===
this.messageSelector()?.selectedOptions.selected[0]?.value
) {
this.messageSelected.emit(structuredClone(protoMessage));
}
});
}
@HostListener('dragover', ['$event']) @HostListener('dragover', ['$event'])
onDrag(event: DragEvent) { onDrag(event: DragEvent) {
event.preventDefault(); event.preventDefault();

View File

@@ -31,6 +31,7 @@ html {
@include mat.select-theme(theme.$rose-theme); @include mat.select-theme(theme.$rose-theme);
@include mat.snack-bar-theme(theme.$rose-theme); @include mat.snack-bar-theme(theme.$rose-theme);
@include mat.button-toggle-theme(theme.$rose-theme); @include mat.button-toggle-theme(theme.$rose-theme);
@include mat.dialog-theme(theme.$rose-theme);
@include custom-colours(theme.$rose-theme); @include custom-colours(theme.$rose-theme);
} }