Browse Source

Escape HTML in markdown. (#955)

* Escape HTML in markdown.

* Escape variables for message history.

* Rename escape method.
pull/957/head
Sebastian Stehle 3 years ago
committed by GitHub
parent
commit
cf4efc52ea
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      frontend/src/app/declarations.d.ts
  2. 2
      frontend/src/app/features/rules/pages/rule/rule-page.component.ts
  3. 2
      frontend/src/app/features/rules/pages/simulator/rule-simulator-page.component.ts
  4. 2
      frontend/src/app/features/rules/shared/actions/generic-action.component.html
  5. 89
      frontend/src/app/framework/angular/markdown.directive.spec.ts
  6. 44
      frontend/src/app/framework/angular/markdown.directive.ts
  7. 2
      frontend/src/app/framework/angular/pager.component.ts
  8. 28
      frontend/src/app/framework/angular/pipes/markdown.pipe.spec.ts
  9. 34
      frontend/src/app/framework/angular/pipes/markdown.pipe.ts
  10. 1
      frontend/src/app/framework/internal.ts
  11. 2
      frontend/src/app/framework/services/localizer.service.ts
  12. 2
      frontend/src/app/framework/utils/date-time.spec.ts
  13. 61
      frontend/src/app/framework/utils/markdown.ts
  14. 2
      frontend/src/app/framework/utils/text-measurer.ts
  15. 66
      frontend/src/app/shared/services/history.service.spec.ts
  16. 69
      frontend/src/app/shared/services/history.service.ts
  17. 2
      frontend/src/app/shared/services/rules.service.spec.ts
  18. 2
      frontend/src/app/shared/services/schemas.service.spec.ts
  19. 4
      frontend/src/app/shared/state/asset-scripts.state.spec.ts
  20. 2
      frontend/src/app/shared/state/asset-scripts.state.ts
  21. 4
      frontend/src/app/shared/state/resolvers.spec.ts
  22. 2
      frontend/src/app/shared/state/rules.forms.ts
  23. 2
      frontend/src/app/shared/state/table-settings.spec.ts

4
frontend/src/app/declarations.d.ts

@ -18,3 +18,7 @@ declare module 'sortablejs' {
export function create(element: any, options: any): Ref;
}
declare namespace marked {
export function escape(input: string): string;
}

2
frontend/src/app/features/rules/pages/rule/rule-page.component.ts

@ -10,7 +10,7 @@ import { AbstractControl } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { debounceTime, Subscription } from 'rxjs';
import { ActionForm, ALL_TRIGGERS, MessageBus, ResourceOwner, RuleDto, RuleElementDto, RulesService, RulesState, SchemasState, TriggerForm, value$ } from '@app/shared';
import { RuleConfigured } from '../messages';
import { RuleConfigured } from './../messages';
@Component({
selector: 'sqx-rule-page',

2
frontend/src/app/features/rules/pages/simulator/rule-simulator-page.component.ts

@ -8,7 +8,7 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { MessageBus, ResourceOwner, RuleSimulatorState, SimulatedRuleEventDto } from '@app/shared';
import { RuleConfigured } from '../messages';
import { RuleConfigured } from './../messages';
@Component({
selector: 'sqx-simulator-events-page',

2
frontend/src/app/features/rules/shared/actions/generic-action.component.html

@ -47,7 +47,7 @@
</ng-container>
<sqx-form-hint>
<span [sqxMarkdown]="property.description" [inline]="true" [html]="true"></span>
<span [sqxMarkdown]="property.description" [inline]="true"></span>
<div *ngIf="property.isFormattable">
{{ 'rules.advancedFormattingHint' | sqxTranslate }}: <a tabindex="-1" href="https://docs.squidex.io/concepts/rules#3-formatting" sqxExternalLink>{{ 'common.documentation' | sqxTranslate }}</a>

89
frontend/src/app/framework/angular/markdown.directive.spec.ts

@ -0,0 +1,89 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Renderer2 } from '@angular/core';
import { IMock, It, Mock, Times } from 'typemoq';
import { MarkdownDirective } from './markdown.directive';
describe('MarkdownDirective', () => {
let renderer: IMock<Renderer2>;
let markdownElement = {};
let markdownDirective: MarkdownDirective;
beforeEach(() => {
renderer = Mock.ofType<Renderer2>();
markdownElement = {};
markdownDirective = new MarkdownDirective(markdownElement as any, renderer.object);
});
it('should render empty text as text', () => {
markdownDirective.markdown = '';
markdownDirective.ngOnChanges();
verifyTextRender('');
});
it('should render as text if result has no tags', () => {
markdownDirective.inline = true;
markdownDirective.markdown = 'markdown';
markdownDirective.ngOnChanges();
verifyTextRender('markdown');
});
it('should render as text if optional', () => {
markdownDirective.optional = true;
markdownDirective.markdown = '**bold**';
markdownDirective.ngOnChanges();
verifyTextRender('**bold**');
});
it('should render if optional with exclamation', () => {
markdownDirective.optional = true;
markdownDirective.markdown = '!**bold**';
markdownDirective.ngOnChanges();
verifyHtmlRender('<strong>bold</strong>');
});
it('should render as HTML if allowed', () => {
markdownDirective.inline = false;
markdownDirective.markdown = '**bold**';
markdownDirective.ngOnChanges();
verifyHtmlRender('<p><strong>bold</strong></p>\n');
});
it('should render as inline HTML if allowed', () => {
markdownDirective.markdown = '!**bold**';
markdownDirective.ngOnChanges();
verifyHtmlRender('<strong>bold</strong>');
});
it('should render HTML escaped', () => {
markdownDirective.inline = false;
markdownDirective.markdown = '<h1>Header</h1>';
markdownDirective.ngOnChanges();
verifyHtmlRender('<p>&lt;h1&gt;Header&lt;/h1&gt;</p>\n');
});
function verifyTextRender(text: string) {
renderer.verify(x => x.setProperty(It.isAny(), 'textContent', text), Times.once());
expect().nothing();
}
function verifyHtmlRender(text: string) {
renderer.verify(x => x.setProperty(It.isAny(), 'innerHTML', text), Times.once());
expect().nothing();
}
});

44
frontend/src/app/framework/angular/markdown.directive.ts

@ -6,24 +6,7 @@
*/
import { Directive, ElementRef, Input, OnChanges, Renderer2 } from '@angular/core';
import { marked } from 'marked';
const RENDERER_DEFAULT = new marked.Renderer();
const RENDERER_INLINE = new marked.Renderer();
RENDERER_DEFAULT.link = (href, _, text) => {
if (href && href.startsWith('mailto')) {
return text;
} else {
return `<a href="${href}" target="_blank", rel="noopener">${text} <i class="icon-external-link"></i></a>`;
}
};
RENDERER_INLINE.paragraph = (text) => {
return text;
};
RENDERER_INLINE.link = RENDERER_DEFAULT.link;
import { renderMarkdown } from '@app/framework/internal';
@Directive({
selector: '[sqxMarkdown]',
@ -35,9 +18,6 @@ export class MarkdownDirective implements OnChanges {
@Input()
public inline = true;
@Input()
public html = false;
@Input()
public optional = false;
@ -50,22 +30,28 @@ export class MarkdownDirective implements OnChanges {
public ngOnChanges() {
let html = '';
const markdown = this.markdown;
let markdown = this.markdown;
const hasExclamation = markdown.indexOf('!') === 0;
if (hasExclamation) {
markdown = markdown.substring(1);
}
if (!markdown) {
html = markdown;
} else if (this.optional && markdown.indexOf('!') !== 0) {
} else if (this.optional && !hasExclamation) {
html = markdown;
} else if (this.markdown) {
const renderer = this.inline ? RENDERER_INLINE : RENDERER_DEFAULT;
html = marked(this.markdown, { renderer });
html = renderMarkdown(markdown, this.inline);
}
if (!this.html && (!html || html === this.markdown || html.indexOf('<') < 0)) {
this.renderer.setProperty(this.element.nativeElement, 'textContent', html);
} else {
const hasHtml = html.indexOf('<') >= 0;
if (hasHtml) {
this.renderer.setProperty(this.element.nativeElement, 'innerHTML', html);
} else {
this.renderer.setProperty(this.element.nativeElement, 'textContent', html);
}
}
}

2
frontend/src/app/framework/angular/pager.component.ts

@ -6,7 +6,7 @@
*/
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
import { PagingInfo } from '../state';
import { PagingInfo } from './../state';
export const PAGE_SIZES: ReadonlyArray<number> = [10, 20, 30, 50];

28
frontend/src/app/framework/angular/pipes/markdown.pipe.spec.ts

@ -8,21 +8,29 @@
import { MarkdownInlinePipe, MarkdownPipe } from './markdown.pipe';
describe('MarkdownInlinePipe', () => {
const pipe = new MarkdownInlinePipe();
it('should convert link to html', () => {
const actual = new MarkdownInlinePipe().transform('[link-name](link-url)');
const actual = pipe.transform('[link-name](link-url)');
expect(actual).toBe('<a href="link-url" target="_blank", rel="noopener">link-name <i class="icon-external-link"></i></a>');
});
it('should convert markdown to html', () => {
const actual = new MarkdownInlinePipe().transform('*bold*');
const actual = pipe.transform('*bold*');
expect(actual).toBe('<em>bold</em>');
});
it('should escape input html', () => {
const actual = pipe.transform('<h1>Header</h1>');
expect(actual).toBe('&lt;h1&gt;Header&lt;/h1&gt;');
});
[null, undefined, ''].forEach(x => {
it('should return empty string for invalid value', () => {
const actual = new MarkdownInlinePipe().transform(x);
const actual = pipe.transform(x);
expect(actual).toBe('');
});
@ -30,21 +38,29 @@ describe('MarkdownInlinePipe', () => {
});
describe('MarkdownPipe', () => {
const pipe = new MarkdownPipe();
it('should convert link to html', () => {
const actual = new MarkdownPipe().transform('[link-name](link-url)');
const actual = pipe.transform('[link-name](link-url)');
expect(actual).toBe('<p><a href="link-url" target="_blank", rel="noopener">link-name <i class="icon-external-link"></i></a></p>\n');
});
it('should convert markdown to html', () => {
const actual = new MarkdownPipe().transform('*bold*');
const actual = pipe.transform('*bold*');
expect(actual).toBe('<p><em>bold</em></p>\n');
});
it('should escape input html', () => {
const actual = pipe.transform('<h1>Header</h1>');
expect(actual).toBe('<p>&lt;h1&gt;Header&lt;/h1&gt;</p>\n');
});
[null, undefined, ''].forEach(x => {
it('should return empty string for invalid value', () => {
const actual = new MarkdownPipe().transform(x);
const actual = pipe.transform(x);
expect(actual).toBe('');
});

34
frontend/src/app/framework/angular/pipes/markdown.pipe.ts

@ -6,25 +6,7 @@
*/
import { Pipe, PipeTransform } from '@angular/core';
import { marked } from 'marked';
const renderer = new marked.Renderer();
renderer.link = (href, _, text) => {
if (href && href.startsWith('mailto')) {
return text;
} else {
return `<a href="${href}" target="_blank", rel="noopener">${text} <i class="icon-external-link"></i></a>`;
}
};
const inlinerRenderer = new marked.Renderer();
inlinerRenderer.paragraph = (text) => {
return text;
};
inlinerRenderer.link = renderer.link;
import { renderMarkdown } from '@app/framework/internal';
@Pipe({
name: 'sqxMarkdown',
@ -32,11 +14,7 @@ inlinerRenderer.link = renderer.link;
})
export class MarkdownPipe implements PipeTransform {
public transform(text: string | undefined | null): string {
if (text) {
return marked(text, { renderer });
} else {
return '';
}
return renderMarkdown(text, false);
}
}
@ -46,10 +24,6 @@ export class MarkdownPipe implements PipeTransform {
})
export class MarkdownInlinePipe implements PipeTransform {
public transform(text: string | undefined | null): string {
if (text) {
return marked(text, { renderer: inlinerRenderer });
} else {
return '';
}
return renderMarkdown(text, true);
}
}
}

1
frontend/src/app/framework/internal.ts

@ -33,6 +33,7 @@ export * from './utils/hateos';
export * from './utils/interpolator';
export * from './utils/keys';
export * from './utils/math-helper';
export * from './utils/markdown';
export * from './utils/modal-positioner';
export * from './utils/modal-view';
export * from './utils/picasso';

2
frontend/src/app/framework/services/localizer.service.ts

@ -6,7 +6,7 @@
*/
import { Injectable } from '@angular/core';
import { compareStrings } from '../utils/array-helper';
import { compareStrings } from './../utils/array-helper';
@Injectable()
export class LocalizerService {

2
frontend/src/app/framework/utils/date-time.spec.ts

@ -5,7 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { DateHelper } from '..';
import { DateHelper } from './..';
import { DateTime } from './date-time';
describe('DateTime', () => {

61
frontend/src/app/framework/utils/markdown.ts

@ -0,0 +1,61 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { marked } from 'marked';
function renderLink(href: string, _: string, text: string) {
if (href && href.startsWith('mailto')) {
return text;
} else {
return `<a href="${href}" target="_blank", rel="noopener">${text} <i class="icon-external-link"></i></a>`;
}
}
function renderInlineParagraph(text: string) {
return text;
}
const RENDERER_DEFAULT = new marked.Renderer();
const RENDERER_INLINE = new marked.Renderer();
RENDERER_INLINE.paragraph = renderInlineParagraph;
RENDERER_INLINE.link = renderLink;
RENDERER_DEFAULT.link = renderLink;
export function renderMarkdown(input: string | undefined | null, inline: boolean) {
if (!input) {
return '';
}
input = escapeHTML(input);
if (inline) {
return marked(input, { renderer: RENDERER_INLINE });
} else {
return marked(input, { renderer: RENDERER_DEFAULT });
}
}
const escapeTestNoEncode = /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/;
const escapeReplaceNoEncode = new RegExp(escapeTestNoEncode.source, 'g');
const escapeReplacements = {
'&' : '&amp;',
'<' : '&lt;',
'>' : '&gt;',
'"' : '&quot;',
'\'': '&#39;',
};
const getEscapeReplacement = (ch: string) => escapeReplacements[ch];
export function escapeHTML(html: string) {
if (escapeTestNoEncode.test(html)) {
return html.replace(escapeReplaceNoEncode, getEscapeReplacement);
}
return html;
}

2
frontend/src/app/framework/utils/text-measurer.ts

@ -6,7 +6,7 @@
*/
import { ElementRef } from '@angular/core';
import { Types } from '../internal';
import { Types } from './../internal';
let CANVAS: HTMLCanvasElement | null = null;

66
frontend/src/app/shared/services/history.service.spec.ts

@ -7,7 +7,71 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing';
import { ApiUrlConfig, DateTime, HistoryEventDto, HistoryService, Version } from '@app/shared/internal';
import { firstValueFrom, of } from 'rxjs';
import { IMock, Mock } from 'typemoq';
import { ApiUrlConfig, DateTime, formatHistoryMessage, HistoryEventDto, HistoryService, UsersProviderService, Version } from '@app/shared/internal';
describe('formatHistoryMessage', () => {
let userProvider: IMock<UsersProviderService>;
beforeEach(() => {
userProvider = Mock.ofType<UsersProviderService>();
});
it('should provide simple message', async () => {
const message = await firstValueFrom(formatHistoryMessage('message', userProvider.object));
expect(message).toEqual('message');
});
it('should embed marker', async () => {
const message = await firstValueFrom(formatHistoryMessage('{Name}', userProvider.object));
expect(message).toEqual('<span class="marker-ref">Name</span>');
});
it('should embed marker ref and escape HTML', async () => {
const message = await firstValueFrom(formatHistoryMessage('{<h1>HTML</h1>}', userProvider.object));
expect(message).toEqual('<span class="marker-ref">&lt;h1&gt;HTML&lt;/h1&gt;</span>');
});
it('should embed user ref with unknown type', async () => {
const message = await firstValueFrom(formatHistoryMessage('{unknown:User1}', userProvider.object));
expect(message).toEqual('<span class="user-ref">User1</span>');
});
it('should embed user ref with client', async () => {
const message = await firstValueFrom(formatHistoryMessage('{user:unknown:Client1}', userProvider.object));
expect(message).toEqual('<span class="user-ref">Client1-client</span>');
});
it('should embed user ref with client ending with client', async () => {
const message = await firstValueFrom(formatHistoryMessage('{user:client:Sample-Client}', userProvider.object));
expect(message).toEqual('<span class="user-ref">Sample-Client</span>');
});
it('should embed user ref with subject', async () => {
userProvider.setup(x => x.getUser('1', null))
.returns(() => of({ id: '1', displayName: 'User1' }));
const message = await firstValueFrom(formatHistoryMessage('{user:subject:1}', userProvider.object));
expect(message).toEqual('<span class="user-ref">User1</span>');
});
it('should embed user ref with id', async () => {
userProvider.setup(x => x.getUser('1', null))
.returns(() => of({ id: '1', displayName: 'User1' }));
const message = await firstValueFrom(formatHistoryMessage('{user:1}', userProvider.object));
expect(message).toEqual('<span class="user-ref">User1</span>');
});
});
describe('HistoryService', () => {
beforeEach(() => {

69
frontend/src/app/shared/services/history.service.ts

@ -7,9 +7,9 @@
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { firstValueFrom, from, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ApiUrlConfig, DateTime, pretifyError, Version } from '@app/framework';
import { ApiUrlConfig, DateTime, escapeHTML, pretifyError, Version } from '@app/framework';
import { UsersProviderService } from './users-provider.service';
export class HistoryEventDto {
@ -24,43 +24,50 @@ export class HistoryEventDto {
}
}
const REPLACEMENT_TEMP = '$TEMP$';
export function formatHistoryMessage(message: string, users: UsersProviderService): Observable<string> {
const userName = (userId: string) => {
const parts = userId.split(':');
if (parts.length === 1) {
return users.getUser(parts[0], null).pipe(map(u => u.displayName));
} else if (parts[0] === 'subject') {
return users.getUser(parts[1], null).pipe(map(u => u.displayName));
} else if (parts[1].endsWith('client')) {
return of(parts[1]);
} else {
return of(`${parts[1]}-client`);
}
};
async function getUserName(id: string): Promise<string> {
const user = await firstValueFrom(users.getUser(id, null));
let foundUserId: string | null = null;
return user.displayName;
}
message = message.replace(/{([^\s:]*):([^}]*)}/, (match: string, type: string, id: string) => {
if (type === 'user') {
foundUserId = id;
return REPLACEMENT_TEMP;
} else {
return id;
async function format(message: string): Promise<string> {
const placeholderMatches = message.matchAll(/{(?<type>[^\s:]*):(?<id>[^}]*)}/g) || [];
const placeholderValues: string[] = [];
for (const match of placeholderMatches) {
const { id, type } = match.groups!;
if (type !== 'user') {
placeholderValues.push(id);
continue;
}
const parts = id.split(':');
if (parts.length === 1) {
placeholderValues.push(await getUserName(parts[0]));
} else if (parts[0] === 'subject') {
placeholderValues.push(await getUserName(parts[1]));
} else if (parts[1].toLowerCase().endsWith('client')) {
placeholderValues.push(parts[1]);
} else {
placeholderValues.push(`${parts[1]}-client`);
}
}
});
message = message.replace(/{([^}]*)}/g, (match: string, marker: string) => {
return `<span class="marker-ref">${marker}</span>`;
});
message = message.replace(/{([^\s:]*):([^}]*)}/, () => {
return `<span class="user-ref">${escapeHTML(placeholderValues.shift() || '')}</span>`;
});
message = message.replace(/{([^}]*)}/g, (match: string, marker: string) => {
return `<span class="marker-ref">${escapeHTML(marker)}</span>`;
});
if (foundUserId) {
return userName(foundUserId).pipe(map(t => message.replace(REPLACEMENT_TEMP, `<span class="user-ref">${t}</span>`)));
return message;
}
return of(message);
return from(format(message));
}
@Injectable()

2
frontend/src/app/shared/services/rules.service.spec.ts

@ -8,7 +8,7 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing';
import { ApiUrlConfig, DateTime, Resource, ResourceLinks, RuleDto, RuleElementDto, RuleElementPropertyDto, RuleEventDto, RuleEventsDto, RulesDto, RulesService, Version } from '@app/shared/internal';
import { RuleCompletions } from '..';
import { RuleCompletions } from './..';
import { SimulatedRuleEventDto, SimulatedRuleEventsDto } from './rules.service';
describe('RulesService', () => {

2
frontend/src/app/shared/services/schemas.service.spec.ts

@ -8,7 +8,7 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing';
import { ApiUrlConfig, createProperties, DateTime, FieldRule, NestedFieldDto, Resource, ResourceLinks, RootFieldDto, SchemaDto, SchemaPropertiesDto, SchemasDto, SchemasService, Version } from '@app/shared/internal';
import { SchemaCompletions } from '..';
import { SchemaCompletions } from './..';
describe('SchemasService', () => {
const version = new Version('1');

4
frontend/src/app/shared/state/asset-scripts.state.spec.ts

@ -9,8 +9,8 @@ import { of, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq';
import { DialogService, versioned } from '@app/shared/internal';
import { AppsService, AssetScriptsPayload } from '../services/apps.service';
import { createAssetScripts } from '../services/apps.service.spec';
import { AppsService, AssetScriptsPayload } from './../services/apps.service';
import { createAssetScripts } from './../services/apps.service.spec';
import { TestValues } from './_test-helpers';
import { AssetScriptsState } from './asset-scripts.state';

2
frontend/src/app/shared/state/asset-scripts.state.ts

@ -9,7 +9,7 @@ import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { finalize, tap } from 'rxjs/operators';
import { DialogService, LoadingState, Resource, shareSubscribed, State, Version } from '@app/framework';
import { AppsService, AssetScripts, AssetScriptsPayload } from '../services/apps.service';
import { AppsService, AssetScripts, AssetScriptsPayload } from './../services/apps.service';
import { AppsState } from './apps.state';
interface Snapshot extends LoadingState {

4
frontend/src/app/shared/state/resolvers.spec.ts

@ -8,8 +8,8 @@
import { firstValueFrom, of, throwError } from 'rxjs';
import { IMock, Mock, Times } from 'typemoq';
import { UIOptions } from '@app/framework';
import { ContentsService } from '../services/contents.service';
import { createContent } from '../services/contents.service.spec';
import { ContentsService } from './../services/contents.service';
import { createContent } from './../services/contents.service.spec';
import { TestValues } from './_test-helpers';
import { ResolveContents } from './resolvers';

2
frontend/src/app/shared/state/rules.forms.ts

@ -7,7 +7,7 @@
import { AbstractControl, UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms';
import { ExtendedFormGroup, Form, ValidatorsEx } from '@app/framework';
import { RuleElementDto } from '../services/rules.service';
import { RuleElementDto } from './../services/rules.service';
export class ActionForm extends Form<any, UntypedFormGroup> {
constructor(public readonly definition: RuleElementDto,

2
frontend/src/app/shared/state/table-settings.spec.ts

@ -9,7 +9,7 @@ import { of } from 'rxjs';
import { IMock, It, Mock, Times } from 'typemoq';
import { DateTime, Version } from '@app/framework';
import { createProperties, FieldSizes, META_FIELDS, RootFieldDto, SchemaDto, TableSettings, UIState } from '@app/shared/internal';
import { FieldWrappings } from '..';
import { FieldWrappings } from './..';
describe('TableSettings', () => {
let uiState: IMock<UIState>;

Loading…
Cancel
Save