diff --git a/application/client/src/app/schema/render/columns.ts b/application/client/src/app/schema/render/columns.ts index 6e5628d475..3c67f9a308 100644 --- a/application/client/src/app/schema/render/columns.ts +++ b/application/client/src/app/schema/render/columns.ts @@ -1,23 +1,176 @@ +import { scope } from '@platform/env/scope'; +import { hash } from '@platform/env/str'; import { Subject, Subjects } from '@platform/env/subscription'; +import { error } from '@platform/log/utils'; +import { bridge } from '@service/bridge'; import { LimittedValue } from '@ui/env/entities/value.limited'; +import * as num from '@platform/env/num'; + export interface Header { caption: string; desc: string; visible: boolean; width: LimittedValue | undefined; + color: string | undefined; index: number; } + export class Columns { - public readonly headers: Header[]; - protected styles: Array<{ [key: string]: string }> = []; + protected readonly styles: Map = new Map(); + protected readonly logger = scope.getLogger('Columns'); + protected readonly defaults: { + headers: { + caption: string; + desc: string; + }[]; + visability: boolean[] | boolean; + widths: number[]; + min: number[] | number; + max: number[] | number; + }; + protected hash!: string; + + protected setup(): void { + const headersVisability = + this.defaults.visability instanceof Array + ? this.defaults.visability + : Array.from({ length: this.defaults.headers.length }, () => true); + const maxWidths: number[] = + this.defaults.max instanceof Array + ? this.defaults.max + : (Array.from( + { length: this.defaults.headers.length }, + () => this.defaults.max, + ) as number[]); + const minWidths: number[] = + this.defaults.min instanceof Array + ? this.defaults.min + : (Array.from( + { length: this.defaults.headers.length }, + () => this.defaults.min, + ) as number[]); + this.headers.clear(); + this.styles.clear(); + this.defaults.headers.forEach( + ( + desc: { + caption: string; + desc: string; + }, + index: number, + ) => { + const header = { + caption: desc.caption, + desc: desc.desc, + width: + this.defaults.widths[index] === -1 + ? undefined + : new LimittedValue( + `column_width_${index}`, + minWidths[index], + maxWidths[index], + this.defaults.widths[index], + ), + visible: headersVisability[index], + color: undefined, + index, + }; + this.headers.set(index, header); + this.styles.set(index, {}); + }, + ); + this.update().all(); + this.hash = this.getHash(); + } + protected getHeader(index: number): Header | undefined { + const header = this.headers.get(index); + if (header === undefined) { + this.logger.error(`Fail to find column with index=${index}`); + } + return header; + } + protected getHash(): string { + return hash( + Array.from(this.headers.values()) + .map((header) => header.caption) + .join(';'), + ).toString(); + } + protected storage(): { load(): void; save(): void } { + return { + load: () => { + bridge + .storage(this.hash) + .read() + .then((content: string) => { + try { + const headers = JSON.parse(content); + if (!(headers instanceof Array)) { + this.logger.error( + `Content from file does not represent Headers as an Array. Gotten: ${typeof headers}`, + ); + return; + } + if (headers.length !== this.headers.size) { + this.logger.error( + `Mismatching header count from last session. Previous: ${headers.length}; current: ${this.headers.size}.`, + ); + return; + } + Array.from(this.headers.values()).forEach( + (header: Header, index: number) => { + if (headers[index].width !== undefined) { + this.width(index).set(headers[index].width); + } + if (headers[index].color !== undefined) { + this.color(index).set(headers[index].color); + } + if (header.visible !== headers[index].visible) { + this.visibility(index).set(headers[index].visible); + } + }, + ); + } catch (err) { + this.logger.error(`Fail to parse columns data due: ${error(err)}`); + } + }) + .catch((err: Error) => + this.logger.error(`Fail to load columns data due: ${err.message}`), + ); + }, + save: () => { + bridge + .storage(this.hash) + .write( + JSON.stringify( + Array.from(this.headers.values()).map((header) => { + return { + width: + header.width === undefined ? undefined : header.width.value, + color: header.color, + visible: header.visible, + }; + }), + ), + ) + .catch((err: Error) => { + this.logger.error(`Fail to save columns data due: ${err.message}`); + }); + }, + }; + } + + public readonly headers: Map = new Map(); public subjects: Subjects<{ resized: Subject; - visibility: Subject; + visibility: Subject; + colorize: Subject; }> = new Subjects({ resized: new Subject(), visibility: new Subject(), + colorize: new Subject(), }); constructor( @@ -30,68 +183,146 @@ export class Columns { min: number[] | number, max: number[] | number, ) { - const headersVisability = - visability instanceof Array - ? visability - : Array.from({ length: headers.length }, () => true); - const maxWidths = - max instanceof Array ? max : Array.from({ length: headers.length }, () => max); - const minWidths = - min instanceof Array ? min : Array.from({ length: headers.length }, () => min); - this.headers = headers.map((header, i) => { - return { - caption: header.caption, - desc: header.desc, - width: - widths[i] === -1 - ? undefined - : new LimittedValue( - `column_width_${i}`, - minWidths[i], - maxWidths[i], - widths[i], - ), - visible: headersVisability[i], - index: i, - }; - }); - this.styles = this.headers.map((h) => { - return { width: `${h.width === undefined ? '' : `${h.width.value}px`}` }; - }); + this.defaults = { + headers, + visability, + widths, + min, + max, + }; + this.setup(); + this.storage().load(); } - public visible(column: number): boolean { - if (this.headers[column] === undefined) { - throw new Error(`Invalid index of column`); - } - return this.headers[column].visible; + public visibility(index: number): { + get(): boolean; + set(value?: boolean): void; + } { + const header = this.getHeader(index); + return { + get: (): boolean => { + return header === undefined ? true : header.visible; + }, + set: (value?: boolean): void => { + if (header === undefined) { + return; + } + header.visible = value === undefined ? !header.visible : value; + this.headers.set(index, header); + this.subjects.get().visibility.emit(index); + this.storage().save(); + }, + }; } - public setWidth(column: number, width: number) { - if (isNaN(width) || !isFinite(width)) { - throw new Error(`Invalid width column value`); - } - const value = this.headers[column].width; - value !== undefined && value.set(width); - this.subjects.get().resized.emit(column); + public color(index: number): { + get(): string | undefined; + set(color: string | undefined): void; + } { + const header = this.getHeader(index); + return { + get: (): string | undefined => { + return header === undefined ? undefined : header.color; + }, + set: (color: string | undefined): void => { + if (header === undefined) { + return; + } + header.color = color; + this.headers.set(index, header); + this.update().styles(index); + this.subjects.get().colorize.emit(index); + this.storage().save(); + }, + }; + } + + public width(index: number): { + get(): number | undefined; + set(width: number): void; + } { + const header = this.getHeader(index); + return { + get: (): number | undefined => { + return header === undefined ? undefined : header.width?.value; + }, + set: (width: number): void => { + if (header === undefined) { + return; + } + if (!num.isValid(width)) { + this.logger.error(`Fail to set column's width: invalid width (${width})`); + return; + } + header.width !== undefined && header.width.set(width); + this.headers.set(index, header); + this.update().styles(index); + this.subjects.get().resized.emit(index); + this.storage().save(); + }, + }; } - public getWidth(column: number): number | undefined { - const value = this.headers[column].width; - return value !== undefined ? value.value : undefined; + public update(): { + all(): void; + styles(index: number): void; + } { + return { + all: (): void => { + this.headers.forEach((_header: Header, index: number) => { + this.update().styles(index); + this.subjects.get().visibility.emit(index); + this.subjects.get().resized.emit(index); + this.subjects.get().colorize.emit(index); + }); + }, + styles: (index: number): void => { + const style = this.styles.get(index); + if (style === undefined) { + this.logger.error(`Fail to find styles of column with index=${index}`); + return; + } + const width = this.width(index).get(); + if (width === undefined) { + style['width'] = ''; + } else { + style['width'] = `${width}px`; + } + const color = this.color(index).get(); + style['color'] = color !== undefined ? color : ''; + }, + }; } - public getStyle(column: number): { [key: string]: string } { - const style = this.styles[column]; + public style(index: number): { [key: string]: string } { + const style = this.styles.get(index); if (style === undefined) { + this.logger.error(`Fail to find styles of column with index=${index}`); return {}; } - const width = this.getWidth(column); - if (width === undefined) { - style['width'] = ''; - } else { - style['width'] = `${width}px`; - } return style; } + + public get(): { + all(): Header[]; + visible(): Header[]; + byIndex(index: number): Header | undefined; + } { + return { + all: (): Header[] => { + return Array.from(this.headers.values()); + }, + visible: (): Header[] => { + return Array.from(this.headers.values()).filter((h) => h.visible); + }, + byIndex: (index: number): Header | undefined => { + return this.getHeader(index); + }, + }; + } + + public reset(): void { + this.setup(); + this.storage().save(); + } } diff --git a/application/client/src/app/ui/elements/scrollarea/row/columns/cell.ts b/application/client/src/app/ui/elements/scrollarea/row/columns/cell.ts index 3f7f3fdf10..11f4019021 100644 --- a/application/client/src/app/ui/elements/scrollarea/row/columns/cell.ts +++ b/application/client/src/app/ui/elements/scrollarea/row/columns/cell.ts @@ -27,7 +27,7 @@ export class Cell { public update(): Update { const update = { styles: (): Update => { - this.styles = this._controller.getStyle(this.index); + this.styles = this._controller.style(this.index); return update; }, content: (content: string): Update => { @@ -39,7 +39,7 @@ export class Cell { return update; }, visability: (): Update => { - this.visible = this._controller.visible(this.index); + this.visible = this._controller.visibility(this.index).get(); return update; }, }; diff --git a/application/client/src/app/ui/elements/scrollarea/row/columns/component.ts b/application/client/src/app/ui/elements/scrollarea/row/columns/component.ts index 6326ed3068..ad7c9b0560 100644 --- a/application/client/src/app/ui/elements/scrollarea/row/columns/component.ts +++ b/application/client/src/app/ui/elements/scrollarea/row/columns/component.ts @@ -11,7 +11,7 @@ import { import { DomSanitizer } from '@angular/platform-browser'; import { Row } from '@schema/content/row'; import { Ilc, IlcInterface } from '@env/decorators/component'; -import { Columns as ColumnsController } from '@schema/render/columns'; +import { Columns as Controller } from '@schema/render/columns'; import { Cell } from './cell'; import { ChangesDetector } from '@ui/env/extentions/changes'; @@ -27,7 +27,7 @@ export class Columns extends ChangesDetector implements AfterContentInit { @Input() public row!: Row; public cells: Cell[] = []; - public controller!: ColumnsController; + public controller!: Controller; public visible: Cell[] = []; private _sanitizer: DomSanitizer; @@ -46,18 +46,25 @@ export class Columns extends ChangesDetector implements AfterContentInit { @HostBinding('style.color') color = ''; public ngAfterContentInit(): void { - this.controller = this.row.session.render.getBoundEntity() as ColumnsController; + this.controller = this.row.session.render.getBoundEntity() as Controller; this.cells = this.row.columns.map((s, i) => { return new Cell(this._sanitizer, this.controller, s, i); }); this.visible = this.cells.filter((c) => c.visible); this.env().subscriber.register( - this.controller.subjects.get().resized.subscribe((column) => { - this.cells[column].update().styles(); + this.controller.subjects.get().resized.subscribe((index: number) => { + this.cells[index].update().styles(); + this.detectChanges(); + }), + this.controller.subjects.get().visibility.subscribe((index: number) => { + this.cells[index].update().visability(); + this.visible = this.cells.filter((c) => c.visible); + this.detectChanges(); + }), + this.controller.subjects.get().colorize.subscribe((index: number) => { + this.cells[index].update().styles(); this.detectChanges(); }), - ); - this.env().subscriber.register( this.row.change.subscribe(() => { this.row.columns.map((s, i) => { if (this.cells[i] === undefined) { diff --git a/application/client/src/app/ui/views/workspace/headers/component.ts b/application/client/src/app/ui/views/workspace/headers/component.ts index 37e6f3e917..62dc909135 100644 --- a/application/client/src/app/ui/views/workspace/headers/component.ts +++ b/application/client/src/app/ui/views/workspace/headers/component.ts @@ -8,14 +8,18 @@ import { import { Ilc, IlcInterface } from '@env/decorators/component'; import { Columns, Header } from '@schema/render/columns'; import { Session } from '@service/session'; +import { contextmenu } from '@ui/service/contextmenu'; import { LimittedValue } from '@ui/env/entities/value.limited'; import { ChangesDetector } from '@ui/env/extentions/changes'; import { Direction } from '@directives/resizer'; +import { ViewWorkspaceHeadersMenuComponent } from './menu/component'; class RenderedHeader { public caption: string; public styles: { [key: string]: string } = {}; public width: LimittedValue | undefined; + public color: string | undefined; + public index: number; private _ref: Header; @@ -23,7 +27,9 @@ class RenderedHeader { this._ref = ref; this.caption = ref.caption; this.width = ref.width; + this.color = ref.color; this.width !== undefined && this.resize(this.width.value); + this.index = ref.index; } public resize(width: number) { @@ -61,13 +67,34 @@ export class ColumnsHeaders extends ChangesDetector implements AfterContentInit public ngAfterContentInit(): void { this.env().subscriber.register( this.session.stream.subjects.get().rank.subscribe(() => { - this.markChangesForCheck(); + this.detectChanges(); + }), + this.controller.subjects.get().visibility.subscribe(() => { + this.headers = this.controller + .get() + .visible() + .map((h) => new RenderedHeader(h)); + this.detectChanges(); }), ); - this.headers = this.controller.headers - .filter((h) => h.visible) + this.headers = this.controller + .get() + .visible() .map((h) => new RenderedHeader(h)); - this.markChangesForCheck(); + } + + public contextmenu(event: MouseEvent, index: number): void { + contextmenu.show({ + component: { + factory: ViewWorkspaceHeadersMenuComponent, + inputs: { + index, + controller: this.controller, + }, + }, + x: event.pageX, + y: event.pageY, + }); } public ngGetOffsetStyle(): { [key: string]: string } { @@ -78,15 +105,15 @@ export class ColumnsHeaders extends ChangesDetector implements AfterContentInit }; } - public ngResize(width: number, header: RenderedHeader, index: number) { + public ngResize(width: number, header: RenderedHeader) { header.resize(width); - this.markChangesForCheck(); - this.controller.subjects.get().resized.emit(index); + this.controller.width(header.index).set(width); + this.detectChanges(); } public setOffset(left: number): void { this.offset = left; - this.markChangesForCheck(); + this.detectChanges(); } } export interface ColumnsHeaders extends IlcInterface {} diff --git a/application/client/src/app/ui/views/workspace/headers/menu/component.ts b/application/client/src/app/ui/views/workspace/headers/menu/component.ts new file mode 100644 index 0000000000..3776b20f70 --- /dev/null +++ b/application/client/src/app/ui/views/workspace/headers/menu/component.ts @@ -0,0 +1,77 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Input, + SimpleChange, + AfterContentInit, +} from '@angular/core'; +import { Columns } from '@schema/render/columns'; +import { ChangesDetector } from '@ui/env/extentions/changes'; +import { CColors } from '@ui/styles/colors'; + +@Component({ + selector: 'app-scrollarea-rows-columns-headers-context-menu', + styleUrls: ['./styles.less'], + templateUrl: './template.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ViewWorkspaceHeadersMenuComponent extends ChangesDetector implements AfterContentInit { + protected clickOnCheckbox: boolean = false; + protected switch(index: number): void { + this.index = index; + const header = this.controller.get().byIndex(index); + this.color = header === undefined ? undefined : header.color; + } + + public colors: string[] = CColors; + public color: string | undefined; + + @Input() public index!: number; + @Input() public controller!: Columns; + + constructor(cdRef: ChangeDetectorRef) { + super(cdRef); + } + + public ngAfterContentInit(): void { + this.switch(this.index); + } + + public ngOnContainerClick(index: number): void { + if (this.clickOnCheckbox) { + this.clickOnCheckbox = false; + return; + } + this.switch(index); + this.detectChanges(); + } + + public ngOnCheckboxClick(): void { + this.clickOnCheckbox = true; + } + + public ngOnCheckboxChange(event: SimpleChange, index: number): void { + this.controller.visibility(index).set(event as unknown as boolean); + this.detectChanges(); + } + + public ngOnColorClick(color: string): void { + this.controller.color(this.index).set(color === CColors[0] ? undefined : color); + this.switch(this.index); + this.detectChanges(); + } + + public isColorSelected(color: string): boolean { + if (this.color === undefined && color === CColors[0]) { + return true; + } + return this.color === color; + } + + public reset() { + this.controller.reset(); + this.switch(this.index); + this.detectChanges(); + } +} diff --git a/application/client/src/app/ui/views/workspace/headers/menu/styles.less b/application/client/src/app/ui/views/workspace/headers/menu/styles.less new file mode 100644 index 0000000000..4d5924c350 --- /dev/null +++ b/application/client/src/app/ui/views/workspace/headers/menu/styles.less @@ -0,0 +1,100 @@ +@import '../../../../styles/variables.less'; + +:host { + position: relative; + display: flex; + flex-direction: row; + padding: 8px; + & ul.columns{ + position: relative; + display: block; + padding: 0; + margin: 0; + margin-right: 28px; + list-style: none; + & li.column{ + position: relative; + display: block; + padding: 4px 10px 0px 6px; + margin: 0; + list-style: none; + width: 100%; + white-space: nowrap; + &:hover{ + background: @scheme-color-4; + } + &.selected { + &::after{ + position: absolute; + display: block; + content: ''; + width: 2px; + left: -2px; + top: 0; + height: 100%; + background: @scheme-color-accent; + } + } + + } + } + & div.controls { + position: relative; + display: flex; + flex-direction: column; + & ul.colors{ + position: relative; + display: block; + padding: 0; + margin: 0; + list-style: none; + width: 104px; + white-space: normal; + flex: auto; + & li.color{ + position: relative; + display: inline-block; + height: 22px; + width: 22px; + margin: 0 4px 2px 0; + padding: 0; + list-style: none; + line-height: 0; + box-sizing: border-box; + vertical-align: top; + &:hover, + &.selected { + border: 1px solid @scheme-color-0; + &::after{ + position: absolute; + content: ''; + top:0; + left:0; + width: 0; + height: 0; + border-top: 6px solid @scheme-color-0; + border-right: 6px solid transparent; + } + &:hover{ + &::after{ + display: none; + } + } + } + } + } + & div.buttons { + text-align: right; + } + } + + & span.label { + position: relative; + display: inline-block; + padding: 2px 0px 0px 4px; + cursor: default; + color: @scheme-color-1; + vertical-align: top; + } +} + diff --git a/application/client/src/app/ui/views/workspace/headers/menu/template.html b/application/client/src/app/ui/views/workspace/headers/menu/template.html new file mode 100644 index 0000000000..26c49fe6bb --- /dev/null +++ b/application/client/src/app/ui/views/workspace/headers/menu/template.html @@ -0,0 +1,29 @@ +
    +
  • + + + {{header.value.caption}} + +
  • +
+
+
    +
  • +
  • +
+
+ +
+
diff --git a/application/client/src/app/ui/views/workspace/headers/styles.less b/application/client/src/app/ui/views/workspace/headers/styles.less index b156d8d6d2..58c01d3f62 100644 --- a/application/client/src/app/ui/views/workspace/headers/styles.less +++ b/application/client/src/app/ui/views/workspace/headers/styles.less @@ -12,17 +12,18 @@ line-height: 16px; height: 16px; max-height: 16px; - color: @scheme-color-0; + color: @scheme-color-2; font-weight: 500; - user-select: text; - cursor: text; + user-select: none; + cursor: default; span.header { display: block; position: relative; overflow: hidden; padding: 0 12px; + color: @scheme-color-2; border-left: thin solid @scheme-color-3; - } + } & span:nth-child(2) { border-left: none; } @@ -38,4 +39,11 @@ height: 100%; cursor: e-resize; } -} \ No newline at end of file + & mat-icon.more { + position: relative; + float: right; + margin-top: -24px; + color: @scheme-color-0; + background-color: @scheme-color-5; + } +} diff --git a/application/client/src/app/ui/views/workspace/headers/template.html b/application/client/src/app/ui/views/workspace/headers/template.html index f9ab8cccd8..249137daec 100644 --- a/application/client/src/app/ui/views/workspace/headers/template.html +++ b/application/client/src/app/ui/views/workspace/headers/template.html @@ -1,10 +1,16 @@ -{{header.caption}} \ No newline at end of file + + {{header.caption}} + + diff --git a/application/client/src/app/ui/views/workspace/module.ts b/application/client/src/app/ui/views/workspace/module.ts index d35744b393..9243997bbb 100644 --- a/application/client/src/app/ui/views/workspace/module.ts +++ b/application/client/src/app/ui/views/workspace/module.ts @@ -4,6 +4,7 @@ import { ViewWorkspace } from './component'; import { ViewContentMapComponent } from './map/component'; import { ViewSdeComponent } from './sde/component'; import { ViewWorkspaceTitleComponent } from './title/component'; +import { ViewWorkspaceHeadersMenuComponent } from './headers/menu/component'; import { ColumnsHeaders } from './headers/component'; import { ScrollAreaModule } from '@elements/scrollarea/module'; import { ContainersModule } from '@elements/containers/module'; @@ -12,14 +13,19 @@ import { AutocompleteModule } from '@elements/autocomplete/module'; import { AttachSourceMenuModule } from '@elements/menu.attachsource/module'; import { MatMenuModule } from '@angular/material/menu'; import { MatDividerModule } from '@angular/material/divider'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; const entryComponents = [ ViewWorkspace, ViewContentMapComponent, ColumnsHeaders, ViewSdeComponent, + ViewWorkspaceHeadersMenuComponent, ViewWorkspaceTitleComponent, ]; const components = [ViewWorkspace, ...entryComponents]; @@ -32,10 +38,15 @@ const components = [ViewWorkspace, ...entryComponents]; AppDirectiviesModule, AutocompleteModule, MatMenuModule, + MatCheckboxModule, MatDividerModule, + MatIconModule, MatProgressBarModule, MatProgressSpinnerModule, + MatButtonModule, AttachSourceMenuModule, + FormsModule, + ReactiveFormsModule, ], declarations: [...components], exports: [...components, ScrollAreaModule], diff --git a/application/platform/env/num.ts b/application/platform/env/num.ts index a2586323ba..f80a5bfd5e 100644 --- a/application/platform/env/num.ts +++ b/application/platform/env/num.ts @@ -27,3 +27,13 @@ export function isValidU32(value: string | number): boolean { } return true; } + +export function isValid(value: number): boolean { + if (typeof value !== 'number') { + return false; + } + if (isNaN(value) || !isFinite(value)) { + return false; + } + return true; +}