@progress/kendo-angular-grid
Version:
Kendo UI Grid for Angular - high performance data grid with paging, filtering, virtualization, CRUD, and more.
625 lines (624 loc) • 26.3 kB
JavaScript
/**-----------------------------------------------------------------------------------------
* Copyright © 2025 Progress Software Corporation. All rights reserved.
* Licensed under commercial license. See LICENSE.md in the project root for more information
*-------------------------------------------------------------------------------------------*/
import { Injectable, NgZone } from '@angular/core';
import { isPresent } from '@progress/kendo-angular-common';
import { ColumnInfoService } from './../../../../common/column-info.service';
import { ContextService } from './../../../../common/provider.service';
import { convertDateStringsInFilter, highlightBy } from './utils';
import { isCheckboxColumn } from '../../../../columns/column-base';
import { CommandColumnComponent } from '../../../../columns/command-column.component';
import * as i0 from "@angular/core";
import * as i1 from "./../../../../common/provider.service";
import * as i2 from "./../../../../common/column-info.service";
/**
* @hidden
*
* Service that builds AI requests and processes AI responses for the Grid.
* Used internally by both the Grid component and the AI Assistant tool.
*/
export class GridAIRequestResponseService {
ctx;
columnInfoService;
zone;
constructor(ctx, columnInfoService, zone) {
this.ctx = ctx;
this.columnInfoService = columnInfoService;
this.zone = zone;
}
/**
* Builds the request body for the AI service based on the Grid's column structure.
* Returns a column descriptor tree that includes column metadata for the AI service.
*/
buildRequestBody(promptMessage, role) {
const columnsTree = this.buildColumnDescriptors();
return {
role: role || 'user',
contents: [
{
text: promptMessage
}
],
columns: columnsTree
};
}
/**
* Builds a nested column descriptor tree based on the Grid's column structure.
* Includes root columns and their nested children (for ColumnGroup and SpanColumn).
*/
buildColumnDescriptors() {
const rootColumns = this.ctx?.grid?.columnList?.rootColumns() || [];
const buildDescriptor = (col) => {
const hasChildren = Boolean(col.hasChildren && col.childrenArray?.length);
const descriptor = {
id: col.id,
field: col.field,
header: col.title
};
if (hasChildren) {
descriptor.columns = col.childrenArray.map((c) => buildDescriptor(c));
}
// For special columns that don't have a field, emit an optional type token
// so the AI service knows how to treat them (checkbox/command/reorder)
if (!col.field) {
if (isCheckboxColumn(col)) {
descriptor.type = 'checkbox';
}
else if (col instanceof CommandColumnComponent) {
descriptor.type = 'command';
}
}
return descriptor;
};
return rootColumns.map((col) => buildDescriptor(col));
}
/**
* Processes AI response commands and applies them to the Grid.
* Returns an array of display messages for each command.
*/
processCommands(commands, columns, leafColumns) {
const messages = [];
this.executeCommands(commands || [], columns, leafColumns, messages);
return messages;
}
executeCommands(commands, columns, leafColumns, messages) {
if (!commands?.length) {
return;
}
const grid = this.ctx.grid;
const isFilterable = Boolean(grid.filterable);
const isSortable = Boolean(grid.sortable);
const isGroupable = Boolean(grid.groupable);
const findColumnById = (id) => grid.columnList.toArray().find((c) => c.id === id);
const updateColumnHierarchy = (column, updater) => {
const changed = [];
const queue = [column];
while (queue.length) {
const current = queue.shift();
if (!current) {
continue;
}
const didChange = updater(current);
if (didChange) {
changed.push(current);
}
if (current.hasChildren && current.childrenArray?.length) {
queue.push(...current.childrenArray);
}
}
return changed;
};
commands.forEach((cmd) => {
let displayMessage = cmd.message || '';
if (this.isColumnCommand(cmd.type)) {
if (cmd.id) {
const column = findColumnById(cmd.id);
const replacement = this.getColumnReplacement(column);
displayMessage = this.replaceQuotedColumnId(displayMessage, replacement);
}
}
messages.push(displayMessage);
switch (cmd.type) {
case 'GridSort':
if (!isSortable) {
break;
}
this.processArrayResponse([cmd.sort], grid.currentState.sort || [], (item) => item.field, (mergedArray) => grid.sortChange.next(mergedArray));
break;
case 'GridClearSort':
if (!isSortable) {
break;
}
grid.sortChange.next([]);
break;
case 'GridFilter':
if (!isFilterable) {
break;
}
this.processFilterResponse(cmd.filter);
break;
case 'GridClearFilter':
if (!isFilterable) {
break;
}
grid.filterChange.next(undefined);
break;
case 'GridGroup':
if (!isGroupable) {
break;
}
this.processArrayResponse([cmd.group], grid.currentState.group || [], (item) => item.field, (mergedArray) => grid.groupChange.next(mergedArray));
break;
case 'GridClearGroup':
if (!isGroupable) {
break;
}
grid.groupChange.next([]);
break;
case 'GridHighlight':
if (!this.ctx.highlightDirective) {
break;
}
this.processHighlightResponse([cmd.highlight], columns);
break;
case 'GridClearHighlight':
if (!this.ctx.highlightDirective) {
break;
}
this.ctx.highlightDirective['setState']([]);
break;
case 'GridSelect': {
this.processSelectionResponse([cmd.select], columns, leafColumns, messages);
break;
}
case 'GridClearSelect': {
const selectionInstance = this.getSelectionInstance();
if (!selectionInstance) {
this.updateLastMessage(messages, this.ctx.localization?.get('aiAssistantSelectionNotEnabled'));
break;
}
this.applySelectionState(selectionInstance, []);
break;
}
case 'GridColumnResize': {
const col = findColumnById(cmd.id);
if (!col) {
break;
}
let newWidth;
if (typeof cmd.size === 'number') {
newWidth = cmd.size;
}
else if (typeof cmd.size === 'string') {
const numericPart = parseFloat(cmd.size);
if (!isNaN(numericPart)) {
newWidth = numericPart;
}
}
if (typeof newWidth === 'number') {
const oldWidth = col.width;
col.width = newWidth;
const args = [{ column: col, oldWidth: oldWidth, newWidth: newWidth }];
grid.columnResize.emit(args);
}
break;
}
case 'GridColumnReorder': {
const col = findColumnById(cmd.id);
if (!col) {
break;
}
const newPosition = Number(cmd.position);
if (!isNaN(newPosition) && newPosition >= 0) {
this.changeColumnPosition(col, newPosition);
}
break;
}
case 'GridColumnShow':
case 'GridColumnHide': {
const col = findColumnById(cmd.id);
if (!col) {
break;
}
const targetHidden = cmd.type === 'GridColumnHide';
const changed = updateColumnHierarchy(col, (current) => {
if (current.hidden === targetHidden) {
return false;
}
current.hidden = targetHidden;
return true;
});
if (changed.length) {
this.columnInfoService.changeVisibility(changed);
}
break;
}
case 'GridColumnLock':
case 'GridColumnUnlock': {
const col = findColumnById(cmd.id);
if (!col) {
break;
}
const targetLocked = cmd.type === 'GridColumnLock';
const changed = updateColumnHierarchy(col, (current) => {
if (current.locked === targetLocked) {
return false;
}
current.locked = targetLocked;
return true;
});
if (changed.length) {
this.columnInfoService.changeLocked(changed);
}
break;
}
case 'GridPage': {
this.processPageCommand(cmd);
break;
}
case 'GridPageSize': {
this.processPageSizeCommand(cmd);
break;
}
case 'GridExportExcel': {
this.runExportWithFileName(this.ctx.excelComponent, cmd.fileName, () => grid.saveAsExcel());
break;
}
case 'GridExportPDF': {
this.runExportWithFileName(this.ctx.pdfComponent, cmd.fileName, () => grid.emitPDFExportEvent());
break;
}
default:
break;
}
});
}
processArrayResponse(newItems, currentItems, getField, updateGrid) {
if (newItems?.length === 0) {
updateGrid([]);
}
else if (newItems?.length) {
let mergedArray = [...newItems];
const newFields = newItems.map(getField);
const existingItemsToKeep = currentItems.filter(item => !newFields.includes(getField(item)));
mergedArray = [...mergedArray, ...existingItemsToKeep];
updateGrid(mergedArray);
}
}
runExportWithFileName(component, fileName, action) {
if (!component || !fileName) {
action();
return;
}
const previousFileName = component.fileName;
component.fileName = fileName;
action();
const isExcel = component === this.ctx.excelComponent;
if (isExcel) {
this.zone.runOutsideAngular(() => {
this.ctx.excelComponent.fileCreated.subscribe(() => {
component.fileName = previousFileName;
});
});
}
else {
component.fileName = previousFileName;
}
}
processPageCommand(command) {
const pageSize = this.getCurrentPageSizeValue();
if (!isPresent(pageSize) || pageSize <= 0) {
return;
}
const total = this.getTotalItemsCount();
const requestedPage = Number(command.page);
let targetPage = Number.isFinite(requestedPage) ? Math.floor(requestedPage) : 1;
if (targetPage < 1) {
targetPage = 1;
}
if (isPresent(total) && pageSize > 0) {
const maxPage = Math.max(1, Math.ceil(total / pageSize));
targetPage = Math.min(targetPage, maxPage);
}
const skip = (targetPage - 1) * pageSize;
this.emitGridPageChange(skip, pageSize);
}
processPageSizeCommand(command) {
const rawPageSize = Number(command.pageSize);
if (!Number.isFinite(rawPageSize)) {
return;
}
const newPageSize = Math.max(1, Math.floor(rawPageSize));
const skip = Math.max(0, this.ctx.grid?.skip ?? 0);
this.ensurePageSizeOption(newPageSize);
this.emitGridPageChange(skip, newPageSize);
}
emitGridPageChange(skip, take) {
const grid = this.ctx.grid;
const normalizedSkip = Math.max(0, Math.floor(skip));
const normalizedTake = Math.max(1, Math.floor(take));
grid.skip = normalizedSkip;
grid.pageSize = normalizedTake;
grid.pageChange.emit({ skip: normalizedSkip, take: normalizedTake });
}
ensurePageSizeOption(pageSize) {
const grid = this.ctx.grid;
if (!grid) {
return;
}
const pageable = grid.pageable;
if (!pageable || typeof pageable === 'boolean') {
return;
}
const pageSizes = pageable.pageSizes;
if (!Array.isArray(pageSizes) || pageSizes.length === 0) {
return;
}
if (pageSizes.includes(pageSize)) {
return;
}
const uniqueSizes = [pageSize, ...pageSizes.filter(size => size !== pageSize)];
grid.pageable = {
...pageable,
pageSizes: uniqueSizes
};
const changeDetector = grid?.changeDetectorRef;
if (changeDetector && typeof changeDetector.markForCheck === 'function') {
changeDetector.markForCheck();
}
}
getCurrentPageSizeValue() {
const grid = this.ctx.grid;
if (!grid) {
return null;
}
const candidates = [grid.pageSize, grid.currentState?.take, this.ctx.dataBindingDirective?.['state']?.take];
for (const candidate of candidates) {
if (typeof candidate === 'number' && candidate > 0) {
return candidate;
}
}
const pageable = grid.pageable;
if (pageable && typeof pageable === 'object' && Array.isArray(pageable.pageSizes)) {
const numericSize = pageable.pageSizes.find(size => typeof size === 'number' && size > 0);
if (numericSize) {
return numericSize;
}
}
const originalData = this.ctx.dataBindingDirective?.['originalData'];
if (Array.isArray(originalData) && originalData.length > 0) {
return originalData.length;
}
return null;
}
getTotalItemsCount() {
const grid = this.ctx.grid;
if (!grid) {
return null;
}
const gridData = grid.data;
if (gridData && typeof gridData.total === 'number') {
return gridData.total;
}
const view = grid.view;
if (view && typeof view.total === 'number') {
return view.total;
}
const originalData = this.ctx.dataBindingDirective?.['originalData'];
if (Array.isArray(originalData)) {
return originalData.length;
}
return null;
}
getSelectionInstance() {
const selectionDirective = this.ctx.grid?.selectionDirective;
if (selectionDirective && typeof selectionDirective === 'object') {
return selectionDirective;
}
const defaultSelection = this.ctx.grid?.defaultSelection;
if (defaultSelection && typeof defaultSelection === 'object') {
return defaultSelection;
}
return null;
}
updateLastMessage(messages, newMessage) {
if (!messages.length) {
return;
}
messages[messages.length - 1] = newMessage;
}
isColumnCommand(type) {
return type === 'GridColumnResize' ||
type === 'GridColumnReorder' ||
type === 'GridColumnShow' ||
type === 'GridColumnHide' ||
type === 'GridColumnLock' ||
type === 'GridColumnUnlock';
}
getColumnReplacement(column) {
if (!column) {
return '';
}
if (column.title && String(column.title).trim()) {
return String(column.title).trim();
}
if (column.field && String(column.field).trim()) {
return String(column.field).trim();
}
return '';
}
replaceQuotedColumnId(message, replacement) {
if (!replacement) {
const columnIdPattern = /(?:"|")(k-grid\d+-col\d+)(?:"|")\s*/g;
return message.replace(columnIdPattern, '').replace(/\s{2,}/g, ' ').trim();
}
const columnIdPattern = /(?:"|")(k-grid\d+-col\d+)(?:"|")/g;
return message.replace(columnIdPattern, (match) => {
const isEncoded = match.startsWith('"');
return isEncoded ? `"${replacement}"` : `"${replacement}"`;
});
}
getHighlightItems(descriptors, columns) {
if (!descriptors?.length) {
return [];
}
const data = this.ctx.dataBindingDirective?.['originalData'] || [];
return highlightBy(data, descriptors, columns);
}
processSelectionResponse(selection, columns, leafColumns, messages) {
const selectionInstance = this.getSelectionInstance();
if (!selectionInstance) {
this.updateLastMessage(messages, this.ctx.localization?.get('aiAssistantSelectionNotEnabled'));
return;
}
const descriptors = (selection || []).filter((descriptor) => Boolean(descriptor));
if (descriptors.length === 0) {
this.applySelectionState(selectionInstance, []);
return;
}
const highlightItems = this.getHighlightItems(descriptors, columns);
if (!highlightItems.length) {
this.applySelectionState(selectionInstance, []);
return;
}
const hasCellSelections = highlightItems.some(item => isPresent(item.columnKey));
const hasRowSelections = highlightItems.some(item => !isPresent(item.columnKey));
const isCellMode = selectionInstance.isCellSelectionMode;
if ((!isCellMode && hasCellSelections) || (isCellMode && hasRowSelections)) {
const key = isCellMode ? 'aiAssistantSelectionRowModeRequired' : 'aiAssistantSelectionCellModeRequired';
this.updateLastMessage(messages, this.ctx.localization?.get(key));
return;
}
const selectionState = this.mapHighlightItemsToSelection(selectionInstance, highlightItems, isCellMode, leafColumns);
this.applySelectionState(selectionInstance, selectionState);
}
mapHighlightItemsToSelection(selectionInstance, highlightItems, isCellMode, leafColumns) {
const data = this.ctx.dataBindingDirective?.['originalData'] || [];
if (isCellMode) {
const mapped = highlightItems
.filter(item => isPresent(item.itemKey) && isPresent(item.columnKey))
.map(item => {
const rowIndex = item.itemKey;
const columnIndex = item.columnKey;
const dataItem = data[rowIndex];
if (!isPresent(dataItem)) {
return null;
}
if (typeof selectionInstance['getSelectionItem'] === 'function') {
const columnComponent = leafColumns[columnIndex];
const selectionItem = selectionInstance['getSelectionItem']({ dataItem, index: rowIndex }, columnComponent, columnIndex);
if (selectionItem && isPresent(selectionItem.itemKey) && isPresent(selectionItem.columnKey)) {
return selectionItem;
}
return null;
}
const itemKey = typeof selectionInstance.getItemKey === 'function'
? selectionInstance.getItemKey({ dataItem, index: rowIndex })
: rowIndex;
return isPresent(itemKey) ? { itemKey, columnKey: columnIndex } : null;
})
.filter((item) => isPresent(item));
return mapped.filter((item, index, self) => self.findIndex(other => other.itemKey === item.itemKey && other.columnKey === item.columnKey) === index);
}
const rowKeys = highlightItems
.filter(item => isPresent(item.itemKey))
.map(item => {
const rowIndex = item.itemKey;
const dataItem = data[rowIndex];
if (!isPresent(dataItem)) {
return null;
}
if (typeof selectionInstance.getItemKey === 'function') {
return selectionInstance.getItemKey({ dataItem, index: rowIndex });
}
return rowIndex;
})
.filter(isPresent);
return Array.from(new Set(rowKeys));
}
applySelectionState(selectionInstance, selectionState) {
selectionInstance.selectedKeys = selectionState;
if (typeof selectionInstance['setState'] === 'function') {
selectionInstance['setState'](selectionState);
}
const changeDetector = selectionInstance['cd'];
if (changeDetector && typeof changeDetector.markForCheck === 'function') {
changeDetector.markForCheck();
}
if (typeof selectionInstance['notifyChange'] === 'function') {
selectionInstance['notifyChange']();
}
}
processHighlightResponse(highlight, columns) {
const highlightedItems = this.getHighlightItems(highlight, columns);
this.ctx.highlightDirective['setState'](highlightedItems);
}
processFilterResponse(filter) {
const processedFilter = convertDateStringsInFilter(filter);
const clearFilter = Object.keys(processedFilter).length === 0;
if (clearFilter) {
this.ctx.grid.filterChange.next(undefined);
}
else if (processedFilter?.filters.length) {
const currentFilter = this.ctx.grid.currentState.filter;
let mergedFilter = processedFilter;
if (currentFilter && currentFilter.filters?.length > 0) {
mergedFilter = {
logic: 'and',
filters: [
currentFilter,
processedFilter
]
};
}
this.ctx.grid.filterChange.next(mergedFilter);
}
}
changeColumnPosition(column, newPosition) {
const grid = this.ctx.grid;
if (!grid?.columns) {
return;
}
const currentColumns = grid.columns.toArray();
const currentIndex = currentColumns.findIndex(col => col === column);
if (currentIndex === -1) {
return;
}
if (newPosition < 0 || newPosition >= currentColumns.length) {
return;
}
const sortedColumns = currentColumns
.map((col, idx) => ({ col, physicalIndex: idx, visualOrder: col.orderIndex ?? idx }))
.sort((a, b) => a.visualOrder - b.visualOrder);
const currentVisualPos = sortedColumns.findIndex(item => item.physicalIndex === currentIndex);
if (currentVisualPos === newPosition) {
return;
}
currentColumns.forEach((col, idx) => {
const sortedIndex = sortedColumns.findIndex(item => item.physicalIndex === idx);
if (idx === currentIndex) {
col.orderIndex = newPosition;
}
else if (currentVisualPos < newPosition) {
col.orderIndex = (sortedIndex > currentVisualPos && sortedIndex <= newPosition)
? sortedIndex - 1 : sortedIndex;
}
else {
col.orderIndex = (sortedIndex >= newPosition && sortedIndex < currentVisualPos)
? sortedIndex + 1 : sortedIndex;
}
col.isReordered = true;
});
grid.columnReorder.emit({
column: column,
oldIndex: currentVisualPos,
newIndex: newPosition
});
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: GridAIRequestResponseService, deps: [{ token: i1.ContextService }, { token: i2.ColumnInfoService }, { token: i0.NgZone }], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: GridAIRequestResponseService });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: GridAIRequestResponseService, decorators: [{
type: Injectable
}], ctorParameters: () => [{ type: i1.ContextService }, { type: i2.ColumnInfoService }, { type: i0.NgZone }] });