Browse Source

Scripting auto-completion. (#656)

pull/662/head
Sebastian Stehle 5 years ago
committed by GitHub
parent
commit
6affc2b7ed
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 104
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptingCompletion.cs
  2. 55
      backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs
  3. 2
      frontend/app-config/webpack.config.js
  4. 6
      frontend/app/features/schemas/pages/schema/scripts/schema-scripts-form.component.html
  5. 14
      frontend/app/features/schemas/pages/schema/scripts/schema-scripts-form.component.ts
  6. 7
      frontend/app/framework/angular/forms/editors/code-editor.component.scss
  7. 40
      frontend/app/framework/angular/forms/editors/code-editor.component.ts
  8. 8
      frontend/app/shared/services/schemas.service.ts
  9. 18
      frontend/app/theme/_common.scss

104
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptingCompletion.cs

@ -0,0 +1,104 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Linq;
using Squidex.Domain.Apps.Core.Schemas;
namespace Squidex.Domain.Apps.Core.Scripting
{
public sealed class ScriptingCompletion
{
private readonly Stack<string> prefixes = new Stack<string>();
private readonly HashSet<(string, string)> result = new HashSet<(string, string)>();
public IReadOnlyList<(string Name, string Description)> GetCompletion(Schema schema, PartitionResolver partitionResolver)
{
Push("ctx", "The context object holding all values.");
Add("appId", "The ID of the current app.");
Add("appName", "The name of the current app.");
Add("contentId", "The ID of the content item.");
Add("operation", "The currnet query operation.");
Add("status", "The status of the content item");
Add("statusOld", "The old status of the content item.");
Push("user", "Information about the current user.");
Add("id", "The ID of the user.");
Add("claims", "The additional properties of the user.");
Add("claims.key", "The additional property of the user with name 'key'.");
Add("claims['key']", "The additional property of the user with name 'key'.");
Add("email", "The email address of the current user.");
Add("isClient", "True when the current user is a client.");
Pop();
Push("data", "The data of the content item.");
AddData(schema, partitionResolver);
Pop();
Push("oldData", "The old data of the content item.");
AddData(schema, partitionResolver);
Pop();
Pop();
Add("replace()",
"Tell Squidex that you have modified the data and that the change should be applied.");
Add("disallow()",
"Tell Squidex to not allow the current operation and to return a 403 (Forbidden).");
Add("reject('Reason')",
"Tell Squidex to reject the current operation and to return a 403 (Forbidden).");
return result.OrderBy(x => x.Item1).ToList();
}
private void AddData(Schema schema, PartitionResolver partitionResolver)
{
foreach (var field in schema.Fields.Where(x => x.IsForApi(true)))
{
Push(field.Name, $"The values of the '{field.DisplayName()}' field.");
foreach (var partition in partitionResolver(field.Partitioning).AllKeys)
{
Push(partition, $"The '{partition}' value of the '{field.DisplayName()}' field.");
if (field is ArrayField arrayField)
{
foreach (var nestedField in arrayField.Fields.Where(x => x.IsForApi(true)))
{
Push(field.Name, $"The value of the '{nestedField.DisplayName()}' nested field.");
Pop();
}
}
Pop();
}
Pop();
}
}
private void Add(string name, string description)
{
result.Add((string.Join('.', prefixes.Reverse().Union(Enumerable.Repeat(name, 1))), description));
}
private void Push(string prefix, string description)
{
Add(prefix, description);
prefixes.Push(prefix);
}
private void Pop()
{
prefixes.Pop();
}
}
}

55
backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs

@ -6,12 +6,16 @@
// ==========================================================================
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using NSwag.Annotations;
using Squidex.Areas.Api.Controllers.Schemas.Models;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.Schemas.Commands;
using Squidex.Infrastructure;
@ -78,20 +82,9 @@ namespace Squidex.Areas.Api.Controllers.Schemas
[ApiCosts(0)]
public async Task<IActionResult> GetSchema(string app, string name)
{
ISchemaEntity? schema;
var schema = await GetSchemaAsync(name);
if (Guid.TryParse(name, out var guid))
{
var schemaId = DomainId.Create(guid);
schema = await appProvider.GetSchemaAsync(AppId, schemaId);
}
else
{
schema = await appProvider.GetSchemaAsync(AppId, name);
}
if (schema == null || schema.IsDeleted)
if (schema == null)
{
return NotFound();
}
@ -346,6 +339,42 @@ namespace Squidex.Areas.Api.Controllers.Schemas
return NoContent();
}
[HttpGet]
[Route("apps/{app}/schemas/{name}/completion")]
[ApiPermissionOrAnonymous]
[ApiCosts(1)]
[OpenApiIgnore]
public async Task<IActionResult> GetScriptCompletiong(string app, string name)
{
var schema = await GetSchemaAsync(name);
if (schema == null)
{
return NotFound();
}
var completer = new ScriptingCompletion();
var completion = completer.GetCompletion(schema.SchemaDef, App.PartitionResolver());
var result = completion.Select(x => new { x.Name, x.Description });
return Ok(result);
}
private Task<ISchemaEntity?> GetSchemaAsync(string name)
{
if (Guid.TryParse(name, out var guid))
{
var schemaId = DomainId.Create(guid);
return appProvider.GetSchemaAsync(AppId, schemaId);
}
else
{
return appProvider.GetSchemaAsync(AppId, name);
}
}
private async Task<SchemaDetailsDto> InvokeCommandAsync(ICommand command)
{
var context = await CommandBus.PublishAsync(command);

2
frontend/app-config/webpack.config.js

@ -225,7 +225,9 @@ module.exports = function (env) {
{ from: './node_modules/ace-builds/src-min/ace.js', to: 'dependencies/ace/ace.js' },
{ from: './node_modules/ace-builds/src-min/mode-*.js', to: 'dependencies/ace/[name].[ext]' },
{ from: './node_modules/ace-builds/src-min/worker-*.js', to: 'dependencies/ace/[name].[ext]' },
{ from: './node_modules/ace-builds/src-min/snippets', to: 'dependencies/ace/snippets' },
{ from: './node_modules/ace-builds/src-min/ext-modelist.js', to: 'dependencies/ace/ext/modelist.js' },
{ from: './node_modules/ace-builds/src-min/ext-language_tools.js', to: 'dependencies/ace/ext/language_tools.js' },
{ from: './node_modules/video.js/dist/video.min.js', to: 'dependencies/videojs' },
{ from: './node_modules/video.js/dist/video-js.min.css', to: 'dependencies/videojs' },

6
frontend/app/features/schemas/pages/schema/scripts/schema-scripts-form.component.html

@ -2,7 +2,7 @@
<div class="inner-header">
<ul class="nav nav-tabs2">
<li class="nav-item" *ngFor="let script of editForm.form.controls | sqxKeys">
<a class="nav-link" [class.active]="selectedField === script" (click)="selectField(script)">
<a class="nav-link" [class.active]="schemaScript === script" (click)="selectField(script)">
{{script | titlecase}}
</a>
</li>
@ -15,8 +15,8 @@
<div class="inner-main">
<ng-container *ngFor="let script of editForm.form.controls | sqxKeys">
<ng-container *ngIf="selectedField === script">
<sqx-code-editor [noBorder]="true" [formControlName]="script"></sqx-code-editor>
<ng-container *ngIf="schemaScript === script">
<sqx-code-editor [noBorder]="true" [formControlName]="script" [completion]="schemaCompletions | async"></sqx-code-editor>
</ng-container>
</ng-container>
</div>

14
frontend/app/features/schemas/pages/schema/scripts/schema-scripts-form.component.ts

@ -7,7 +7,8 @@
import { Component, Input, OnChanges } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { EditScriptsForm, SchemaDetailsDto, SchemasState } from '@app/shared';
import { AppsState, EditScriptsForm, SchemaCompletions, SchemaDetailsDto, SchemasService, SchemasState } from '@app/shared';
import { EMPTY, Observable } from 'rxjs';
@Component({
selector: 'sqx-schema-scripts-form',
@ -18,15 +19,18 @@ export class SchemaScriptsFormComponent implements OnChanges {
@Input()
public schema: SchemaDetailsDto;
public selectedField = 'query';
public schemaScript = 'query';
public schemaCompletions: Observable<SchemaCompletions> = EMPTY;
public editForm = new EditScriptsForm(this.formBuilder);
public isEditable = false;
constructor(
private readonly appsState: AppsState,
private readonly formBuilder: FormBuilder,
private readonly schemasState: SchemasState
private readonly schemasState: SchemasState,
private readonly schemasService: SchemasService
) {
}
@ -35,10 +39,12 @@ export class SchemaScriptsFormComponent implements OnChanges {
this.editForm.load(this.schema.scripts);
this.editForm.setEnabled(this.isEditable);
this.schemaCompletions = this.schemasService.getCompletions(this.appsState.appName, this.schema.name);
}
public selectField(field: string) {
this.selectedField = field;
this.schemaScript = field;
}
public saveSchema() {

7
frontend/app/framework/angular/forms/editors/code-editor.component.scss

@ -4,12 +4,19 @@
.ace_editor {
background: $color-dark-foreground;
border: 1px solid $color-input;
border-radius: 0;
&.no-border {
border: 0;
}
}
.ace_tooltip {
background: $color-dark-foreground;
border: 1px solid $color-input;
border-radius: 0;
}
.ace_active-line,
.ace_gutter-active-line {
background: none !important;

40
frontend/app/framework/angular/forms/editors/code-editor.component.ts

@ -32,6 +32,7 @@ export class CodeEditorComponent extends StatefulControlComponent<{}, string> im
private valueChanged = new Subject();
private value = '';
private modelist: any;
private completions: ReadonlyArray<{ name: string, value: string }> = [];
@ViewChild('editor', { static: false })
public editor: ElementRef;
@ -51,6 +52,15 @@ export class CodeEditorComponent extends StatefulControlComponent<{}, string> im
@Input()
public height = 0;
@Input()
public set completion(value: ReadonlyArray<{ name: string, description: string }> | undefined) {
if (value) {
this.completions = value.map(({ name, description }) => ({ value: name, name, meta: 'context', description }));
} else {
this.completions = [];
}
}
constructor(changeDetector: ChangeDetectorRef,
private readonly resourceLoader: ResourceLoaderService
) {
@ -111,7 +121,8 @@ export class CodeEditorComponent extends StatefulControlComponent<{}, string> im
Promise.all([
this.resourceLoader.loadLocalScript('dependencies/ace/ace.js'),
this.resourceLoader.loadLocalScript('dependencies/ace/ext/modelist.js')
this.resourceLoader.loadLocalScript('dependencies/ace/ext/modelist.js'),
this.resourceLoader.loadLocalScript('dependencies/ace/ext/language_tools.js')
]).then(() => {
this.aceEditor = ace.edit(this.editor.nativeElement);
@ -124,8 +135,35 @@ export class CodeEditorComponent extends StatefulControlComponent<{}, string> im
this.setValue(this.value);
this.setMode();
const langTools = ace.require('ace/ext/language_tools');
if (langTools) {
this.aceEditor.setOptions({
enableBasicAutocompletion: true,
enableSnippets: true,
enableLiveAutocompletion: true
});
const previous = this.aceEditor.completers;
this.aceEditor.completers = [
previous[0], {
getCompletions: (editor: any, session: any, pos: any, prefix: any, callback: any) => {
callback(null, this.completions);
},
getDocTooltip: (item: any) => {
if (item.meta === 'context' && item.description) {
item.docHTML = `<b>${item.value}</b><hr></hr>${item.description}`;
}
},
identifierRegexps: [/[a-zA-Z_0-9\$\-\.\u00A2-\u2000\u2070-\uFFFF]/]
}
];
}
this.aceEditor.on('blur', () => {
this.changeValue();
this.callTouched();
});

8
frontend/app/shared/services/schemas.service.ts

@ -105,6 +105,8 @@ export const FIELD_RULE_ACTIONS: ReadonlyArray<FieldRuleAction> = [
'Require'
];
export type SchemaCompletions = ReadonlyArray<{ name: string, description: string }>;
export class SchemaDetailsDto extends SchemaDto {
public readonly contentFields: ReadonlyArray<RootFieldDto>;
@ -696,6 +698,12 @@ export class SchemasService {
}),
pretifyError('i18n:schemas.deleteFailed'));
}
public getCompletions(appName: string, schemaName: string): Observable<SchemaCompletions> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/schemas/${schemaName}/completion`);
return this.http.get<SchemaCompletions>(url);
}
}
function parseSchemas(response: any) {

18
frontend/app/theme/_common.scss

@ -252,6 +252,24 @@ body {
}
}
//
// ACE
//
// sass-lint:disable class-name-format
.ace_tooltip {
background: $color-dark-foreground;
border: 1px solid $color-input;
border-radius: 0;
font-size: 14px;
max-width: 300px;
min-width: 100px;
white-space: pre-wrap;
hr {
margin: .5rem 0;
}
}
//
// Animations
//

Loading…
Cancel
Save