Compare commits

..

18 Commits

Author SHA1 Message Date
e0acfcc27e Fix project name for cloudflare publish command
All checks were successful
release-nightly / macos (push) Successful in 1m48s
release-nightly / web-demo (push) Successful in 6m38s
2025-05-29 17:12:24 +09:30
906c13fde2 Update node version in CI build
Some checks failed
release-nightly / macos (push) Successful in 1m48s
release-nightly / web-demo (push) Failing after 5m23s
2025-05-29 17:05:39 +09:30
7bda79db00 Specify node version so angular builds, revert back to ubuntu 22.04
Some checks failed
release-nightly / macos (push) Successful in 1m52s
release-nightly / web-demo (push) Failing after 5m4s
2025-05-29 16:58:47 +09:30
2eca7fc22d Switch CI to ubuntu-latest, update cloudflare
Some checks failed
release-nightly / macos (push) Successful in 1m56s
release-nightly / web-demo (push) Failing after 2m14s
2025-05-29 16:36:43 +09:30
200f76780b Remove zone.js dependency
Some checks failed
release-nightly / macos (push) Successful in 1m46s
release-nightly / web-demo (push) Failing after 3m18s
2025-05-29 08:50:39 +09:30
cf89834fe9 Fix test post update
Some checks failed
release-nightly / macos (push) Successful in 2m3s
release-nightly / web-demo (push) Failing after 2m7s
2025-05-29 07:20:10 +09:30
acbb4c9e2c angular-20 (#3)
Some checks failed
release-nightly / macos (push) Failing after 12s
release-nightly / web-demo (push) Failing after 2m5s
Reviewed-on: #3
2025-05-29 07:10:26 +09:30
1e18b9e768 Set workspace name correctly
All checks were successful
release-nightly / macos (push) Successful in 2m9s
release-nightly / web-demo (push) Successful in 5m46s
2025-05-18 10:43:08 +09:30
53a9ce3a45 Move selected browser directory into directory name
Some checks failed
release-nightly / web-demo (push) Has been cancelled
release-nightly / macos (push) Has been cancelled
2025-05-18 10:42:39 +09:30
885ed5892e Start adding json schema import
All checks were successful
release-nightly / macos (push) Successful in 2m12s
release-nightly / web-demo (push) Successful in 4m59s
2025-05-17 11:03:19 +09:30
1c4536878e Make sidenav background transparent so bubbles stand out on narrow screens
All checks were successful
release-nightly / macos (push) Successful in 2m6s
release-nightly / web-demo (push) Successful in 4m57s
2025-05-14 22:53:53 +09:30
3897c1841e Restore bubble styling for sidenavs
All checks were successful
release-nightly / macos (push) Successful in 2m2s
release-nightly / web-demo (push) Successful in 4m46s
2025-05-14 20:59:58 +09:30
8003a8fee9 Use accordions for nested field types
All checks were successful
release-nightly / macos (push) Successful in 2m9s
release-nightly / web-demo (push) Successful in 4m53s
2025-05-14 20:38:14 +09:30
cdeccf33b3 Fix enum message using configuration rather than protoname in json output
All checks were successful
release-nightly / macos (push) Successful in 2m8s
release-nightly / web-demo (push) Successful in 4m16s
2025-05-13 21:01:44 +09:30
af613c3d37 Fix tests to work with new enum format
All checks were successful
release-nightly / macos (push) Successful in 2m1s
release-nightly / web-demo (push) Successful in 4m19s
2025-05-13 20:52:08 +09:30
ec0f21cf16 Remove common module, add friendy name editing, add enum options editing, fix list option warning
Some checks failed
release-nightly / macos (push) Failing after 23s
release-nightly / web-demo (push) Failing after 4m25s
2025-05-13 20:49:46 +09:30
dd8d1374ab Update to angular 19.2.x, switch to new angular material styling system, reduce density (#2)
All checks were successful
release-nightly / macos (push) Successful in 2m8s
release-nightly / web-demo (push) Successful in 5m45s
Reviewed-on: #2
2025-03-29 16:48:27 +10:30
451877667a Update to Angular 19 (#1)
All checks were successful
release-nightly / web-demo (push) Successful in 5m27s
release-nightly / macos (push) Successful in 2m9s
Reviewed-on: #1
2025-02-11 11:12:35 +10:30
38 changed files with 591 additions and 332 deletions

View File

@@ -32,6 +32,9 @@ jobs:
with: with:
lfs: true lfs: true
- uses: oven-sh/setup-bun@v2 - uses: oven-sh/setup-bun@v2
- uses: actions/setup-node@v4
with:
node-version: ^22.12.0
- uses: browser-actions/setup-chrome@v1 - uses: browser-actions/setup-chrome@v1
id: setup-chrome id: setup-chrome
with: with:
@@ -49,9 +52,8 @@ jobs:
run: bun run build run: bun run build
- name: Publish to Cloudflare Pages - name: Publish to Cloudflare Pages
uses: cloudflare/pages-action@v1 uses: cloudflare/wrangler-action@v3
with: with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: buf command: pages deploy dist/proto-creator/browser --project-name=buf
directory: dist/proto-creator/browser

View File

@@ -58,7 +58,8 @@
} }
], ],
"outputHashing": "all", "outputHashing": "all",
"serviceWorker": "ngsw-config.json" "serviceWorker": "ngsw-config.json",
"security": { "autoCsp": true }
}, },
"development": { "development": {
"optimization": false, "optimization": false,
@@ -84,10 +85,10 @@
"defaultConfiguration": "development" "defaultConfiguration": "development"
}, },
"extract-i18n": { "extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n" "builder": "@angular/build:extract-i18n"
}, },
"test": { "test": {
"builder": "@angular-devkit/build-angular:karma", "builder": "@angular/build:karma",
"options": { "options": {
"polyfills": [], "polyfills": [],
"tsConfig": "tsconfig.spec.json", "tsConfig": "tsconfig.spec.json",
@@ -107,5 +108,31 @@
} }
} }
} }
},
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
} }
} }

BIN
bun.lockb

Binary file not shown.

View File

@@ -4,13 +4,12 @@
module.exports = function (config) { module.exports = function (config) {
config.set({ config.set({
basePath: '', basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'], frameworks: ['jasmine'],
plugins: [ plugins: [
require('karma-jasmine'), require('karma-jasmine'),
require('karma-chrome-launcher'), require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'), require('karma-jasmine-html-reporter'),
require('karma-coverage'), require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma')
], ],
client: { client: {
jasmine: { jasmine: {

View File

@@ -11,40 +11,39 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular-devkit/build-angular": "^18.1.1", "@angular/animations": "^20.0.0",
"@angular/animations": "^18.1.1", "@angular/cdk": "^20.0.1",
"@angular/cdk": "^18.1.1", "@angular/common": "^20.0.0",
"@angular/common": "^18.1.1", "@angular/compiler": "^20.0.0",
"@angular/compiler": "^18.1.1", "@angular/core": "^20.0.0",
"@angular/core": "^18.1.1", "@angular/forms": "^20.0.0",
"@angular/forms": "^18.1.1", "@angular/material": "^20.0.1",
"@angular/material": "^18.1.1", "@angular/platform-browser": "^20.0.0",
"@angular/platform-browser": "^18.1.1", "@angular/platform-browser-dynamic": "^20.0.0",
"@angular/platform-browser-dynamic": "^18.1.1", "@angular/router": "^20.0.0",
"@angular/router": "^18.1.1", "@angular/service-worker": "^20.0.0",
"@angular/service-worker": "^18.1.1",
"@tauri-apps/api": "^1.6.0", "@tauri-apps/api": "^1.6.0",
"highlight.js": "^11.10.0", "@types/json-schema": "^7.0.15",
"highlight.js": "^11.11.1",
"monaco-editor": "^0.50.0", "monaco-editor": "^0.50.0",
"ngx-monaco-editor-v2": "^18.0.1", "ngx-monaco-editor-v2": "^19.0.2",
"protobufjs": "^7.3.2", "protobufjs": "^7.4.0",
"rxjs": "^7.8.1", "rxjs": "^7.8.2",
"tslib": "^2.6.3", "tslib": "^2.8.1"
"zone.js": "^0.14.8"
}, },
"devDependencies": { "devDependencies": {
"@angular/build": "^18.1.1", "@angular/build": "^20.0.0",
"@angular/cli": "^18.1.1", "@angular/cli": "^20.0.0",
"@angular/compiler-cli": "^18.1.1", "@angular/compiler-cli": "^20.0.0",
"@tauri-apps/cli": "^1.6.0", "@tauri-apps/cli": "^1.6.3",
"@types/jasmine": "^5.1.4", "@types/jasmine": "^5.1.7",
"@types/protobufjs": "^6.0.0", "@types/protobufjs": "^6.0.0",
"jasmine-core": "^5.1.2", "jasmine-core": "^5.6.0",
"karma": "^6.4.3", "karma": "^6.4.4",
"karma-chrome-launcher": "^3.2.0", "karma-chrome-launcher": "^3.2.0",
"karma-coverage": "^2.2.1", "karma-coverage": "^2.2.1",
"karma-jasmine": "^5.1.0", "karma-jasmine": "^5.1.0",
"karma-jasmine-html-reporter": "^2.1.0", "karma-jasmine-html-reporter": "^2.1.0",
"typescript": "^5.5.3" "typescript": "^5.8.2"
} }
} }

View File

@@ -31,7 +31,10 @@
[opened]="leftSideOpen()" [opened]="leftSideOpen()"
(closed)="leftSideOpen.set(false)" (closed)="leftSideOpen.set(false)"
> >
<app-file-tree (fileSelected)="fileSelected($event)"></app-file-tree> <app-file-tree
class="bubble-sidenav"
(fileSelected)="fileSelected($event)"
></app-file-tree>
</mat-sidenav> </mat-sidenav>
<mat-sidenav <mat-sidenav
[opened]="rightSideOpen()" [opened]="rightSideOpen()"
@@ -40,6 +43,7 @@
(closed)="rightSideOpen.set(false)" (closed)="rightSideOpen.set(false)"
> >
<app-proto-definition-selector <app-proto-definition-selector
class="bubble-sidenav"
(messageSelected)="selectMessage($event)" (messageSelected)="selectMessage($event)"
></app-proto-definition-selector> ></app-proto-definition-selector>
</mat-sidenav> </mat-sidenav>

View File

@@ -33,3 +33,9 @@ app-editor {
.mat-icon.filled { .mat-icon.filled {
font-variation-settings: "FILL" 1, "wght" 400, "GRAD" 0, "opsz" 24; font-variation-settings: "FILL" 1, "wght" 400, "GRAD" 0, "opsz" 24;
} }
.bubble-sidenav {
background-color: var(--mat-sys-secondary-container);
border-radius: 1rem;
padding: 1rem;
}

View File

@@ -1,13 +1,11 @@
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout'; import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { CommonModule } from '@angular/common'; import { Component, signal, inject } from '@angular/core';
import { Component, computed, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatSidenavModule } from '@angular/material/sidenav'; import { MatSidenavModule } from '@angular/material/sidenav';
import { MatToolbarModule } from '@angular/material/toolbar'; import { MatToolbarModule } from '@angular/material/toolbar';
import { MatTreeModule } from '@angular/material/tree'; import { MatTreeModule } from '@angular/material/tree';
import { RouterOutlet } from '@angular/router';
import { EditorComponent } from './editor/editor.component'; import { EditorComponent } from './editor/editor.component';
import { import {
FileOrFolder, FileOrFolder,
@@ -15,15 +13,14 @@ import {
} from './file-tree/file-tree.component'; } from './file-tree/file-tree.component';
import { ProtoMessage } from './model/proto-message.model'; import { ProtoMessage } from './model/proto-message.model';
import { ProtoDefinitionSelectorComponent } from './proto-definition-selector/proto-definition-selector.component'; import { ProtoDefinitionSelectorComponent } from './proto-definition-selector/proto-definition-selector.component';
const mobileBreakpoints = [Breakpoints.Handset, Breakpoints.TabletPortrait]; const mobileBreakpoints = [Breakpoints.Handset, Breakpoints.TabletPortrait];
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
standalone: true,
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrl: './app.component.scss', styleUrl: './app.component.scss',
imports: [ imports: [
CommonModule,
EditorComponent, EditorComponent,
FileTreeComponent, FileTreeComponent,
MatButtonModule, MatButtonModule,
@@ -32,10 +29,11 @@ const mobileBreakpoints = [Breakpoints.Handset, Breakpoints.TabletPortrait];
MatToolbarModule, MatToolbarModule,
MatTreeModule, MatTreeModule,
ProtoDefinitionSelectorComponent, ProtoDefinitionSelectorComponent,
RouterOutlet,
], ],
}) })
export class AppComponent { export class AppComponent {
private breakpointObserver = inject(BreakpointObserver);
protected selectedFile = signal<FileOrFolder | undefined>(undefined); protected selectedFile = signal<FileOrFolder | undefined>(undefined);
protected selectedMessage = signal<ProtoMessage | undefined>(undefined); protected selectedMessage = signal<ProtoMessage | undefined>(undefined);
protected rightSideOpen = signal(true); protected rightSideOpen = signal(true);
@@ -46,7 +44,9 @@ export class AppComponent {
this.breakpointObserver.isMatched(mobileBreakpoints) this.breakpointObserver.isMatched(mobileBreakpoints)
); );
constructor(private breakpointObserver: BreakpointObserver) { constructor() {
const breakpointObserver = this.breakpointObserver;
breakpointObserver breakpointObserver
.observe(mobileBreakpoints) .observe(mobileBreakpoints)
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())

View File

@@ -1,8 +1,9 @@
import { import {
APP_INITIALIZER,
ApplicationConfig, ApplicationConfig,
isDevMode, isDevMode,
provideExperimentalZonelessChangeDetection, provideZonelessChangeDetection,
provideAppInitializer,
inject,
} from '@angular/core'; } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
@@ -16,30 +17,25 @@ import hljs from 'highlight.js/lib/core';
import json from 'highlight.js/lib/languages/json'; import json from 'highlight.js/lib/languages/json';
import { provideMonacoEditor } from 'ngx-monaco-editor-v2'; import { provideMonacoEditor } from 'ngx-monaco-editor-v2';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideExperimentalZonelessChangeDetection(), provideZonelessChangeDetection(),
provideRouter(routes), provideRouter(routes),
provideAnimationsAsync(), provideAnimationsAsync(),
{ provideAppInitializer(() => {
provide: APP_INITIALIZER, hljs.registerLanguage('json', json);
useValue: () => { }),
hljs.registerLanguage('json', json); provideAppInitializer(() => {
}, const initializerFn = ((iconRegistry: MatIconRegistry) => () => {
multi: true,
},
{
provide: APP_INITIALIZER,
useFactory: (iconRegistry: MatIconRegistry) => () => {
iconRegistry.registerFontClassAlias( iconRegistry.registerFontClassAlias(
'material-symbols-rounded', 'material-symbols-rounded',
'material-symbols-rounded' 'material-symbols-rounded'
); );
}, })(inject(MatIconRegistry));
deps: [MatIconRegistry], return initializerFn();
multi: true, }),
},
{ {
provide: MAT_ICON_DEFAULT_OPTIONS, provide: MAT_ICON_DEFAULT_OPTIONS,
useValue: { fontSet: 'material-symbols-rounded' }, useValue: { fontSet: 'material-symbols-rounded' },
@@ -49,5 +45,11 @@ export const appConfig: ApplicationConfig = {
enabled: !isDevMode(), enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000', registrationStrategy: 'registerWhenStable:30000',
}), }),
{
provide: MAT_FORM_FIELD_DEFAULT_OPTIONS,
// Filled doesn't look great with low density, by default the label is lost, and adding
// it back makes it look cramped
useValue: { appearance: 'outline' },
},
], ],
}; };

View File

@@ -7,7 +7,7 @@
<div class="editor-items"> <div class="editor-items">
@if(values()) { @for (item of selectedMessage().values; track $index) { @if(values()) { @for (item of selectedMessage().values; track $index) {
<app-proto-field <app-proto-field
[label]="item.name" [label]="item.friendlyName || item.name"
[configuration]="item.configuration" [configuration]="item.configuration"
[value]="values()[item.name]" [value]="values()[item.name]"
(valueChange)="updateValue(item.name, $event)" (valueChange)="updateValue(item.name, $event)"

View File

@@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common';
import { import {
Component, Component,
ElementRef, ElementRef,
@@ -8,6 +7,7 @@ import {
input, input,
signal, signal,
viewChild, viewChild,
inject,
} from '@angular/core'; } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
@@ -29,9 +29,7 @@ type PreviewType = 'raw' | 'edit' | 'diff';
@Component({ @Component({
selector: 'app-editor', selector: 'app-editor',
standalone: true,
imports: [ imports: [
CommonModule,
ProtoFieldComponent, ProtoFieldComponent,
MatButtonModule, MatButtonModule,
MatButtonToggleModule, MatButtonToggleModule,
@@ -43,6 +41,8 @@ type PreviewType = 'raw' | 'edit' | 'diff';
styleUrl: './editor.component.scss', styleUrl: './editor.component.scss',
}) })
export class EditorComponent { export class EditorComponent {
private snackBar = inject(MatSnackBar);
selectedFile = input<FileOrFolder>(); selectedFile = input<FileOrFolder>();
selectedMessage = input.required<ProtoMessage>(); selectedMessage = input.required<ProtoMessage>();
indentSize = input<number>(2); indentSize = input<number>(2);
@@ -80,7 +80,9 @@ export class EditorComponent {
private code = viewChild<ElementRef<HTMLElement>>('code'); private code = viewChild<ElementRef<HTMLElement>>('code');
constructor(sanitizer: DomSanitizer, private snackBar: MatSnackBar) { constructor() {
const sanitizer = inject(DomSanitizer);
effect(() => { effect(() => {
const element = this.code()?.nativeElement; const element = this.code()?.nativeElement;
if (element) { if (element) {
@@ -98,17 +100,14 @@ export class EditorComponent {
} }
} }
}); });
effect( effect(() => {
() => { const message = this.selectedMessage();
const message = this.selectedMessage(); this.values.set(
this.values.set( Object.fromEntries(
Object.fromEntries( message.values.map((value) => [[value.name, undefined]])
message.values.map((value) => [[value.name, undefined]]) )
) );
); });
},
{ allowSignalWrites: true }
);
effect(async () => { effect(async () => {
const selectedFile = this.selectedFile(); const selectedFile = this.selectedFile();

View File

@@ -1,6 +1,7 @@
:host { :host {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin: 0.5rem 0;
} }
.row-wrapper { .row-wrapper {
@@ -11,7 +12,3 @@
app-proto-field { app-proto-field {
flex: 1; flex: 1;
} }
.add-button {
align-self: flex-end;
}

View File

@@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common';
import { import {
AfterViewInit, AfterViewInit,
ChangeDetectionStrategy, ChangeDetectionStrategy,
@@ -12,32 +11,40 @@ import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { ListMessage } from '../../model/proto-message.model'; import { ListMessage } from '../../model/proto-message.model';
import { ProtoFieldComponent } from '../proto-field/proto-field.component'; import { ProtoFieldComponent } from '../proto-field/proto-field.component';
import { MatExpansionModule } from '@angular/material/expansion';
@Component({ @Component({
selector: 'app-list-field', selector: 'app-list-field',
standalone: true,
imports: [ imports: [
CommonModule,
MatButtonModule, MatButtonModule,
MatExpansionModule,
MatIconModule, MatIconModule,
forwardRef(() => ProtoFieldComponent), forwardRef(() => ProtoFieldComponent),
], ],
template: `<h3>{{ label() }}</h3> template: `
@if(values()) { @for(value of values(); track $index) { <mat-accordion>
<div class="row-wrapper"> <mat-expansion-panel [expanded]="true">
<app-proto-field <mat-expansion-panel-header>
[configuration]="configuration().subConfiguration" <mat-panel-title>{{ label() }}</mat-panel-title>
[value]="value" </mat-expansion-panel-header>
(valueChange)="updateValue($index, $event)" @if(values()) { @for(value of values(); track $index) {
></app-proto-field> <div class="row-wrapper">
<button mat-icon-button (click)="remove($index)"> <app-proto-field
<mat-icon>remove</mat-icon> [configuration]="configuration().subConfiguration"
</button> [value]="value"
</div> (valueChange)="updateValue($index, $event)"
} } ></app-proto-field>
<button mat-icon-button class="add-button" (click)="add()"> <button mat-icon-button (click)="remove($index)">
<mat-icon>add</mat-icon> <mat-icon>remove</mat-icon>
</button>`, </button>
</div>
} }
<button mat-icon-button class="add-button" (click)="add()">
<mat-icon>add</mat-icon>
</button>
</mat-expansion-panel>
</mat-accordion>
`,
styleUrl: './list-field.component.scss', styleUrl: './list-field.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })

View File

@@ -1,6 +1,7 @@
:host { :host {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin: 0.5rem 0;
} }
.row-wrapper { .row-wrapper {
@@ -12,10 +13,6 @@ app-proto-field {
flex: 1; flex: 1;
&:first-child { &:first-child {
margin-right: 10px; margin-right: 0.5rem;
} }
} }
.add-button {
align-self: flex-end;
}

View File

@@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@@ -12,39 +11,47 @@ import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MapMessage } from '../../model/proto-message.model'; import { MapMessage } from '../../model/proto-message.model';
import { ProtoFieldComponent } from '../proto-field/proto-field.component'; import { ProtoFieldComponent } from '../proto-field/proto-field.component';
import { MatExpansionModule } from '@angular/material/expansion';
const keyIsEmpty = (key: string | number) => key == null || key === ''; const keyIsEmpty = (key: string | number) => key == null || key === '';
@Component({ @Component({
selector: 'app-map-field', selector: 'app-map-field',
standalone: true,
imports: [ imports: [
CommonModule,
forwardRef(() => ProtoFieldComponent), forwardRef(() => ProtoFieldComponent),
MatExpansionModule,
MatIconModule, MatIconModule,
MatButtonModule, MatButtonModule,
], ],
template: `<h3>{{ label() }}</h3> template: `
@if(valuePairs()) { @for(value of valuePairs(); track $index) { <mat-accordion>
<div class="row-wrapper"> <mat-expansion-panel [expanded]="true">
<app-proto-field <mat-expansion-panel-header>
[configuration]="configuration().keyConfiguration" <mat-panel-title>{{ label() }}</mat-panel-title>
[value]="value[0]" </mat-expansion-panel-header>
(valueChange)="updateKey($index, $event)" @if(valuePairs()) { @for(value of valuePairs(); track $index) {
></app-proto-field> <div class="row-wrapper">
<app-proto-field <app-proto-field
[configuration]="configuration().valueConfiguration" [configuration]="configuration().keyConfiguration"
[value]="value[1]" [value]="value[0]"
(valueChange)="updateValue($index, $event)" (valueChange)="updateKey($index, $event)"
></app-proto-field> ></app-proto-field>
<button mat-icon-button (click)="remove($index)"> <app-proto-field
<mat-icon>remove</mat-icon> [configuration]="configuration().valueConfiguration"
</button> [value]="value[1]"
</div> (valueChange)="updateValue($index, $event)"
} } ></app-proto-field>
<button mat-icon-button class="add-button" (click)="add()"> <button mat-icon-button (click)="remove($index)">
<mat-icon>add</mat-icon> <mat-icon>remove</mat-icon>
</button>`, </button>
</div>
} }
<button mat-icon-button class="add-button" (click)="add()">
<mat-icon>add</mat-icon>
</button>
</mat-expansion-panel>
</mat-accordion>
`,
styleUrl: './map-field.component.scss', styleUrl: './map-field.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
@@ -58,22 +65,19 @@ export class MapFieldComponent {
private changedInternal = false; private changedInternal = false;
constructor() { constructor() {
effect( effect(() => {
() => { // TODO: Super hacky but can't really think of another way to keep these in sync
// TODO: Super hacky but can't really think of another way to keep these in sync // without removing an entry when the key gets blanked. Would need an alternate
// without removing an entry when the key gets blanked. Would need an alternate // design that updated on blur only perhaps
// design that updated on blur only perhaps if (this.changedInternal) {
if (this.changedInternal) { this.changedInternal = false;
this.changedInternal = false; return;
return; }
} const values = this.values();
const values = this.values(); if (values) {
if (values) { this.valuePairs.set(Object.entries(values));
this.valuePairs.set(Object.entries(values)); }
} });
},
{ allowSignalWrites: true }
);
} }
add() { add() {

View File

@@ -0,0 +1,4 @@
:host {
display: block;
margin: 0.5rem 0;
}

View File

@@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@@ -9,24 +8,35 @@ import {
} from '@angular/core'; } from '@angular/core';
import { ObjectMessage } from '../../model/proto-message.model'; import { ObjectMessage } from '../../model/proto-message.model';
import { ProtoFieldComponent } from '../proto-field/proto-field.component'; import { ProtoFieldComponent } from '../proto-field/proto-field.component';
import { MatExpansionModule } from '@angular/material/expansion';
@Component({ @Component({
selector: 'app-object-field', selector: 'app-object-field',
standalone: true, imports: [MatExpansionModule, forwardRef(() => ProtoFieldComponent)],
imports: [CommonModule, forwardRef(() => ProtoFieldComponent)], template: `
template: `<h3> <mat-accordion>
{{ label() }} ({{ configuration().messageDefinition.name }}) <mat-expansion-panel [expanded]="true">
</h3> <mat-expansion-panel-header>
<div> <mat-panel-title>
@for (item of configuration().messageDefinition.values; track $index) { {{ label() ?? configuration().messageDefinition.name }}
<app-proto-field </mat-panel-title>
[label]="item.name" @if(label()) {
[configuration]="item.configuration" <mat-panel-description>
[value]="currentValue()[item.name]" {{ configuration().messageDefinition.name }}
(valueChange)="updateValue(item.name, $event)" </mat-panel-description>
></app-proto-field> }
} </mat-expansion-panel-header>
</div>`, @for (item of configuration().messageDefinition.values; track $index) {
<app-proto-field
[label]="item.friendlyName || item.name"
[configuration]="item.configuration"
[value]="currentValue()[item.name]"
(valueChange)="updateValue(item.name, $event)"
></app-proto-field>
}
</mat-expansion-panel>
</mat-accordion>
`,
styleUrl: './object-field.component.scss', styleUrl: './object-field.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })

View File

@@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@@ -28,9 +27,7 @@ import { StringFieldComponent } from '../string-field/string-field.component';
@Component({ @Component({
selector: 'app-proto-field', selector: 'app-proto-field',
standalone: true,
imports: [ imports: [
CommonModule,
FormsModule, FormsModule,
ListFieldComponent, ListFieldComponent,
MapFieldComponent, MapFieldComponent,
@@ -58,14 +55,17 @@ import { StringFieldComponent } from '../string-field/string-field.component';
[min]="numericConfiguration().min ?? null" [min]="numericConfiguration().min ?? null"
[max]="numericConfiguration().max ?? null" [max]="numericConfiguration().max ?? null"
/> />
<mat-hint *ngIf="number.hasError('min')" @if(number.hasError('min')) {
<mat-hint
>Number should not be less than >Number should not be less than
{{ numericConfiguration().min }}</mat-hint {{ numericConfiguration().min }}</mat-hint
> >
<mat-hint *ngIf="number.hasError('max')" } @if(number.hasError('max')) {
<mat-hint
>Number should not greater than >Number should not greater than
{{ numericConfiguration().max }}</mat-hint {{ numericConfiguration().max }}</mat-hint
> >
}
</mat-form-field> </mat-form-field>
} @case (MessageTypeEnum.Boolean) { } @case (MessageTypeEnum.Boolean) {
<p> <p>
@@ -75,10 +75,11 @@ import { StringFieldComponent } from '../string-field/string-field.component';
<mat-form-field> <mat-form-field>
<mat-label>{{ label() }}</mat-label> <mat-label>{{ label() }}</mat-label>
<mat-select [(value)]="value"> <mat-select [(value)]="value">
@for(option of enumConfiguration().options; track
enumConfiguration()!.options) {
<mat-option>None</mat-option> <mat-option>None</mat-option>
<mat-option [value]="option">{{ option }}</mat-option> @for(option of enumConfiguration().options; track option) {
<mat-option [value]="option.protoName">{{
option.friendlyName || option.protoName
}}</mat-option>
} }
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>

View File

@@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@@ -13,9 +12,7 @@ import { MonacoEditorModule } from 'ngx-monaco-editor-v2';
@Component({ @Component({
selector: 'app-string-field', selector: 'app-string-field',
standalone: true,
imports: [ imports: [
CommonModule,
FormsModule, FormsModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,

View File

@@ -1,16 +1,15 @@
:host { :host {
display: block; display: block;
border-radius: var(--mat-sidenav-container-shape);
padding: var(--mat-sidenav-container-shape);
height: 100%; height: 100%;
box-sizing: border-box; box-sizing: border-box;
transition: background-color 100ms linear; transition: background-color 100ms linear;
overflow-x: hidden; overflow-x: auto;
overflow-y: auto; overflow-y: auto;
} }
.mat-tree { .mat-tree {
background-color: transparent; background-color: transparent;
min-width: fit-content;
} }
.mat-tree-node { .mat-tree-node {

View File

@@ -1,5 +1,4 @@
import { FlatTreeControl } from '@angular/cdk/tree'; import { FlatTreeControl } from '@angular/cdk/tree';
import { CommonModule } from '@angular/common';
import { import {
Component, Component,
ElementRef, ElementRef,
@@ -40,8 +39,7 @@ const collator = new Intl.Collator(undefined, { numeric: true });
@Component({ @Component({
selector: 'app-file-tree', selector: 'app-file-tree',
standalone: true, imports: [MatButtonModule, MatIconModule, MatTreeModule],
imports: [CommonModule, MatButtonModule, MatIconModule, MatTreeModule],
templateUrl: './file-tree.component.html', templateUrl: './file-tree.component.html',
styleUrl: './file-tree.component.scss', styleUrl: './file-tree.component.scss',
}) })
@@ -151,7 +149,10 @@ export class FileTreeComponent implements OnInit, OnDestroy {
const file = files[i]; const file = files[i];
if (file.webkitRelativePath?.includes('/')) { if (file.webkitRelativePath?.includes('/')) {
// Got a file in a folder, so put it into the appropriate folder in the tree // Got a file in a folder, so put it into the appropriate folder in the tree
const splitFilePath = file.webkitRelativePath.split('/'); if (i === 0) {
this.selectedDirectory.set(file.webkitRelativePath.split('/')[0]);
}
const splitFilePath = file.webkitRelativePath.split('/').slice(1);
let currentChildren: FileOrFolder[] | undefined = mappedFiles; let currentChildren: FileOrFolder[] | undefined = mappedFiles;
for (let j = 0; j < splitFilePath.length - 1; j++) { for (let j = 0; j < splitFilePath.length - 1; j++) {
const relativePath = splitFilePath[j]; const relativePath = splitFilePath[j];

View File

@@ -38,8 +38,13 @@ export interface ObjectMessage extends MessageConfiguration {
export interface RawMessage extends MessageConfiguration {} export interface RawMessage extends MessageConfiguration {}
export interface EnumMessageOption {
protoName: string;
friendlyName?: string;
}
export interface EnumMessage extends MessageConfiguration { export interface EnumMessage extends MessageConfiguration {
options: string[]; options: EnumMessageOption[];
} }
export interface MessageConfiguration { export interface MessageConfiguration {
@@ -90,11 +95,12 @@ export const RawMessage = (): RawMessage => ({ type: MessageTypeEnum.Raw });
export const EnumMessage = (options: string[]) => ({ export const EnumMessage = (options: string[]) => ({
type: MessageTypeEnum.Enum, type: MessageTypeEnum.Enum,
options, options: options.map((option) => ({ protoName: option })),
}); });
export interface ProtoMessageField<T extends MessageConfiguration> { export interface ProtoMessageField<T extends MessageConfiguration> {
name: string; name: string;
friendlyName?: string;
configuration: T; configuration: T;
} }
@@ -119,7 +125,9 @@ export const UnknownProto = (
): ProtoMessage => ({ ): ProtoMessage => ({
name, name,
fullName, fullName,
values: [{ name: 'Raw JSON', configuration: RawMessage() }], values: [
{ name: 'Raw JSON', friendlyName: 'Raw JSON', configuration: RawMessage() },
],
}); });
export const EDITABLE_MESSAGE_TYPES = [ export const EDITABLE_MESSAGE_TYPES = [

View File

@@ -1,13 +1,12 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { import {
EnumMessage,
ListMessage, ListMessage,
MapMessage, MapMessage,
MessageConfiguration, MessageConfiguration,
MessageTypeEnum, MessageTypeEnum,
NumericMessage, NumericMessage,
ObjectMessage, ObjectMessage,
ProtoMessageField,
StringMessage, StringMessage,
} from '../../../model/proto-message.model'; } from '../../../model/proto-message.model';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@@ -18,12 +17,11 @@ import { ListEditorFieldComponent } from '../list-editor-field/list-editor-field
import { MapEditorFieldComponent } from '../map-editor-field/map-editor-field.component'; import { MapEditorFieldComponent } from '../map-editor-field/map-editor-field.component';
import { ObjectEditorFieldComponent } from '../object-editor-field/object-editor-field.component'; import { ObjectEditorFieldComponent } from '../object-editor-field/object-editor-field.component';
import { StringEditorFieldComponent } from '../string-editor-field/string-editor-field.component'; import { StringEditorFieldComponent } from '../string-editor-field/string-editor-field.component';
import { EnumEditorFieldComponent } from '../enum-editor-field/enum-editor-field.component';
@Component({ @Component({
selector: 'app-definition-editor-field', selector: 'app-definition-editor-field',
standalone: true,
imports: [ imports: [
CommonModule,
FormsModule, FormsModule,
ListEditorFieldComponent, ListEditorFieldComponent,
MapEditorFieldComponent, MapEditorFieldComponent,
@@ -32,6 +30,7 @@ import { StringEditorFieldComponent } from '../string-editor-field/string-editor
MatSelectModule, MatSelectModule,
ObjectEditorFieldComponent, ObjectEditorFieldComponent,
StringEditorFieldComponent, StringEditorFieldComponent,
EnumEditorFieldComponent,
], ],
template: ` @switch (fieldConfiguration().type) { template: ` @switch (fieldConfiguration().type) {
@case(MessageTypeEnum.String) { @case(MessageTypeEnum.String) {
@@ -49,9 +48,11 @@ import { StringEditorFieldComponent } from '../string-editor-field/string-editor
[(ngModel)]="configuration.min" [(ngModel)]="configuration.min"
[max]="configuration.max ?? null" [max]="configuration.max ?? null"
/> />
<mat-hint *ngIf="min.hasError('max')" @if(min.hasError('max')) {
<mat-hint
>Min should not be greater than {{ configuration.max }}</mat-hint >Min should not be greater than {{ configuration.max }}</mat-hint
> >
}
</mat-form-field> </mat-form-field>
<mat-form-field> <mat-form-field>
<mat-label>Max</mat-label> <mat-label>Max</mat-label>
@@ -62,9 +63,9 @@ import { StringEditorFieldComponent } from '../string-editor-field/string-editor
[(ngModel)]="configuration.max" [(ngModel)]="configuration.max"
[min]="configuration.min ?? null" [min]="configuration.min ?? null"
/> />
<mat-hint *ngIf="max.hasError('min')" @if(max.hasError('min')) {
>Max should not be less than {{ configuration.min }}</mat-hint <mat-hint>Max should not be less than {{ configuration.min }}</mat-hint>
> }
</mat-form-field> </mat-form-field>
} @case (MessageTypeEnum.List) { } @case (MessageTypeEnum.List) {
<app-list-editor-field <app-list-editor-field
@@ -78,6 +79,10 @@ import { StringEditorFieldComponent } from '../string-editor-field/string-editor
<app-object-editor-field <app-object-editor-field
[field]="objectConfiguration(fieldConfiguration())" [field]="objectConfiguration(fieldConfiguration())"
></app-object-editor-field> ></app-object-editor-field>
} @case(MessageTypeEnum.Enum) {
<app-enum-editor-field
[configuration]="enumConfiguration(fieldConfiguration())"
></app-enum-editor-field>
} }`, } }`,
styleUrl: './definition-editor-field.component.css', styleUrl: './definition-editor-field.component.css',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -105,5 +110,9 @@ export class DefinitionEditorFieldComponent {
return configuration as ObjectMessage; return configuration as ObjectMessage;
} }
protected enumConfiguration(configuration: MessageConfiguration) {
return configuration as EnumMessage;
}
protected readonly MessageTypeEnum = MessageTypeEnum; protected readonly MessageTypeEnum = MessageTypeEnum;
} }

View File

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

View File

@@ -1,9 +1,7 @@
import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { import {
EDITABLE_MESSAGE_TYPES,
ListMessage, ListMessage,
MessageConfiguration, MessageConfiguration,
MessageTypeEnum, MessageTypeEnum,
@@ -11,14 +9,18 @@ import {
ProtoMessage, ProtoMessage,
} from '../../model/proto-message.model'; } from '../../model/proto-message.model';
import { DefinitionEditorFieldComponent } from './definition-editor-field/definition-editor-field.component'; import { DefinitionEditorFieldComponent } from './definition-editor-field/definition-editor-field.component';
import { MatFormFieldModule } from '@angular/material/form-field';
import { FormsModule } from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
@Component({ @Component({
selector: 'app-definition-editor', selector: 'app-definition-editor',
standalone: true,
imports: [ imports: [
CommonModule, FormsModule,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,
MatFormFieldModule,
MatInputModule,
DefinitionEditorFieldComponent, DefinitionEditorFieldComponent,
], ],
template: ` template: `
@@ -26,6 +28,14 @@ import { DefinitionEditorFieldComponent } from './definition-editor-field/defini
<mat-dialog-content> <mat-dialog-content>
@for (field of editableMessages; track $index) { @for (field of editableMessages; track $index) {
<h3>{{ field.name }}</h3> <h3>{{ field.name }}</h3>
<mat-form-field>
<mat-label>Friendly Name</mat-label>
<input
matInput
[(ngModel)]="field.friendlyName"
placeholder="Optional"
/>
</mat-form-field>
<app-definition-editor-field <app-definition-editor-field
[fieldConfiguration]="field.configuration" [fieldConfiguration]="field.configuration"
></app-definition-editor-field> ></app-definition-editor-field>
@@ -39,28 +49,7 @@ import { DefinitionEditorFieldComponent } from './definition-editor-field/defini
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class DefinitionEditorComponent { export class DefinitionEditorComponent {
protected editableMessages = this.protoMessage.values.filter((message) => protected protoMessage = inject<ProtoMessage>(MAT_DIALOG_DATA);
this.filterMessageConfiguration(message.configuration)
);
private filterMessageConfiguration( protected editableMessages = this.protoMessage.values;
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,3 @@
mat-form-field {
display: block;
}

View File

@@ -0,0 +1,28 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { EnumMessage } from '../../../model/proto-message.model';
import { MatFormFieldModule, MatLabel } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-enum-editor-field',
standalone: true,
imports: [FormsModule, MatFormFieldModule, MatInputModule, MatLabel],
template: `
@for (option of configuration().options; track $index) {
<mat-form-field>
<mat-label>{{ option.protoName }}</mat-label>
<input
matInput
[(ngModel)]="option.friendlyName"
placeholder="Friendly Name"
/>
</mat-form-field>
}
`,
styleUrl: './enum-editor-field.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EnumEditorFieldComponent {
configuration = input.required<EnumMessage>();
}

View File

@@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@@ -10,8 +9,7 @@ import { ListMessage } from '../../../model/proto-message.model';
@Component({ @Component({
selector: 'app-list-editor-field', selector: 'app-list-editor-field',
standalone: true, imports: [forwardRef(() => DefinitionEditorFieldComponent)],
imports: [CommonModule, forwardRef(() => DefinitionEditorFieldComponent)],
template: `<app-definition-editor-field template: `<app-definition-editor-field
[fieldConfiguration]="field().subConfiguration" [fieldConfiguration]="field().subConfiguration"
></app-definition-editor-field>`, ></app-definition-editor-field>`,

View File

@@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@@ -14,8 +13,7 @@ import {
@Component({ @Component({
selector: 'app-map-editor-field', selector: 'app-map-editor-field',
standalone: true, imports: [forwardRef(() => DefinitionEditorFieldComponent)],
imports: [CommonModule, forwardRef(() => DefinitionEditorFieldComponent)],
template: ` template: `
<h4>Key Configuration</h4> <h4>Key Configuration</h4>
<app-definition-editor-field <app-definition-editor-field

View File

@@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@@ -6,32 +5,43 @@ import {
forwardRef, forwardRef,
input, input,
} from '@angular/core'; } from '@angular/core';
import { import { ObjectMessage } from '../../../model/proto-message.model';
EDITABLE_MESSAGE_TYPES,
ObjectMessage,
} from '../../../model/proto-message.model';
import { DefinitionEditorFieldComponent } from '../definition-editor-field/definition-editor-field.component'; import { DefinitionEditorFieldComponent } from '../definition-editor-field/definition-editor-field.component';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { FormsModule } from '@angular/forms';
@Component({ @Component({
selector: 'app-object-editor-field', selector: 'app-object-editor-field',
standalone: true, imports: [
imports: [CommonModule, forwardRef(() => DefinitionEditorFieldComponent)], FormsModule,
MatFormFieldModule,
MatInputModule,
forwardRef(() => DefinitionEditorFieldComponent),
],
template: ` template: `
@for (field of editableFields(); track $index) { @for (field of editableFields(); track $index) {
<h4>{{ field.name }}</h4> <h4>{{ field.name }}</h4>
<mat-form-field>
<mat-label>Friendly Name</mat-label>
<input matInput [(ngModel)]="field.friendlyName" placeholder="Optional" />
</mat-form-field>
<app-definition-editor-field <app-definition-editor-field
[fieldConfiguration]="field.configuration" [fieldConfiguration]="field.configuration"
></app-definition-editor-field> ></app-definition-editor-field>
} }
`, `,
styles: `
mat-form-field {
display: block;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ObjectEditorFieldComponent { export class ObjectEditorFieldComponent {
field = input.required<ObjectMessage>(); field = input.required<ObjectMessage>();
protected editableFields = computed(() => protected editableFields = computed(
this.field().messageDefinition.values.filter((field) => () => this.field().messageDefinition.values
EDITABLE_MESSAGE_TYPES.includes(field.configuration.type)
)
); );
} }

View File

@@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
@@ -8,15 +7,9 @@ import { MatInputModule } from '@angular/material/input';
@Component({ @Component({
selector: 'app-string-editor-field', selector: 'app-string-editor-field',
standalone: true, imports: [FormsModule, MatFormFieldModule, MatInputModule, MatSelectModule],
imports: [ template: `
CommonModule, <mat-form-field>
FormsModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
],
template: ` <mat-form-field>
<mat-label>Max Length</mat-label> <mat-label>Max Length</mat-label>
<input matInput type="number" [(ngModel)]="configuration().maxLength" /> <input matInput type="number" [(ngModel)]="configuration().maxLength" />
</mat-form-field> </mat-form-field>
@@ -26,7 +19,8 @@ import { MatInputModule } from '@angular/material/input';
<mat-option value="text">Text</mat-option> <mat-option value="text">Text</mat-option>
<mat-option value="sql">SQL</mat-option> <mat-option value="sql">SQL</mat-option>
</mat-select> </mat-select>
</mat-form-field>`, </mat-form-field>
`,
styleUrl: './string-editor-field.component.scss', styleUrl: './string-editor-field.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })

View File

@@ -0,0 +1,166 @@
import { Injectable } from '@angular/core';
import {
BooleanMessage,
EnumMessage,
ListMessage,
MessageConfiguration,
NumericMessage,
ObjectMessage,
ProtoMessage,
ProtoMessageField,
StringMessage,
UnknownProto,
} from '../model/proto-message.model';
import { JSONSchema7 } from 'json-schema';
// jSON Schema types: https://json-schema.org/understanding-json-schema/reference/type
// Note: There are specific keywords for each type that should be considered.
// Ignore required properties for now, we don't care about this yet (in protobuf everything is optional
// anyway)
// Also ignore references/don't follow them, they can be unknown/raw json. Later can add an option to follow
// trusted/untrusted references. Only use $ref that starts with # as it's a local ref/defined in the file.
// Also ignore pattern properties: https://tour.json-schema.org/content/03-Objects/01-Pattern-Properties
// Also ignore conditional validation: https://tour.json-schema.org/content/05-Conditional-Validation/01-Ensuring-Conditional-Property-Presence
// Bare minimum to support is anything required by ingey
// Definitely do want to consider reusable subschemas though, already have a similar check to this
// in protobuf parsing as well when a nested message is used: https://tour.json-schema.org/content/06-Combining-Subschemas/01-Reusing-and-Referencing-with-defs-and-ref
// Simple example schema (create a test for this):
// {
// "$schema": "https://json-schema.org/draft/2020-12/schema",
// "$id": "https://example.com/product.schema.json",
// "title": "Product",
// "description": "A product from Acme's catalog",
// "type": "object",
// "properties": {
// "productId": {
// "description": "The unique identifier for a product",
// "type": "integer"
// },
// "productName": {
// "description": "Name of the product",
// "type": "string"
// }
// }
// }
@Injectable({
providedIn: 'root',
})
export class JsonSchemaImporterService {
async parseJsonSchema(
fileName: string,
schema: JSONSchema7
): Promise<ProtoMessage[]> {
const rootElements: ProtoMessage[] = [];
if (schema['$defs']) {
// Parse all definitions first, they're referenced later
for (const [name, value] of Object.entries(schema.$defs)) {
if (value instanceof Boolean) {
throw new Error('Cannot use boolean for definition value');
}
const jsonValue = value as JSONSchema7;
const objectMessage = ObjectMessage({ name, fileName, values: [] });
const properties = jsonValue.properties;
if (!properties) {
throw new Error('Malformed JOSN Schema, no properties present');
}
for (const [name, value] of Object.entries(properties)) {
objectMessage.messageDefinition.values.push({
name,
configuration: this.convertMessageRecursive(
value as JSONSchema7,
rootElements,
name
),
});
}
}
}
if (!schema.title || schema.type !== 'object') {
throw new Error('Unsupported JSON Schema, root must be an object');
}
const properties = schema.properties;
if (!properties) {
throw new Error('Malformed JOSN Schema, no properties present');
}
const objectMessage: ObjectMessage = ObjectMessage({
name: schema.title!,
fileName,
values: [],
});
for (const [name, value] of Object.entries(properties)) {
objectMessage.messageDefinition.values.push({
name,
configuration: this.convertMessageRecursive(
value as JSONSchema7,
rootElements,
name
),
});
}
return [
{
name: schema.title,
fileName,
values: [{ name: schema.title, configuration: objectMessage }],
},
...rootElements,
];
}
// Convert each type here?
private convertMessageRecursive(
value: JSONSchema7,
parsedMessages: ProtoMessage[],
name?: string
): MessageConfiguration {
if (value.$ref) {
const refMessageName = value.$ref.substring('#/definitions/'.length);
const foundMessage = parsedMessages.find(
(message) => message.name === refMessageName
);
return ObjectMessage(foundMessage!);
}
const type = Array.isArray(value.type) ? value.type[0] : value.type;
switch (type) {
case 'string':
if (value.enum) {
// TODO: Validate we have strings
return EnumMessage(value.enum.map((value) => value as string));
}
return StringMessage();
case 'integer':
case 'number':
return NumericMessage();
case 'array':
if (Array.isArray(value.items)) {
throw new Error('Cannot parse array type');
}
return ListMessage(
this.convertMessageRecursive(
value.items as JSONSchema7,
parsedMessages
)
);
case 'boolean':
return BooleanMessage();
case 'object':
return ObjectMessage({
name: name!,
values: Object.entries(value.properties!).map(([name, value]) => ({
name,
configuration: this.convertMessageRecursive(
// TODO: How can this be a boolean???
value as JSONSchema7,
parsedMessages,
name
),
})),
});
}
throw new Error('Invalid message type found');
}
}

View File

@@ -1,7 +1,5 @@
:host { :host {
display: block; display: block;
border-radius: var(--mat-sidenav-container-shape);
padding: var(--mat-sidenav-container-shape);
height: 100%; height: 100%;
box-sizing: border-box; box-sizing: border-box;
transition: background-color 100ms linear; transition: background-color 100ms linear;

View File

@@ -1,27 +1,28 @@
import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef,
Component, Component,
ElementRef, ElementRef,
HostBinding, HostBinding,
HostListener, HostListener,
computed, computed,
inject,
output, output,
signal, signal,
viewChild, viewChild,
} from '@angular/core'; } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatListModule, MatSelectionList } from '@angular/material/list';
import { ProtoMessage } from '../model/proto-message.model';
import { ProtoDefinitionService } from './proto-definition.service';
import { MatTreeModule } from '@angular/material/tree';
import { MatSnackBar } from '@angular/material/snack-bar';
import { writeTextFile } from '@tauri-apps/api/fs';
import { save } from '@tauri-apps/api/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule, MatSelectionList } from '@angular/material/list';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTreeModule } from '@angular/material/tree';
import { save } from '@tauri-apps/api/dialog';
import { writeTextFile } from '@tauri-apps/api/fs';
import { ProtoMessage } from '../model/proto-message.model';
import { DefinitionEditorComponent } from './definition-editor/definition-editor.component'; import { DefinitionEditorComponent } from './definition-editor/definition-editor.component';
import { ProtoDefinitionService } from './proto-definition.service';
import { JSONSchema7 } from 'json-schema';
import { JsonSchemaImporterService } from './json-schema-importer.service';
declare const __TAURI__: any; declare const __TAURI__: any;
@@ -29,27 +30,23 @@ const collator = new Intl.Collator(undefined, { numeric: true });
@Component({ @Component({
selector: 'app-proto-definition-selector', selector: 'app-proto-definition-selector',
standalone: true, imports: [MatButtonModule, MatIconModule, MatListModule, MatTreeModule],
imports: [
CommonModule,
MatButtonModule,
MatIconModule,
MatListModule,
MatTreeModule,
],
template: ` template: `
<h2>Protobuf Definitions</h2> <h2>Schemas</h2>
<button mat-button (click)="protoSelector.click()"> <button mat-button (click)="protoSelector.click()">
Select definitions Import Protobuf Definition
</button>
<button mat-button (click)="jsonSchemaSelector.click()">
Import JSON Schema
</button>
<button mat-button (click)="configurationSelector.click()">
Import Configuration
</button> </button>
@if(currentFiles().length > 0) { @if(currentFiles().length > 0) {
<button mat-button (click)="exportConfiguration()"> <button mat-button (click)="exportConfiguration()">
Export Configuration Export Configuration
</button> </button>
} }
<button mat-button (click)="configurationSelector.click()">
Import Configuration
</button>
<mat-selection-list [multiple]="false"> <mat-selection-list [multiple]="false">
@for (item of currentFiles(); track $index) { @for (item of currentFiles(); track $index) {
<mat-list-option <mat-list-option
@@ -78,7 +75,7 @@ const collator = new Intl.Collator(undefined, { numeric: true });
> >
<mat-icon>edit</mat-icon> <mat-icon>edit</mat-icon>
</button> </button>
<div matListItemLine>{{ item.name }}</div> <div>{{ item.name }}</div>
</mat-list-option> </mat-list-option>
} }
</mat-selection-list> </mat-selection-list>
@@ -90,6 +87,12 @@ const collator = new Intl.Collator(undefined, { numeric: true });
(change)="addDefinitionFiles()" (change)="addDefinitionFiles()"
accept=".proto" accept=".proto"
/> />
<input
#jsonSchemaSelector
type="file"
(change)="importJsonSchema()"
accept=".json"
/>
<input <input
#configurationSelector #configurationSelector
type="file" type="file"
@@ -102,10 +105,17 @@ const collator = new Intl.Collator(undefined, { numeric: true });
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class ProtoDefinitionSelectorComponent { export class ProtoDefinitionSelectorComponent {
private jsonSchemaImporterService = inject(JsonSchemaImporterService);
private protoDefinitionService = inject(ProtoDefinitionService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
messageSelected = output<ProtoMessage>(); messageSelected = output<ProtoMessage>();
protected protoSelector = protected protoSelector =
viewChild<ElementRef<HTMLInputElement>>('protoSelector'); viewChild<ElementRef<HTMLInputElement>>('protoSelector');
protected jsonSchemaSelector =
viewChild<ElementRef<HTMLInputElement>>('jsonSchemaSelector');
protected configurationSelector = viewChild<ElementRef<HTMLInputElement>>( protected configurationSelector = viewChild<ElementRef<HTMLInputElement>>(
'configurationSelector' 'configurationSelector'
); );
@@ -135,12 +145,6 @@ export class ProtoDefinitionSelectorComponent {
return this.isDragging(); return this.isDragging();
} }
constructor(
private protoDefinitionService: ProtoDefinitionService,
private snackBar: MatSnackBar,
private dialog: MatDialog
) {}
protected async addDefinitionFiles() { protected async addDefinitionFiles() {
const files = this.protoSelector()?.nativeElement.files; const files = this.protoSelector()?.nativeElement.files;
if (files) { if (files) {
@@ -185,6 +189,29 @@ export class ProtoDefinitionSelectorComponent {
); );
} }
protected async importJsonSchema() {
const jsonSchemaFile = this.jsonSchemaSelector()?.nativeElement.files;
try {
const fileContents = await jsonSchemaFile?.item(0)?.text();
if (fileContents) {
const schema: JSONSchema7 = JSON.parse(fileContents);
const messageObjects =
await this.jsonSchemaImporterService.parseJsonSchema(
jsonSchemaFile?.item(0)?.name!,
schema
);
this.allProtoFiles.set(messageObjects);
this.selectedDefinition.set(messageObjects);
this.selectedProtoFile.set(null);
}
} catch (err) {
console.error(err);
this.snackBar.open(
"Could not parse json schema file, please ensure it's valid. Note: This feature is still unstable and is likely to have errors with valid schemas."
);
}
}
protected async importConfiguration() { protected async importConfiguration() {
const configurationFile = this.configurationSelector()?.nativeElement.files; const configurationFile = this.configurationSelector()?.nativeElement.files;
try { try {

View File

@@ -1,6 +1,6 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideExperimentalZonelessChangeDetection } from '@angular/core'; import { provideZonelessChangeDetection } from '@angular/core';
import { import {
EnumMessage, EnumMessage,
ListMessage, ListMessage,
@@ -38,7 +38,7 @@ message Test {
} }
message ReferenceMessage { message ReferenceMessage {
repeated DoubleNestedMessage test = 1; repeated DoubleNestedMessage test = 1;
Test.NestedMessage nested_message = 2; Test.NestedMessage nested_message = 2;
EnumTest enum_test = 3; EnumTest enum_test = 3;
} }
@@ -58,7 +58,7 @@ describe('TestService', () => {
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [provideExperimentalZonelessChangeDetection()], providers: [provideZonelessChangeDetection()],
}); });
service = TestBed.inject(ProtoDefinitionService); service = TestBed.inject(ProtoDefinitionService);
}); });
@@ -142,7 +142,7 @@ describe('TestService', () => {
const enumMessage = converted.values[10].configuration as EnumMessage; const enumMessage = converted.values[10].configuration as EnumMessage;
expect(enumMessage.options.length).toBe(1); expect(enumMessage.options.length).toBe(1);
expect(enumMessage.options[0]).toBe('Hello'); expect(enumMessage.options[0].protoName).toBe('Hello');
const objectListMessage = converted.values[12].configuration as ListMessage; const objectListMessage = converted.values[12].configuration as ListMessage;
expect(objectListMessage.type).toBe(MessageTypeEnum.List); expect(objectListMessage.type).toBe(MessageTypeEnum.List);

View File

@@ -383,7 +383,6 @@ export class ProtoDefinitionService {
switch (fieldType) { switch (fieldType) {
case 'string': case 'string':
return StringMessage(); return StringMessage();
break;
case 'int32': case 'int32':
case 'int64': case 'int64':
case 'float': case 'float':

View File

@@ -1,50 +1,34 @@
@use "@angular/material" as mat; @use "@angular/material" as mat;
@use "./theme.scss";
@include mat.core();
@mixin custom-colours($theme) {
.mat-toolbar {
background-color: mat.get-theme-color($theme, tertiary-container);
}
app-proto-definition-selector,
app-file-tree {
background-color: mat.get-theme-color($theme, secondary-container);
transition: background-color 100ms linear;
}
.droppable {
background-color: mat.get-theme-color($theme, secondary, 40);
}
}
html { html {
@include mat.core-theme(theme.$rose-theme); color-scheme: dark light;
@include mat.toolbar-theme(theme.$rose-theme); height: 100%;
@include mat.button-theme(theme.$rose-theme);
@include mat.tree-theme(theme.$rose-theme); @include mat.theme(
@include mat.sidenav-theme(theme.$rose-theme); (
@include mat.icon-theme(theme.$rose-theme); color: mat.$rose-palette,
@include mat.form-field-theme(theme.$rose-theme); typography: Roboto,
@include mat.list-theme(theme.$rose-theme); density: -5,
@include mat.select-theme(theme.$rose-theme); )
@include mat.snack-bar-theme(theme.$rose-theme); );
@include mat.button-toggle-theme(theme.$rose-theme); @include mat.toolbar-overrides(
@include mat.dialog-theme(theme.$rose-theme); (
@include custom-colours(theme.$rose-theme); container-background-color: var(--mat-sys-primary-container),
)
);
@include mat.sidenav-overrides(
(
container-shape: 1rem,
container-background-color: transparent,
)
);
} }
html,
body { body {
height: 100%; height: 100%;
}
body {
margin: 0; margin: 0;
font: mat.get-theme-typography(theme.$rose-theme, body-medium, font); background: var(--mat-sys-surface);
--mat-tree-node-min-height: 24px; color: var(--mat-sys-on-surface);
--mat-tree-node-text-size: 14px; font-family: Roboto, "Helvetica Neue", sans-serif;
--mdc-icon-button-state-layer-size: 24px;
--mat-icon-button-touch-target-display: none;
--mdc-list-list-item-one-line-container-height: 24px;
} }

View File

@@ -1,10 +0,0 @@
@use "@angular/material" as mat;
$rose-theme: mat.define-theme(
(
color: (
theme-type: dark,
primary: mat.$rose-palette,
),
)
);