diff --git a/.run/BigInt support.run.xml b/.run/BigInt support.run.xml new file mode 100644 index 00000000000..1696dcfaa07 --- /dev/null +++ b/.run/BigInt support.run.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/documentation/ag-grid-docs/.env.build b/documentation/ag-grid-docs/.env.build index 7c07c61cca6..13b11a03c80 100644 --- a/documentation/ag-grid-docs/.env.build +++ b/documentation/ag-grid-docs/.env.build @@ -12,5 +12,6 @@ PUBLIC_GTM_AUTH=K7m5Yf0NyqFbLXvp-gPogw PUBLIC_GTM_PREVIEW=env-55 # Use production sitemap/robots disallow +LIVE_SITEMAP_URL=https://grid-staging.ag-grid.com/sitemap-0.xml CHARTS_SITEMAP_INDEX_URL=https://www.ag-grid.com/charts/sitemap-index.xml CHARTS_ROBOTS_DISALLOW_JSON_URL=https://www.ag-grid.com/charts/robots-disallow.json diff --git a/documentation/ag-grid-docs/.env.build.archive b/documentation/ag-grid-docs/.env.build.archive index 3a89861a52f..c6f7e9b98a1 100644 --- a/documentation/ag-grid-docs/.env.build.archive +++ b/documentation/ag-grid-docs/.env.build.archive @@ -5,6 +5,8 @@ PUBLIC_ALGOLIA_SEARCH_KEY=01142fe24ea5d2e36f4eb66b0b2ff872 PUBLIC_ALGOLIA_INDEX_PREFIX=ag-grid-dev PUBLIC_TRIAL_LICENCE_FORM_URL=https://us-central1-aggrid-ecommerce.cloudfunctions.net/CreateLeadForTrialLK +LIVE_SITEMAP_URL=https://www.ag-grid.com/sitemap-0.xml + # Google Tag Manager (same as production) PUBLIC_GTM_ID=GTM-T7JG534 PUBLIC_GTM_AUTH=BZRbizt3P5eacrM4bCJO5g diff --git a/documentation/ag-grid-docs/.env.build.production b/documentation/ag-grid-docs/.env.build.production index f569ff32d4f..bff7ec21892 100644 --- a/documentation/ag-grid-docs/.env.build.production +++ b/documentation/ag-grid-docs/.env.build.production @@ -6,6 +6,8 @@ PUBLIC_ALGOLIA_SEARCH_KEY=01142fe24ea5d2e36f4eb66b0b2ff872 PUBLIC_ALGOLIA_INDEX_PREFIX=ag-grid PUBLIC_TRIAL_LICENCE_FORM_URL=https://us-central1-aggrid-ecommerce.cloudfunctions.net/CreateLeadForTrialLK +LIVE_SITEMAP_URL=https://www.ag-grid.com/sitemap-0.xml + # Google Tag Manager PUBLIC_GTM_ID=GTM-T7JG534 PUBLIC_GTM_AUTH=BZRbizt3P5eacrM4bCJO5g diff --git a/documentation/ag-grid-docs/.env.build.staging b/documentation/ag-grid-docs/.env.build.staging index 74de3f5da07..585e543b7d3 100644 --- a/documentation/ag-grid-docs/.env.build.staging +++ b/documentation/ag-grid-docs/.env.build.staging @@ -6,4 +6,6 @@ PUBLIC_TRIAL_LICENCE_FORM_URL=https://us-central1-stripe-testing-19784.cloudfunc # Use staging sitemap CHARTS_SITEMAP_INDEX_URL=https://charts-staging.ag-grid.com/sitemap-index.xml +LIVE_SITEMAP_URL=https://grid-staging.ag-grid.com/sitemap-0.xml + # No robots disallow required, as staging is disallow all diff --git a/documentation/ag-grid-docs/.env.dev b/documentation/ag-grid-docs/.env.dev index 0d950dac0a1..ecb753d82d2 100644 --- a/documentation/ag-grid-docs/.env.dev +++ b/documentation/ag-grid-docs/.env.dev @@ -6,7 +6,8 @@ PUBLIC_ALGOLIA_SEARCH_KEY=01142fe24ea5d2e36f4eb66b0b2ff872 PUBLIC_ALGOLIA_INDEX_PREFIX=ag-grid-dev PUBLIC_TRIAL_LICENCE_FORM_URL=https://us-central1-stripe-testing-19784.cloudfunctions.net/CreateLeadForTrialLK -# No sitemap, as dev server doesn't generate a sitemap +LIVE_SITEMAP_URL=https://grid-staging.ag-grid.com/sitemap-0.xml + # Use production robots disallow CHARTS_ROBOTS_DISALLOW_JSON_URL=https://www.ag-grid.com/charts/robots-disallow.json diff --git a/documentation/ag-grid-docs/.env.preview b/documentation/ag-grid-docs/.env.preview index 77275803b5f..582b0a32d90 100644 --- a/documentation/ag-grid-docs/.env.preview +++ b/documentation/ag-grid-docs/.env.preview @@ -4,3 +4,5 @@ PUBLIC_SITE_URL=http://localhost:${PORT} PUBLIC_ALGOLIA_APP_ID=O1K1ESGB5K PUBLIC_ALGOLIA_SEARCH_KEY=01142fe24ea5d2e36f4eb66b0b2ff872 PUBLIC_ALGOLIA_INDEX_PREFIX=ag-grid-dev + +LIVE_SITEMAP_URL=https://grid-staging.ag-grid.com/sitemap-0.xml diff --git a/documentation/ag-grid-docs/.env.preview.production b/documentation/ag-grid-docs/.env.preview.production index 3ffbb1b21b1..d6476e4ef01 100644 --- a/documentation/ag-grid-docs/.env.preview.production +++ b/documentation/ag-grid-docs/.env.preview.production @@ -3,3 +3,5 @@ PUBLIC_USE_PUBLISHED_PACKAGES=true PUBLIC_ALGOLIA_APP_ID=O1K1ESGB5K PUBLIC_ALGOLIA_SEARCH_KEY=01142fe24ea5d2e36f4eb66b0b2ff872 PUBLIC_ALGOLIA_INDEX_PREFIX=ag-grid + +LIVE_SITEMAP_URL=https://www.ag-grid.com/sitemap-0.xml diff --git a/documentation/ag-grid-docs/.env.preview.staging b/documentation/ag-grid-docs/.env.preview.staging index 6922092c520..c85797b2a44 100644 --- a/documentation/ag-grid-docs/.env.preview.staging +++ b/documentation/ag-grid-docs/.env.preview.staging @@ -2,3 +2,5 @@ PUBLIC_SITE_URL=https://grid-staging.ag-grid.com PUBLIC_ALGOLIA_APP_ID=O1K1ESGB5K PUBLIC_ALGOLIA_SEARCH_KEY=01142fe24ea5d2e36f4eb66b0b2ff872 PUBLIC_ALGOLIA_INDEX_PREFIX=ag-grid-dev + +LIVE_SITEMAP_URL=https://grid-staging.ag-grid.com/sitemap-0.xml diff --git a/documentation/ag-grid-docs/project.json b/documentation/ag-grid-docs/project.json index 7ca22736d4e..8c004076217 100644 --- a/documentation/ag-grid-docs/project.json +++ b/documentation/ag-grid-docs/project.json @@ -23,26 +23,20 @@ { "env": "PUBLIC_PACKAGE_VERSION" } ], "cache": true, - "command": "tsx ../../external/ag-website-shared/scripts/buildWithSitemapCache.ts", + "command": "tsx ../../external/ag-website-shared/scripts/buildWithSitemapCache.ts --silent", "options": { - "cwd": "{projectRoot}", - "silent": true + "cwd": "{projectRoot}" }, "configurations": { - "staging": { - "silent": true, - "clean-cache": true - }, + "staging": {}, "archive": { - "silent": true, - "clean-cache": true + "args": "--clean-cache=true --run-second-build=true" }, "production": { - "silent": true, - "clean-cache": true + "args": "--clean-cache=true --run-second-build=true" }, "verbose": { - "silent": null + "args": "--silent=false" } } }, diff --git a/documentation/ag-grid-docs/src/constants.ts b/documentation/ag-grid-docs/src/constants.ts index 386e4e2d8df..98a52adb1b5 100644 --- a/documentation/ag-grid-docs/src/constants.ts +++ b/documentation/ag-grid-docs/src/constants.ts @@ -171,6 +171,8 @@ function calculateGridUrl() { export const GRID_URL = calculateGridUrl(); +export const LIVE_SITEMAP_URL = import.meta.env?.LIVE_SITEMAP_URL; + export const EXAMPLE_RANDOM_SEED = 'AG Grid Random Seed'; export const TRIAL_LICENCE_FORM_URL = import.meta.env?.PUBLIC_TRIAL_LICENCE_FORM_URL; diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/ChatToolPanel.ts b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/ChatToolPanel.ts new file mode 100644 index 00000000000..8567da693ea --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/ChatToolPanel.ts @@ -0,0 +1,241 @@ +import type { GridApi, IToolPanel, IToolPanelParams } from 'ag-grid-community'; + +import { callChatGPT } from './chatgptApi'; +import type { ChatMessage } from './types'; + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +// Store conversation history outside the component to persist across grid state changes +let conversationHistory: ChatMessage[] = []; + +export class ChatToolPanel implements IToolPanel { + private eGui!: HTMLElement; + private gridApi!: GridApi; + private chatMessagesContainer!: HTMLElement; + private inputElement!: HTMLTextAreaElement; + private submitButton!: HTMLButtonElement; + private formElement!: HTMLFormElement; + private resetButton!: HTMLButtonElement; + + // Event handler references for cleanup + private handleSubmitBound = (e: Event) => this.handleSubmit(e); + private handleKeydownBound = (e: KeyboardEvent) => this.handleKeydown(e); + private resetBound = () => this.reset(); + + init(params: IToolPanelParams): void { + this.gridApi = params.api; + this.eGui = this.createGui(); + // Re-render existing messages when tool panel is re-created + this.renderExistingMessages(); + } + + getGui(): HTMLElement { + return this.eGui; + } + + refresh(_params: IToolPanelParams): boolean { + return false; + } + + private createGui(): HTMLElement { + const container = document.createElement('div'); + container.className = 'chat-tool-panel'; + container.innerHTML = ` +
+
+

AI Assistant

+
+ +
+
+

This example demonstrates the AI Toolkit with conversation history, embedded in a custom tool panel.

+
+
+
+ + +
+ `; + + this.chatMessagesContainer = container.querySelector('.chat-messages')!; + this.inputElement = container.querySelector('.chat-input')!; + this.submitButton = container.querySelector('.chat-submit')!; + this.formElement = container.querySelector('.chat-input-form')!; + this.resetButton = container.querySelector('.reset-btn')!; + + // Add event listeners using bound handlers for cleanup + this.formElement.addEventListener('submit', this.handleSubmitBound); + this.inputElement.addEventListener('keydown', this.handleKeydownBound); + this.resetButton.addEventListener('click', this.resetBound); + + return container; + } + + private handleKeydown(event: KeyboardEvent): void { + // Enter sends message, Shift+Enter adds newline + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + this.formElement.dispatchEvent(new Event('submit', { cancelable: true })); + } + } + + private async handleSubmit(event: Event): Promise { + event.preventDefault(); + + const userMessage = this.inputElement.value.trim(); + if (!userMessage) return; + + // Render user message + this.renderMessage('user', userMessage); + + // Clear input and disable form + this.inputElement.value = ''; + this.inputElement.disabled = true; + this.submitButton.disabled = true; + + // Show loading indicator + const loadingId = this.showLoadingMessage(); + + try { + const response = await callChatGPT(userMessage, this.gridApi, conversationHistory); + + // Log the LLM response + console.log('Explanation:', response.explanation); + if (response.gridState && Object.keys(response.gridState).length > 0) { + console.log('New Grid State: ', response.gridState); + } + if (response.propertiesToIgnore?.length > 0) { + console.log('Properties Ignored:', response.propertiesToIgnore); + } + + // Remove loading indicator + this.removeLoadingMessage(loadingId); + + // Add both messages to history after successful response + conversationHistory.push( + { role: 'user', content: userMessage }, + { role: 'assistant', content: response.explanation } + ); + + // Apply grid state changes if any (this will destroy and recreate the tool panel) + // Messages will be automatically added when the tool panel reloads + if (response.gridState && Object.keys(response.gridState).length > 0) { + this.gridApi.setState(response.gridState, response.propertiesToIgnore); + } else { + // If no state change, manually render the response + this.renderMessage('assistant', response.explanation); + } + } catch (error) { + this.removeLoadingMessage(loadingId); + const errorMessage = `Error: ${error instanceof Error ? error.message : String(error)}`; + this.renderMessage('assistant', errorMessage, true); + } finally { + // Re-enable form + this.inputElement.disabled = false; + this.submitButton.disabled = false; + this.inputElement.focus(); + } + } + + private renderMessage(role: 'user' | 'assistant', content: string, isError = false): void { + const messageDiv = document.createElement('div'); + messageDiv.className = `chat-message ${role}-message${isError ? ' error-message' : ''}`; + + const bubble = document.createElement('div'); + bubble.className = 'message-bubble'; + bubble.textContent = content; + + messageDiv.appendChild(bubble); + this.chatMessagesContainer.appendChild(messageDiv); + + this.scrollToBottomOfChat(); + } + + private showLoadingMessage(): string { + const loadingId = `loading-${Date.now()}`; + const messageDiv = document.createElement('div'); + messageDiv.className = 'chat-message assistant-message loading-message'; + messageDiv.id = loadingId; + + const bubble = document.createElement('div'); + bubble.className = 'message-bubble'; + bubble.innerHTML = 'Thinking...'; + + const disclaimer = document.createElement('div'); + disclaimer.className = 'loading-disclaimer'; + disclaimer.innerHTML = + ' This demo uses a proxy, so responses may take up to 30 seconds'; + + messageDiv.appendChild(bubble); + messageDiv.appendChild(disclaimer); + this.chatMessagesContainer.appendChild(messageDiv); + + this.scrollToBottomOfChat(); + + return loadingId; + } + + private removeLoadingMessage(loadingId: string): void { + const loadingElement = document.getElementById(loadingId); + if (loadingElement) { + loadingElement.remove(); + } + } + + private renderExistingMessages(): void { + // Re-render all messages from conversation history when tool panel is recreated + for (const message of conversationHistory) { + this.renderMessage(message.role, message.content); + } + this.scrollToBottomOfChat(); + } + + // Scroll to bottom + private scrollToBottomOfChat = () => { + this.chatMessagesContainer.scrollTop = this.chatMessagesContainer.scrollHeight; + }; + + private reset(): void { + // Reset conversation + conversationHistory = []; + this.chatMessagesContainer.innerHTML = ''; + this.inputElement.value = ''; + this.inputElement.focus(); + + // Reset grid state + this.gridApi.setState({ + columnVisibility: { + hiddenColIds: [ + 'ag-Grid-HierarchyColumn-transactionDate-year', + 'ag-Grid-HierarchyColumn-transactionDate-year', + 'ag-Grid-HierarchyColumn-transactionDate-formattedMonth', + 'ag-Grid-HierarchyColumn-transactionDate-formattedMonth', + 'currency', + ], + }, + columnPinning: { leftColIds: [], rightColIds: [] }, + sort: { sortModel: [] }, + filter: { filterModel: {} }, + rowGroup: { groupColIds: [] }, + pagination: { page: 0, pageSize: 100 }, + }); + } + + destroy(): void { + // Clean up event listeners + this.formElement.removeEventListener('submit', this.handleSubmitBound); + this.inputElement.removeEventListener('keydown', this.handleKeydownBound); + this.resetButton.removeEventListener('click', this.resetBound); + } +} diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/CountryFlagCellRenderer.ts b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/CountryFlagCellRenderer.ts new file mode 100644 index 00000000000..26a75cc7b72 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/CountryFlagCellRenderer.ts @@ -0,0 +1,39 @@ +import type { ICellRendererComp, ICellRendererParams } from 'ag-grid-community'; + +export class CountryFlagCellRenderer implements ICellRendererComp { + eGui!: HTMLDivElement; + + init(params: ICellRendererParams) { + this.eGui = document.createElement('div'); + this.eGui.style.display = 'flex'; + this.eGui.style.alignItems = 'center'; + this.eGui.style.gap = '6px'; + + if (!params.value) { + this.eGui.textContent = ''; + return; + } + + const countryCode = params.value.toLowerCase(); + const flagUrl = `https://flags.fmcdn.net/data/flags/mini/${countryCode}.png`; + const flagImage = document.createElement('img'); + flagImage.src = flagUrl; + flagImage.width = 15; + flagImage.height = 10; + flagImage.style.border = '0'; + + const countryText = document.createElement('span'); + countryText.textContent = params.value; + + this.eGui.appendChild(flagImage); + this.eGui.appendChild(countryText); + } + + getGui() { + return this.eGui; + } + + refresh() { + return false; + } +} diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/chatgptApi.ts b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/chatgptApi.ts new file mode 100644 index 00000000000..b9011fba39b --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/chatgptApi.ts @@ -0,0 +1,148 @@ +import { GridApi } from 'ag-grid-community'; + +import { ChatMessage } from './ChatToolPanel'; +import { generateSystemPrompt } from './systemPrompt'; + +const CHATGPT_MODEL = 'gpt-5-mini'; +const BASE_URL = 'https://ai-api.ag-grid.com/api/openai/v1'; + +export const callChatGPT = async ( + userRequest: string, + gridApi: GridApi, + conversationHistory: ChatMessage[] = [] +): Promise => { + // Extract relevant parts of the current grid state + const { aggregation, rowGroup, columnSizing, columnVisibility, sort, filter, pivot } = gridApi.getState(); + const currentState = { aggregation, rowGroup, columnSizing, columnVisibility, sort, filter, pivot }; + + // Build LLM Schema from Grid API Structured Schema + const schema = buildLLMSchema(gridApi); + + // Build conversation history with system prompt, previous messages, and user request + const messages: ChatMessage[] = [ + { role: 'system', content: generateSystemPrompt(currentState) }, + ...conversationHistory, + { role: 'user', content: userRequest }, + ]; + + // Send request to ChatGPT API + let result; + try { + result = await sendRequest({ + model: CHATGPT_MODEL, + schema, + messages, + }); + } catch (error: any) { + throw new Error(`OpenAI API error: ${error.message || 'Unknown error'}`); + } + + return result; +}; + +const buildLLMSchema = (gridApi: GridApi): any => { + // Generate structured schema from grid API + const { $defs, ...structuredSchema } = gridApi.getStructuredSchema({ + columns: { + category: { + includeSetValues: true, + }, + merchant: { + includeSetValues: true, + }, + status: { + includeSetValues: true, + }, + currency: { + includeSetValues: true, + }, + country: { + includeSetValues: true, + }, + accountType: { + includeSetValues: true, + }, + type: { + includeSetValues: true, + }, + }, + }); + + // Return LLM compatible JSON Schema from AI Toolkit structured schema + return { + type: 'object', + $defs, + properties: { + gridState: structuredSchema, + propertiesToIgnore: { + type: 'array', + items: { + type: 'string', + enum: ['aggregation', 'filter', 'sort', 'pivot', 'columnVisibility', 'columnSizing', 'rowGroup'], + }, + description: 'List of grid state properties to ignore when applying the new state', + }, + explanation: { + type: 'string', + description: 'Human-readable explanation of the changes made to the grid state', + }, + }, + required: ['gridState', 'explanation', 'propertiesToIgnore'], + additionalProperties: false, + }; +}; + +export const sendRequest = async (options: any): Promise => { + const { model = 'gpt-4o-mini', schema, messages, maxTokens = 4096, stream = false } = options; + + const requestBody = { + model, + messages, + max_completion_tokens: maxTokens, + response_format: schema + ? { + type: 'json_schema', + json_schema: { + name: 'grid_state_response', + schema, + }, + } + : { type: 'json_object' }, + stream, + }; + + const url = `${BASE_URL}/chat/completions`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: 'Unknown error' })); + const error = + errorData.error?.code === 'rate_limit_exceeded' + ? 'OpenAI Rate Limit Exceeded' + : `OpenAI API error: ${response.status} - ${errorData.error?.message || 'Unknown error'}`; + throw new Error(error); + } + + const data = await response.json(); + const content = data.choices[0]?.message?.content; + + if (!content) { + throw new Error('No content received from OpenAI API'); + } + + let parsedObject; + try { + parsedObject = JSON.parse(content); + } catch (error) { + throw new Error(`Failed to parse JSON response: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + + return parsedObject; +}; diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/example.spec.ts b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/example.spec.ts new file mode 100644 index 00000000000..ed79767b830 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/example.spec.ts @@ -0,0 +1,11 @@ +import { clickAllButtons, ensureGridReady, test, waitForGridContent } from '@utils/grid/test-utils'; + +test.agExample(import.meta, () => { + test.eachFramework('Example', async ({ page }) => { + // PLACEHOLDER - MINIMAL TEST TO ENSURE GRID LOADS WITHOUT ERRORS + await ensureGridReady(page); + await waitForGridContent(page); + await clickAllButtons(page); + // END PLACEHOLDER + }); +}); diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/generateTransactions.ts b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/generateTransactions.ts new file mode 100644 index 00000000000..d7533d6c8ff --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/generateTransactions.ts @@ -0,0 +1,134 @@ +/** + * Generates an array of mock financial transaction data for testing and demonstration purposes. + */ + +export interface ITransaction { + transactionDate: Date; + amount: number; + currency: string; + category: string; + merchant: string; + status: boolean; + country: string; +} + +const countries = ['GB', 'IE', 'FR', 'DE', 'ES', 'NL', 'US']; +const countryCurrencyMap: Record = { + GB: 'GBP', + IE: 'EUR', + FR: 'EUR', + DE: 'EUR', + ES: 'EUR', + NL: 'EUR', + US: 'USD', +}; + +const statuses: { value: boolean; w: number }[] = [ + { value: true, w: 75 }, + { value: false, w: 25 }, +]; + +const categories: { value: string; w: number; merchants: string[] }[] = [ + { value: 'Groceries', w: 14, merchants: ['Tesco', "Sainsbury's", 'Aldi', 'Lidl', 'Waitrose'] }, + { value: 'Rent', w: 6, merchants: ['Landlord Ltd', 'Lettings Co'] }, + { value: 'Utilities', w: 8, merchants: ['British Gas', 'Octopus Energy', 'Thames Water'] }, + { value: 'Dining', w: 10, merchants: ['Pret', "Nando's", 'PizzaExpress', 'Local Cafe'] }, + { value: 'Transport', w: 10, merchants: ['TfL', 'Uber', 'Bolt', 'National Rail'] }, + { value: 'Shopping', w: 12, merchants: ['Amazon', 'John Lewis', 'Argos', 'ASOS'] }, + { value: 'Travel', w: 6, merchants: ['easyJet', 'British Airways', 'Booking.com', 'Trainline'] }, + { value: 'Health', w: 5, merchants: ['Boots', 'NHS', 'Bupa'] }, + { value: 'Salary', w: 6, merchants: ['Acme Corp Payroll', 'Globex Payroll'] }, + { value: 'Transfers', w: 8, merchants: ['Internal Transfer', 'External Transfer'] }, + { value: 'Insurance', w: 5, merchants: ['Aviva', 'AXA', 'Direct Line'] }, + { value: 'Entertainment', w: 10, merchants: ['Netflix', 'Spotify', 'Cinema', 'Steam'] }, +]; + +export function generateTransactions({ count = 10000, seed = 1 } = {}): ITransaction[] { + // --- seeded RNG (Mulberry32) for repeatable demos --- + function mulberry32(a: number) { + return function () { + let t = (a += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + } + const rand = mulberry32(seed); + const pick = (arr: T[]): T => arr[Math.floor(rand() * arr.length)]; + const weightedPick = (items: { value: T; w: number }[]): T => { + const total = items.reduce((s, x) => s + x.w, 0); + let r = rand() * total; + for (const it of items) { + r -= it.w; + if (r <= 0) return it.value; + } + return items[items.length - 1].value; + }; + + // Generate random date between startDate and endDate + const year = new Date().getFullYear() - 1; + const start = new Date(year, 0, 1).getTime(); // Jan 1, previous year + const end = new Date(year, 11, 31).getTime(); // Dec 31, previous year + const randomDate = () => new Date(start + Math.floor(rand() * (end - start))); + + // Amount model by category (simple but plausible) + function amountForCategory(cat: string): number { + const round2 = (x: number): number => { + return Math.round(x * 100) / 100; + }; + + switch (cat) { + case 'Rent': + return round2(600 + rand() * 1600); + case 'Utilities': + return round2(30 + rand() * 220); + case 'Groceries': + return round2(10 + rand() * 180); + case 'Dining': + return round2(6 + rand() * 90); + case 'Transport': + return round2(2 + rand() * 120); + case 'Shopping': + return round2(8 + rand() * 450); + case 'Travel': + return round2(30 + rand() * 900); + case 'Insurance': + return round2(20 + rand() * 300); + case 'Entertainment': + return round2(5 + rand() * 80); + case 'Health': + return round2(5 + rand() * 250); + case 'Salary': + return round2(1800 + rand() * 3500); + case 'Transfers': + return round2(20 + rand() * 2000); + default: + return round2(5 + rand() * 200); + } + } + + const rows: ITransaction[] = new Array(count); + for (let i = 0; i < count; i++) { + const catObj = weightedPick(categories.map((c) => ({ value: c, w: c.w }))); + const category = catObj.value; + const merchant = pick(catObj.merchants); + const txnDate = randomDate(); + const status = weightedPick(statuses); + const country = pick(countries); + const currency = countryCurrencyMap[country] ?? defaultCurrency; + const magnitude = amountForCategory(category); + const amount = rand() < 0.5 ? -magnitude : magnitude; + + rows[i] = { + transactionDate: txnDate, + amount, + currency, + category, + merchant, + status, + country, + }; + } + + return rows; +} diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/gridOptions.ts b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/gridOptions.ts new file mode 100644 index 00000000000..3c4aefbf4d2 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/gridOptions.ts @@ -0,0 +1,99 @@ +import type { ColDef, GridOptions, ValueFormatterParams } from 'ag-grid-community'; + +import { ChatToolPanel } from './ChatToolPanel'; +import { CountryFlagCellRenderer } from './CountryFlagCellRenderer'; +import type { ITransaction } from './generateTransactions'; + +export const gridOptions: GridOptions = { + columnDefs: [ + { + field: 'transactionDate', + filter: 'agDateColumnFilter', + groupHierarchy: ['formattedMonth'], + enablePivot: true, + enableRowGroup: true, + valueFormatter: (params: ValueFormatterParams) => { + if (params.value == null) return; + return params.value.toLocaleDateString('en-GB', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }, + }, + { + field: 'country', + filter: 'agSetColumnFilter', + cellRenderer: CountryFlagCellRenderer, + enablePivot: true, + enableRowGroup: true, + }, + { + field: 'status', + filter: 'agSetColumnFilter', + enablePivot: true, + enableRowGroup: true, + }, + { + field: 'amount', + filter: 'agNumberColumnFilter', + valueFormatter: (params) => { + if (params.value == null) return; + return params.value.toLocaleString(`en-${params?.data?.country || 'GB'}`, { + style: 'currency', + currency: params.data?.currency || 'GBP', + }); + }, + cellStyle: (params) => ({ color: params?.value < 0 ? '#dc3545' : '#28a745' }), + enableValue: true, + aggFunc: 'sum', + }, + { + field: 'merchant', + filter: 'agSetColumnFilter', + enablePivot: true, + enableRowGroup: true, + }, + { + field: 'category', + filter: 'agSetColumnFilter', + enablePivot: true, + enableRowGroup: true, + }, + { + field: 'currency', + filter: 'agSetColumnFilter', + enablePivot: true, + enableRowGroup: true, + hide: true, + }, + ], + autoSizeStrategy: { + type: 'fitCellContents', + }, + defaultColDef: { + filter: true, + sortable: true, + resizable: true, + }, + pagination: true, + enableFilterHandlers: true, + sideBar: { + toolPanels: [ + 'columns', + 'filters-new', + { + id: 'chatPanel', + labelDefault: 'AI Assistant', + labelKey: 'chatPanel', + iconKey: 'message', + toolPanel: ChatToolPanel, + }, + ], + defaultToolPanel: 'chatPanel', + }, + icons: { + message: + '', + }, +}; diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/index.html b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/index.html new file mode 100644 index 00000000000..f7aff2860b8 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/index.html @@ -0,0 +1 @@ +
diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/main.ts b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/main.ts new file mode 100644 index 00000000000..d66059c539a --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/main.ts @@ -0,0 +1,19 @@ +import type { GridApi } from 'ag-grid-community'; +import { ModuleRegistry, createGrid } from 'ag-grid-community'; +import { AllEnterpriseModule } from 'ag-grid-enterprise'; + +import { type ITransaction, generateTransactions } from './generateTransactions'; +import { gridOptions } from './gridOptions'; + +ModuleRegistry.registerModules([AllEnterpriseModule]); + +let gridApi: GridApi; + +document.addEventListener('DOMContentLoaded', function () { + const gridDiv = document.querySelector('#myGrid')!; + gridApi = createGrid(gridDiv, gridOptions); + + // Generate synthetic transaction data + const data = generateTransactions({ count: 10000, seed: 42 }); + gridApi.setGridOption('rowData', data); +}); diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/angular/ChatToolPanel.ts b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/angular/ChatToolPanel.ts new file mode 100644 index 00000000000..cc3becf416f --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/angular/ChatToolPanel.ts @@ -0,0 +1,230 @@ +import { Component, ElementRef, afterRenderEffect, signal, viewChild } from '@angular/core'; + +import { IToolPanelAngularComp } from 'ag-grid-angular'; +import { GridApi, IToolPanelParams } from 'ag-grid-community'; + +import { callChatGPT } from './chatgptApi'; +import { ChatMessage } from './types'; + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +// Store conversation history outside the component to persist across grid state changes +let conversationHistory: ChatMessage[] = []; + +@Component({ + selector: 'chat-tool-panel', + standalone: true, + styles: [ + ` + :host { + display: block; + width: 100%; + height: 100%; + overflow: hidden; + } + .chat-tool-panel { + width: 100% !important; + max-width: 100% !important; + overflow: hidden; + } + .chat-messages { + min-width: 0; + overflow-x: hidden; + } + .chat-message { + min-width: 0; + max-width: 85%; + } + .message-bubble { + word-break: break-word; + overflow-wrap: break-word; + } + .chat-input-form { + min-width: 0; + } + .chat-input { + min-width: 0; + } + `, + ], + template: ` +
+
+
+

AI Assistant

+
+ +
+
+

+ This example demonstrates the AI Toolkit with conversation history, embedded in a custom tool panel. +

+
+ +
+ @for (message of messages(); track $index) { +
+
{{ message.content }}
+
+ } + @if (isLoading()) { +
+
+ Thinking... +
+
+ i This demo uses a proxy, so responses may take up to 30 + seconds +
+
+ } +
+ +
+ + +
+
+ `, +}) +export class ChatToolPanel implements IToolPanelAngularComp { + private readonly chatMessagesRef = viewChild>('chatMessages'); + private gridApi!: GridApi; + messages = signal([]); + inputValue = signal(''); + isLoading = signal(false); + + constructor() { + afterRenderEffect({ + write: () => { + this.messages(); + this.isLoading(); + + // Scroll to bottom after render + const element = this.chatMessagesRef()?.nativeElement; + if (element) { + element.scrollTop = element.scrollHeight; + } + }, + }); + } + + agInit(params: IToolPanelParams): void { + this.gridApi = params.api; + // Sync local state with conversation history on init + this.messages.set([...conversationHistory]); + } + + refresh(): void { + // Sync messages when refreshed + this.messages.set([...conversationHistory]); + } + + async handleSubmit(event: Event): Promise { + event.preventDefault(); + + const userMessage = this.inputValue().trim(); + if (!userMessage || this.isLoading()) return; + + // Render user message + this.messages.set([...this.messages(), { role: 'user', content: userMessage }]); + this.inputValue.set(''); + this.isLoading.set(true); + + try { + const response = await callChatGPT(userMessage, this.gridApi, conversationHistory); + + // Log the LLM response + console.log('Explanation:', response.explanation); + if (response.gridState && Object.keys(response.gridState).length > 0) { + console.log('New Grid State: ', response.gridState); + } + if (response.propertiesToIgnore?.length > 0) { + console.log('Properties Ignored:', response.propertiesToIgnore); + } + + // Add both messages to history after successful response + conversationHistory.push( + { role: 'user', content: userMessage }, + { role: 'assistant', content: response.explanation } + ); + + // Apply grid state changes if any (this will destroy and recreate the tool panel) + // Messages will be automatically added when the tool panel reloads + if (response.gridState && Object.keys(response.gridState).length > 0) { + this.gridApi.setState(response.gridState, response.propertiesToIgnore); + } else { + // If no state change, manually update messages + this.messages.set([...conversationHistory]); + } + } catch (error) { + const errorMessage = `Error: ${error instanceof Error ? error.message : String(error)}`; + this.messages.set([...this.messages(), { role: 'assistant', content: errorMessage }]); + } finally { + this.isLoading.set(false); + } + } + + // Send message on Enter, allow new lines with Shift+Enter + handleKeyDown(event: KeyboardEvent): void { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + this.handleSubmit(event); + } + } + + reset(): void { + // Reset conversation + conversationHistory = []; + this.messages.set([]); + this.inputValue.set(''); + + // Reset grid state + this.gridApi.setState({ + columnVisibility: { + hiddenColIds: [ + 'ag-Grid-HierarchyColumn-transactionDate-year', + 'ag-Grid-HierarchyColumn-transactionDate-year', + 'ag-Grid-HierarchyColumn-transactionDate-formattedMonth', + 'ag-Grid-HierarchyColumn-transactionDate-formattedMonth', + 'currency', + ], + }, + columnPinning: { leftColIds: [], rightColIds: [] }, + sort: { sortModel: [] }, + filter: { filterModel: {} }, + rowGroup: { groupColIds: [] }, + pagination: { page: 0, pageSize: 100 }, + }); + } +} diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/angular/CountryFlagCellRenderer.ts b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/angular/CountryFlagCellRenderer.ts new file mode 100644 index 00000000000..13e43c61ee1 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/angular/CountryFlagCellRenderer.ts @@ -0,0 +1,34 @@ +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; + +import { ICellRendererAngularComp } from 'ag-grid-angular'; +import { ICellRendererParams } from 'ag-grid-community'; + +@Component({ + selector: 'country-flag-cell-renderer', + standalone: true, + imports: [CommonModule], + template: ` +
+ + {{ value }} +
+ `, +}) +export class CountryFlagCellRenderer implements ICellRendererAngularComp { + value: string = ''; + flagUrl: string = ''; + + agInit(params: ICellRendererParams): void { + this.value = params.value || ''; + if (this.value) { + const countryCode = this.value.toLowerCase(); + this.flagUrl = `https://flags.fmcdn.net/data/flags/mini/${countryCode}.png`; + } + } + + refresh(params: ICellRendererParams): boolean { + this.agInit(params); + return true; + } +} diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/angular/app.component.ts b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/angular/app.component.ts new file mode 100644 index 00000000000..90a2e164451 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/angular/app.component.ts @@ -0,0 +1,32 @@ +import { Component, OnInit, signal } from '@angular/core'; + +import { AgGridAngular } from 'ag-grid-angular'; +import { ModuleRegistry } from 'ag-grid-community'; +import { AllEnterpriseModule } from 'ag-grid-enterprise'; + +import { ITransaction, generateTransactions } from './generateTransactions'; +import { gridOptions } from './gridOptions'; +import './styles.css'; + +ModuleRegistry.registerModules([AllEnterpriseModule]); + +@Component({ + selector: 'my-app', + standalone: true, + imports: [AgGridAngular], + template: ` +
+ +
+ `, +}) +export class AppComponent implements OnInit { + gridOptions = gridOptions; + rowData = signal([]); + + ngOnInit() { + // Generate synthetic transaction data + const data = generateTransactions({ count: 10000, seed: 42 }); + this.rowData.set(data); + } +} diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/angular/chatgptApi.ts b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/angular/chatgptApi.ts new file mode 100644 index 00000000000..b9011fba39b --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/angular/chatgptApi.ts @@ -0,0 +1,148 @@ +import { GridApi } from 'ag-grid-community'; + +import { ChatMessage } from './ChatToolPanel'; +import { generateSystemPrompt } from './systemPrompt'; + +const CHATGPT_MODEL = 'gpt-5-mini'; +const BASE_URL = 'https://ai-api.ag-grid.com/api/openai/v1'; + +export const callChatGPT = async ( + userRequest: string, + gridApi: GridApi, + conversationHistory: ChatMessage[] = [] +): Promise => { + // Extract relevant parts of the current grid state + const { aggregation, rowGroup, columnSizing, columnVisibility, sort, filter, pivot } = gridApi.getState(); + const currentState = { aggregation, rowGroup, columnSizing, columnVisibility, sort, filter, pivot }; + + // Build LLM Schema from Grid API Structured Schema + const schema = buildLLMSchema(gridApi); + + // Build conversation history with system prompt, previous messages, and user request + const messages: ChatMessage[] = [ + { role: 'system', content: generateSystemPrompt(currentState) }, + ...conversationHistory, + { role: 'user', content: userRequest }, + ]; + + // Send request to ChatGPT API + let result; + try { + result = await sendRequest({ + model: CHATGPT_MODEL, + schema, + messages, + }); + } catch (error: any) { + throw new Error(`OpenAI API error: ${error.message || 'Unknown error'}`); + } + + return result; +}; + +const buildLLMSchema = (gridApi: GridApi): any => { + // Generate structured schema from grid API + const { $defs, ...structuredSchema } = gridApi.getStructuredSchema({ + columns: { + category: { + includeSetValues: true, + }, + merchant: { + includeSetValues: true, + }, + status: { + includeSetValues: true, + }, + currency: { + includeSetValues: true, + }, + country: { + includeSetValues: true, + }, + accountType: { + includeSetValues: true, + }, + type: { + includeSetValues: true, + }, + }, + }); + + // Return LLM compatible JSON Schema from AI Toolkit structured schema + return { + type: 'object', + $defs, + properties: { + gridState: structuredSchema, + propertiesToIgnore: { + type: 'array', + items: { + type: 'string', + enum: ['aggregation', 'filter', 'sort', 'pivot', 'columnVisibility', 'columnSizing', 'rowGroup'], + }, + description: 'List of grid state properties to ignore when applying the new state', + }, + explanation: { + type: 'string', + description: 'Human-readable explanation of the changes made to the grid state', + }, + }, + required: ['gridState', 'explanation', 'propertiesToIgnore'], + additionalProperties: false, + }; +}; + +export const sendRequest = async (options: any): Promise => { + const { model = 'gpt-4o-mini', schema, messages, maxTokens = 4096, stream = false } = options; + + const requestBody = { + model, + messages, + max_completion_tokens: maxTokens, + response_format: schema + ? { + type: 'json_schema', + json_schema: { + name: 'grid_state_response', + schema, + }, + } + : { type: 'json_object' }, + stream, + }; + + const url = `${BASE_URL}/chat/completions`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: 'Unknown error' })); + const error = + errorData.error?.code === 'rate_limit_exceeded' + ? 'OpenAI Rate Limit Exceeded' + : `OpenAI API error: ${response.status} - ${errorData.error?.message || 'Unknown error'}`; + throw new Error(error); + } + + const data = await response.json(); + const content = data.choices[0]?.message?.content; + + if (!content) { + throw new Error('No content received from OpenAI API'); + } + + let parsedObject; + try { + parsedObject = JSON.parse(content); + } catch (error) { + throw new Error(`Failed to parse JSON response: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + + return parsedObject; +}; diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/angular/example.spec.ts b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/angular/example.spec.ts new file mode 100644 index 00000000000..ed79767b830 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/angular/example.spec.ts @@ -0,0 +1,11 @@ +import { clickAllButtons, ensureGridReady, test, waitForGridContent } from '@utils/grid/test-utils'; + +test.agExample(import.meta, () => { + test.eachFramework('Example', async ({ page }) => { + // PLACEHOLDER - MINIMAL TEST TO ENSURE GRID LOADS WITHOUT ERRORS + await ensureGridReady(page); + await waitForGridContent(page); + await clickAllButtons(page); + // END PLACEHOLDER + }); +}); diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/angular/generateTransactions.ts b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/angular/generateTransactions.ts new file mode 100644 index 00000000000..91a33d9754d --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/angular/generateTransactions.ts @@ -0,0 +1,134 @@ +/** + * Generates an array of mock financial transaction data for testing and demonstration purposes. + */ + +export interface ITransaction { + transactionDate: Date; + amount: number; + currency: string; + category: string; + merchant: string; + status: boolean; + country: string; +} + +const countries = ['GB', 'IE', 'FR', 'DE', 'ES', 'NL', 'US']; +const countryCurrencyMap: Record = { + GB: 'GBP', + IE: 'EUR', + FR: 'EUR', + DE: 'EUR', + ES: 'EUR', + NL: 'EUR', + US: 'USD', +}; + +const statuses: { value: boolean; w: number }[] = [ + { value: true, w: 75 }, + { value: false, w: 25 }, +]; + +const categories: { value: string; w: number; merchants: string[] }[] = [ + { value: 'Groceries', w: 14, merchants: ['Tesco', "Sainsbury's", 'Aldi', 'Lidl', 'Waitrose'] }, + { value: 'Rent', w: 6, merchants: ['Landlord Ltd', 'Lettings Co'] }, + { value: 'Utilities', w: 8, merchants: ['British Gas', 'Octopus Energy', 'Thames Water'] }, + { value: 'Dining', w: 10, merchants: ['Pret', "Nando's", 'PizzaExpress', 'Local Cafe'] }, + { value: 'Transport', w: 10, merchants: ['TfL', 'Uber', 'Bolt', 'National Rail'] }, + { value: 'Shopping', w: 12, merchants: ['Amazon', 'John Lewis', 'Argos', 'ASOS'] }, + { value: 'Travel', w: 6, merchants: ['easyJet', 'British Airways', 'Booking.com', 'Trainline'] }, + { value: 'Health', w: 5, merchants: ['Boots', 'NHS', 'Bupa'] }, + { value: 'Salary', w: 6, merchants: ['Acme Corp Payroll', 'Globex Payroll'] }, + { value: 'Transfers', w: 8, merchants: ['Internal Transfer', 'External Transfer'] }, + { value: 'Insurance', w: 5, merchants: ['Aviva', 'AXA', 'Direct Line'] }, + { value: 'Entertainment', w: 10, merchants: ['Netflix', 'Spotify', 'Cinema', 'Steam'] }, +]; + +export function generateTransactions({ count = 10000, seed = 1 } = {}): ITransaction[] { + // --- seeded RNG (Mulberry32) for repeatable demos --- + function mulberry32(a: number) { + return function () { + let t = (a += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + } + const rand = mulberry32(seed); + const pick = (arr: T[]): T => arr[Math.floor(rand() * arr.length)]; + const weightedPick = (items: { value: T; w: number }[]): T => { + const total = items.reduce((s, x) => s + x.w, 0); + let r = rand() * total; + for (const it of items) { + r -= it.w; + if (r <= 0) return it.value; + } + return items[items.length - 1].value; + }; + + // Generate random date between startDate and endDate + const year = new Date().getFullYear() - 1; + const start = new Date(year, 0, 1).getTime(); // Jan 1, previous year + const end = new Date(year, 11, 31).getTime(); // Dec 31, previous year + const randomDate = () => new Date(start + Math.floor(rand() * (end - start))); + + // Amount model by category (simple but plausible) + function amountForCategory(cat: string): number { + const round2 = (x: number): number => { + return Math.round(x * 100) / 100; + }; + + switch (cat) { + case 'Rent': + return round2(600 + rand() * 1600); + case 'Utilities': + return round2(30 + rand() * 220); + case 'Groceries': + return round2(10 + rand() * 180); + case 'Dining': + return round2(6 + rand() * 90); + case 'Transport': + return round2(2 + rand() * 120); + case 'Shopping': + return round2(8 + rand() * 450); + case 'Travel': + return round2(30 + rand() * 900); + case 'Insurance': + return round2(20 + rand() * 300); + case 'Entertainment': + return round2(5 + rand() * 80); + case 'Health': + return round2(5 + rand() * 250); + case 'Salary': + return round2(1800 + rand() * 3500); + case 'Transfers': + return round2(20 + rand() * 2000); + default: + return round2(5 + rand() * 200); + } + } + + const rows: ITransaction[] = new Array(count); + for (let i = 0; i < count; i++) { + const catObj = weightedPick(categories.map((c) => ({ value: c, w: c.w }))); + const category = catObj.value; + const merchant = pick(catObj.merchants); + const txnDate = randomDate(); + const status = weightedPick(statuses); + const country = pick(countries); + const currency = countryCurrencyMap[country]; + const magnitude = amountForCategory(category); + const amount = rand() < 0.5 ? -magnitude : magnitude; + + rows[i] = { + transactionDate: txnDate, + amount, + currency, + category, + merchant, + status, + country, + }; + } + + return rows; +} diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/angular/gridOptions.ts b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/angular/gridOptions.ts new file mode 100644 index 00000000000..50d925b6a32 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/angular/gridOptions.ts @@ -0,0 +1,99 @@ +import { GridOptions, ValueFormatterParams } from 'ag-grid-community'; + +import { ChatToolPanel } from './ChatToolPanel'; +import { CountryFlagCellRenderer } from './CountryFlagCellRenderer'; +import { ITransaction } from './generateTransactions'; + +export const gridOptions: GridOptions = { + columnDefs: [ + { + field: 'transactionDate', + filter: 'agDateColumnFilter', + groupHierarchy: ['formattedMonth'], + enablePivot: true, + enableRowGroup: true, + valueFormatter: (params: ValueFormatterParams) => { + if (params.value == null) return; + return params.value.toLocaleDateString('en-GB', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }, + }, + { + field: 'country', + filter: 'agSetColumnFilter', + cellRenderer: CountryFlagCellRenderer, + enablePivot: true, + enableRowGroup: true, + }, + { + field: 'status', + filter: 'agSetColumnFilter', + enablePivot: true, + enableRowGroup: true, + }, + { + field: 'amount', + filter: 'agNumberColumnFilter', + valueFormatter: (params) => { + if (params.value == null) return; + return params.value.toLocaleString(`en-${params?.data?.country || 'GB'}`, { + style: 'currency', + currency: params.data?.currency || 'GBP', + }); + }, + cellStyle: (params) => ({ color: params?.value < 0 ? '#dc3545' : '#28a745' }), + enableValue: true, + aggFunc: 'sum', + }, + { + field: 'merchant', + filter: 'agSetColumnFilter', + enablePivot: true, + enableRowGroup: true, + }, + { + field: 'category', + filter: 'agSetColumnFilter', + enablePivot: true, + enableRowGroup: true, + }, + { + field: 'currency', + filter: 'agSetColumnFilter', + enablePivot: true, + enableRowGroup: true, + hide: true, + }, + ], + autoSizeStrategy: { + type: 'fitCellContents', + }, + defaultColDef: { + filter: true, + sortable: true, + resizable: true, + }, + pagination: true, + enableFilterHandlers: true, + sideBar: { + toolPanels: [ + 'columns', + 'filters-new', + { + id: 'chatPanel', + labelDefault: 'AI Assistant', + labelKey: 'chatPanel', + iconKey: 'message', + toolPanel: ChatToolPanel, + }, + ], + defaultToolPanel: 'chatPanel', + }, + icons: { + message: + '', + }, +}; diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/angular/systemPrompt.ts b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/angular/systemPrompt.ts new file mode 100644 index 00000000000..219f356add4 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/angular/systemPrompt.ts @@ -0,0 +1,24 @@ +export const generateSystemPrompt = (state: any) => ` +You are an assistant for a table displaying financial transaction data. You help users modify grid configuration to fit their needs. + +The data includes transactions with the following fields: +- country: GB, IE, FR, DE, ES, NL, US +- amount: Positive for credits (income), negative for debits (expenses) +- status: True or False indicating if the transaction is cleared +- transactionDate: When the transaction occurred +- category: Groceries, Rent, Utilities, Dining, Transport, Shopping, Travel, Health, Salary, Transfers, Insurance, Entertainment +- merchant: The business or entity involved +- currency: GBP, EUR, or USD + +The schema provided can be used to manipulate multiple features of the table to help the user with their query. + +Current grid state: ${JSON.stringify(state)} + +Respond with only the necessary state changes, not the complete state. Provide a clear explanation of what you changed. + +Any unchanged properties that are present in the current state must be included in \`propertiesToIgnore\`. Otherwise they will be removed from the state. + +You are not able to make any changes to the grids configuration, e.g. enabling features, you are only able to modify state. + +Important: Only modify the properties that the user specifically requested. If they ask to "filter by category", only include filter in your response, not other unrelated properties. +Where possible, augment the provided state `; diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/reactFunctionalTs/ChatToolPanel.tsx b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/reactFunctionalTs/ChatToolPanel.tsx new file mode 100644 index 00000000000..ad0e248b4c6 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/reactFunctionalTs/ChatToolPanel.tsx @@ -0,0 +1,199 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import type { IToolPanelParams } from 'ag-grid-community'; +import type { CustomToolPanelProps } from 'ag-grid-react'; + +import { callChatGPT } from './chatgptApi'; +import type { ChatMessage } from './types'; + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +// Store conversation history outside the component to persist across grid state changes +let conversationHistory: ChatMessage[] = []; + +export const ChatToolPanel = (props: CustomToolPanelProps & IToolPanelParams) => { + const { api: gridApi } = props; + const [messages, setMessages] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const chatMessagesRef = useRef(null); + + // Sync local state with conversation history on mount + useEffect(() => { + setMessages([...conversationHistory]); + }, []); + + // Scroll to bottom when messages change + useEffect(() => { + if (chatMessagesRef.current) { + chatMessagesRef.current.scrollTop = chatMessagesRef.current.scrollHeight; + } + }, [messages, isLoading]); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + + const userMessage = inputValue.trim(); + if (!userMessage || isLoading) return; + + // Render user message + setMessages((prev) => [...prev, { role: 'user', content: userMessage }]); + setInputValue(''); + setIsLoading(true); + + try { + const response = await callChatGPT(userMessage, gridApi, conversationHistory); + + // Log the LLM response + console.log('Explanation:', response.explanation); + if (response.gridState && Object.keys(response.gridState).length > 0) { + console.log('New Grid State: ', response.gridState); + } + if (response.propertiesToIgnore?.length > 0) { + console.log('Properties Ignored:', response.propertiesToIgnore); + } + + // Add both messages to history after successful response + conversationHistory.push( + { role: 'user', content: userMessage }, + { role: 'assistant', content: response.explanation } + ); + + // Always update messages state to render the assistant response + setMessages([...conversationHistory]); + + // Apply grid state changes if any + if (response.gridState && Object.keys(response.gridState).length > 0) { + gridApi.setState(response.gridState, response.propertiesToIgnore); + } + } catch (error) { + const errorMessage = `Error: ${error instanceof Error ? error.message : String(error)}`; + setMessages((prev) => [...prev, { role: 'assistant', content: errorMessage }]); + } finally { + setIsLoading(false); + } + }, + [inputValue, isLoading, gridApi] + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e as unknown as React.FormEvent); + } + }, + [handleSubmit] + ); + + const reset = useCallback(() => { + // Reset conversation + conversationHistory = []; + setMessages([]); + setInputValue(''); + + // Reset grid state + gridApi.setState({ + columnVisibility: { + hiddenColIds: [ + 'ag-Grid-HierarchyColumn-transactionDate-year', + 'ag-Grid-HierarchyColumn-transactionDate-year', + 'ag-Grid-HierarchyColumn-transactionDate-formattedMonth', + 'ag-Grid-HierarchyColumn-transactionDate-formattedMonth', + 'currency', + ], + }, + columnPinning: { leftColIds: [], rightColIds: [] }, + sort: { sortModel: [] }, + filter: { filterModel: {} }, + rowGroup: { groupColIds: [] }, + pagination: { page: 0, pageSize: 100 }, + }); + }, [gridApi]); + + return ( +
+
+
+

AI Assistant

+
+ +
+
+

+ This example demonstrates the AI Toolkit with conversation history, embedded in a custom tool panel. +

+
+ +
+ {messages.map((message, index) => ( +
+
+ {message.content} +
+
+ ))} + {isLoading && ( +
+
+ + Thinking. + . + . + +
+
+ i This demo uses a proxy, so responses may take up to 30 + seconds +
+
+ )} +
+ +
+ + +
+
+ `, +}); diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/vue3/CountryFlagCellRenderer.ts b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/vue3/CountryFlagCellRenderer.ts new file mode 100644 index 00000000000..86750e283d5 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/vue3/CountryFlagCellRenderer.ts @@ -0,0 +1,32 @@ +import { computed, defineComponent, ref } from 'vue'; + +import { ICellRendererParams } from 'ag-grid-community'; + +export const CountryFlagCellRenderer = defineComponent({ + props: { + params: { + type: Object as () => ICellRendererParams, + required: true, + }, + }, + setup(props) { + const value = ref(props.params.value || ''); + + const flagUrl = computed(() => { + if (!value.value) return ''; + const countryCode = value.value.toLowerCase(); + return `https://flags.fmcdn.net/data/flags/mini/${countryCode}.png`; + }); + + return { + value, + flagUrl, + }; + }, + template: ` +
+ + {{ value }} +
+ `, +}); diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/vue3/chatgptApi.ts b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/vue3/chatgptApi.ts new file mode 100644 index 00000000000..b9011fba39b --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/vue3/chatgptApi.ts @@ -0,0 +1,148 @@ +import { GridApi } from 'ag-grid-community'; + +import { ChatMessage } from './ChatToolPanel'; +import { generateSystemPrompt } from './systemPrompt'; + +const CHATGPT_MODEL = 'gpt-5-mini'; +const BASE_URL = 'https://ai-api.ag-grid.com/api/openai/v1'; + +export const callChatGPT = async ( + userRequest: string, + gridApi: GridApi, + conversationHistory: ChatMessage[] = [] +): Promise => { + // Extract relevant parts of the current grid state + const { aggregation, rowGroup, columnSizing, columnVisibility, sort, filter, pivot } = gridApi.getState(); + const currentState = { aggregation, rowGroup, columnSizing, columnVisibility, sort, filter, pivot }; + + // Build LLM Schema from Grid API Structured Schema + const schema = buildLLMSchema(gridApi); + + // Build conversation history with system prompt, previous messages, and user request + const messages: ChatMessage[] = [ + { role: 'system', content: generateSystemPrompt(currentState) }, + ...conversationHistory, + { role: 'user', content: userRequest }, + ]; + + // Send request to ChatGPT API + let result; + try { + result = await sendRequest({ + model: CHATGPT_MODEL, + schema, + messages, + }); + } catch (error: any) { + throw new Error(`OpenAI API error: ${error.message || 'Unknown error'}`); + } + + return result; +}; + +const buildLLMSchema = (gridApi: GridApi): any => { + // Generate structured schema from grid API + const { $defs, ...structuredSchema } = gridApi.getStructuredSchema({ + columns: { + category: { + includeSetValues: true, + }, + merchant: { + includeSetValues: true, + }, + status: { + includeSetValues: true, + }, + currency: { + includeSetValues: true, + }, + country: { + includeSetValues: true, + }, + accountType: { + includeSetValues: true, + }, + type: { + includeSetValues: true, + }, + }, + }); + + // Return LLM compatible JSON Schema from AI Toolkit structured schema + return { + type: 'object', + $defs, + properties: { + gridState: structuredSchema, + propertiesToIgnore: { + type: 'array', + items: { + type: 'string', + enum: ['aggregation', 'filter', 'sort', 'pivot', 'columnVisibility', 'columnSizing', 'rowGroup'], + }, + description: 'List of grid state properties to ignore when applying the new state', + }, + explanation: { + type: 'string', + description: 'Human-readable explanation of the changes made to the grid state', + }, + }, + required: ['gridState', 'explanation', 'propertiesToIgnore'], + additionalProperties: false, + }; +}; + +export const sendRequest = async (options: any): Promise => { + const { model = 'gpt-4o-mini', schema, messages, maxTokens = 4096, stream = false } = options; + + const requestBody = { + model, + messages, + max_completion_tokens: maxTokens, + response_format: schema + ? { + type: 'json_schema', + json_schema: { + name: 'grid_state_response', + schema, + }, + } + : { type: 'json_object' }, + stream, + }; + + const url = `${BASE_URL}/chat/completions`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: 'Unknown error' })); + const error = + errorData.error?.code === 'rate_limit_exceeded' + ? 'OpenAI Rate Limit Exceeded' + : `OpenAI API error: ${response.status} - ${errorData.error?.message || 'Unknown error'}`; + throw new Error(error); + } + + const data = await response.json(); + const content = data.choices[0]?.message?.content; + + if (!content) { + throw new Error('No content received from OpenAI API'); + } + + let parsedObject; + try { + parsedObject = JSON.parse(content); + } catch (error) { + throw new Error(`Failed to parse JSON response: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + + return parsedObject; +}; diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/vue3/example.spec.ts b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/vue3/example.spec.ts new file mode 100644 index 00000000000..ed79767b830 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/vue3/example.spec.ts @@ -0,0 +1,11 @@ +import { clickAllButtons, ensureGridReady, test, waitForGridContent } from '@utils/grid/test-utils'; + +test.agExample(import.meta, () => { + test.eachFramework('Example', async ({ page }) => { + // PLACEHOLDER - MINIMAL TEST TO ENSURE GRID LOADS WITHOUT ERRORS + await ensureGridReady(page); + await waitForGridContent(page); + await clickAllButtons(page); + // END PLACEHOLDER + }); +}); diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/vue3/generateTransactions.ts b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/vue3/generateTransactions.ts new file mode 100644 index 00000000000..91a33d9754d --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/vue3/generateTransactions.ts @@ -0,0 +1,134 @@ +/** + * Generates an array of mock financial transaction data for testing and demonstration purposes. + */ + +export interface ITransaction { + transactionDate: Date; + amount: number; + currency: string; + category: string; + merchant: string; + status: boolean; + country: string; +} + +const countries = ['GB', 'IE', 'FR', 'DE', 'ES', 'NL', 'US']; +const countryCurrencyMap: Record = { + GB: 'GBP', + IE: 'EUR', + FR: 'EUR', + DE: 'EUR', + ES: 'EUR', + NL: 'EUR', + US: 'USD', +}; + +const statuses: { value: boolean; w: number }[] = [ + { value: true, w: 75 }, + { value: false, w: 25 }, +]; + +const categories: { value: string; w: number; merchants: string[] }[] = [ + { value: 'Groceries', w: 14, merchants: ['Tesco', "Sainsbury's", 'Aldi', 'Lidl', 'Waitrose'] }, + { value: 'Rent', w: 6, merchants: ['Landlord Ltd', 'Lettings Co'] }, + { value: 'Utilities', w: 8, merchants: ['British Gas', 'Octopus Energy', 'Thames Water'] }, + { value: 'Dining', w: 10, merchants: ['Pret', "Nando's", 'PizzaExpress', 'Local Cafe'] }, + { value: 'Transport', w: 10, merchants: ['TfL', 'Uber', 'Bolt', 'National Rail'] }, + { value: 'Shopping', w: 12, merchants: ['Amazon', 'John Lewis', 'Argos', 'ASOS'] }, + { value: 'Travel', w: 6, merchants: ['easyJet', 'British Airways', 'Booking.com', 'Trainline'] }, + { value: 'Health', w: 5, merchants: ['Boots', 'NHS', 'Bupa'] }, + { value: 'Salary', w: 6, merchants: ['Acme Corp Payroll', 'Globex Payroll'] }, + { value: 'Transfers', w: 8, merchants: ['Internal Transfer', 'External Transfer'] }, + { value: 'Insurance', w: 5, merchants: ['Aviva', 'AXA', 'Direct Line'] }, + { value: 'Entertainment', w: 10, merchants: ['Netflix', 'Spotify', 'Cinema', 'Steam'] }, +]; + +export function generateTransactions({ count = 10000, seed = 1 } = {}): ITransaction[] { + // --- seeded RNG (Mulberry32) for repeatable demos --- + function mulberry32(a: number) { + return function () { + let t = (a += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + } + const rand = mulberry32(seed); + const pick = (arr: T[]): T => arr[Math.floor(rand() * arr.length)]; + const weightedPick = (items: { value: T; w: number }[]): T => { + const total = items.reduce((s, x) => s + x.w, 0); + let r = rand() * total; + for (const it of items) { + r -= it.w; + if (r <= 0) return it.value; + } + return items[items.length - 1].value; + }; + + // Generate random date between startDate and endDate + const year = new Date().getFullYear() - 1; + const start = new Date(year, 0, 1).getTime(); // Jan 1, previous year + const end = new Date(year, 11, 31).getTime(); // Dec 31, previous year + const randomDate = () => new Date(start + Math.floor(rand() * (end - start))); + + // Amount model by category (simple but plausible) + function amountForCategory(cat: string): number { + const round2 = (x: number): number => { + return Math.round(x * 100) / 100; + }; + + switch (cat) { + case 'Rent': + return round2(600 + rand() * 1600); + case 'Utilities': + return round2(30 + rand() * 220); + case 'Groceries': + return round2(10 + rand() * 180); + case 'Dining': + return round2(6 + rand() * 90); + case 'Transport': + return round2(2 + rand() * 120); + case 'Shopping': + return round2(8 + rand() * 450); + case 'Travel': + return round2(30 + rand() * 900); + case 'Insurance': + return round2(20 + rand() * 300); + case 'Entertainment': + return round2(5 + rand() * 80); + case 'Health': + return round2(5 + rand() * 250); + case 'Salary': + return round2(1800 + rand() * 3500); + case 'Transfers': + return round2(20 + rand() * 2000); + default: + return round2(5 + rand() * 200); + } + } + + const rows: ITransaction[] = new Array(count); + for (let i = 0; i < count; i++) { + const catObj = weightedPick(categories.map((c) => ({ value: c, w: c.w }))); + const category = catObj.value; + const merchant = pick(catObj.merchants); + const txnDate = randomDate(); + const status = weightedPick(statuses); + const country = pick(countries); + const currency = countryCurrencyMap[country]; + const magnitude = amountForCategory(category); + const amount = rand() < 0.5 ? -magnitude : magnitude; + + rows[i] = { + transactionDate: txnDate, + amount, + currency, + category, + merchant, + status, + country, + }; + } + + return rows; +} diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/vue3/gridOptions.ts b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/vue3/gridOptions.ts new file mode 100644 index 00000000000..50d925b6a32 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/vue3/gridOptions.ts @@ -0,0 +1,99 @@ +import { GridOptions, ValueFormatterParams } from 'ag-grid-community'; + +import { ChatToolPanel } from './ChatToolPanel'; +import { CountryFlagCellRenderer } from './CountryFlagCellRenderer'; +import { ITransaction } from './generateTransactions'; + +export const gridOptions: GridOptions = { + columnDefs: [ + { + field: 'transactionDate', + filter: 'agDateColumnFilter', + groupHierarchy: ['formattedMonth'], + enablePivot: true, + enableRowGroup: true, + valueFormatter: (params: ValueFormatterParams) => { + if (params.value == null) return; + return params.value.toLocaleDateString('en-GB', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }, + }, + { + field: 'country', + filter: 'agSetColumnFilter', + cellRenderer: CountryFlagCellRenderer, + enablePivot: true, + enableRowGroup: true, + }, + { + field: 'status', + filter: 'agSetColumnFilter', + enablePivot: true, + enableRowGroup: true, + }, + { + field: 'amount', + filter: 'agNumberColumnFilter', + valueFormatter: (params) => { + if (params.value == null) return; + return params.value.toLocaleString(`en-${params?.data?.country || 'GB'}`, { + style: 'currency', + currency: params.data?.currency || 'GBP', + }); + }, + cellStyle: (params) => ({ color: params?.value < 0 ? '#dc3545' : '#28a745' }), + enableValue: true, + aggFunc: 'sum', + }, + { + field: 'merchant', + filter: 'agSetColumnFilter', + enablePivot: true, + enableRowGroup: true, + }, + { + field: 'category', + filter: 'agSetColumnFilter', + enablePivot: true, + enableRowGroup: true, + }, + { + field: 'currency', + filter: 'agSetColumnFilter', + enablePivot: true, + enableRowGroup: true, + hide: true, + }, + ], + autoSizeStrategy: { + type: 'fitCellContents', + }, + defaultColDef: { + filter: true, + sortable: true, + resizable: true, + }, + pagination: true, + enableFilterHandlers: true, + sideBar: { + toolPanels: [ + 'columns', + 'filters-new', + { + id: 'chatPanel', + labelDefault: 'AI Assistant', + labelKey: 'chatPanel', + iconKey: 'message', + toolPanel: ChatToolPanel, + }, + ], + defaultToolPanel: 'chatPanel', + }, + icons: { + message: + '', + }, +}; diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/vue3/main.ts b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/vue3/main.ts new file mode 100644 index 00000000000..01e491bb8a8 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/vue3/main.ts @@ -0,0 +1,37 @@ +import { createApp, ref } from 'vue'; + +import { ModuleRegistry } from 'ag-grid-community'; +import { AllEnterpriseModule } from 'ag-grid-enterprise'; +import { AgGridVue } from 'ag-grid-vue3'; + +import { ITransaction, generateTransactions } from './generateTransactions'; +import { gridOptions } from './gridOptions'; +import './styles.css'; + +ModuleRegistry.registerModules([AllEnterpriseModule]); + +const App = { + components: { + AgGridVue, + }, + setup() { + // Generate synthetic transaction data + const rowData = ref(generateTransactions({ count: 10000, seed: 42 })); + + return { + rowData, + gridOptions: gridOptions, + }; + }, + template: ` +
+ +
+ `, +}; + +createApp(App).mount('#app'); diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/vue3/systemPrompt.ts b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/vue3/systemPrompt.ts new file mode 100644 index 00000000000..219f356add4 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/provided/vue3/systemPrompt.ts @@ -0,0 +1,24 @@ +export const generateSystemPrompt = (state: any) => ` +You are an assistant for a table displaying financial transaction data. You help users modify grid configuration to fit their needs. + +The data includes transactions with the following fields: +- country: GB, IE, FR, DE, ES, NL, US +- amount: Positive for credits (income), negative for debits (expenses) +- status: True or False indicating if the transaction is cleared +- transactionDate: When the transaction occurred +- category: Groceries, Rent, Utilities, Dining, Transport, Shopping, Travel, Health, Salary, Transfers, Insurance, Entertainment +- merchant: The business or entity involved +- currency: GBP, EUR, or USD + +The schema provided can be used to manipulate multiple features of the table to help the user with their query. + +Current grid state: ${JSON.stringify(state)} + +Respond with only the necessary state changes, not the complete state. Provide a clear explanation of what you changed. + +Any unchanged properties that are present in the current state must be included in \`propertiesToIgnore\`. Otherwise they will be removed from the state. + +You are not able to make any changes to the grids configuration, e.g. enabling features, you are only able to modify state. + +Important: Only modify the properties that the user specifically requested. If they ask to "filter by category", only include filter in your response, not other unrelated properties. +Where possible, augment the provided state `; diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/styles.css b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/styles.css new file mode 100644 index 00000000000..20fd508e2b3 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/styles.css @@ -0,0 +1,515 @@ +/** + * Styles for control elements in examples, not required for the examples' functionality + */ +:root { + --main-fg: #101828; + --main-bg: #fff; + + --chart-bg: #fff; + --chart-border: #d0d5dd; + + --button-fg: #212529; + --button-bg: transparent; + --button-border: #d0d5dd; + --button-hover-bg: rgba(0, 0, 0, 0.1); + + --input-accent: #0e4491; + --input-focus-border: #3d7acd; + --range-track-bg: #efefef; + + --row-gap: 6px; + + --select-chevron: url('data:image/svg+xml;utf8,'); + --checkbox-tick-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6L9 17L4 12'/%3E%3C/svg%3E"); + + --success: #0759c2; + --error: #dc0505; +} + +:root[data-color-scheme='dark'] { + --main-fg: #fff; + --main-bg: #141d2c; + + --chart-bg: #192232; + --chart-border: #344054; + + --button-fg: #f8f9fa; + --button-bg: transparent; + --button-border: rgba(255, 255, 255, 0.2); + --button-hover-bg: #2a343e; + + --input-accent: #a9c5ec; + --input-focus-border: #3d7acd; + --range-track-bg: #4a5465; + + --select-chevron: url('data:image/svg+xml;utf8,'); + --checkbox-tick-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%232a343e' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6L9 17L4 12'/%3E%3C/svg%3E"); + + --success: #9bc7ff; + --error: #ff7878; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +:root, +body { + height: 100%; + width: 100%; + margin: 0; + overflow: hidden; +} + +/* Hide codesandbox highlighter */ +body > #highlighter { + display: none; +} + +.example-controls { + display: flex; + flex-direction: column; + flex-wrap: wrap; +} + +.example-controls *, +.example-controls *::before, +.example-controls *::after { + margin: 0 !important; + font-family: -apple-system, 'system-ui', sans-serif; + font-size: 14px; + font-weight: 500; + line-height: 17px; + letter-spacing: 0.01em; + color: var(--main-fg); +} + +.example-controls :where(button, textarea, select, input[type='submit'], input[type='text'], input[type='number']) { + appearance: none; + display: inline-block; + height: 36px; + padding: 5px 14px 7px; + white-space: nowrap; + border-radius: 6px; + color: var(--button-fg) !important; + background-color: var(--button-bg); + border: 1px solid var(--button-border); + box-shadow: 0 0 0 0 transparent; + transition: + background-color 0.25s ease-in-out, + border-color 0.25s ease-in-out, + box-shadow 0.25s ease-in-out; + align-self: flex-start; +} + +.example-controls :where(button, select, input[type='submit']) { + cursor: pointer; +} + +.example-controls select { + appearance: none; + padding-right: 32px; + padding-left: 14px; + background: no-repeat center right 4px var(--select-chevron); +} + +.example-controls textarea { + height: auto; + padding: 7px 14px; +} + +.example-controls pre, +.example-controls code { + font-family: SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; +} + +.example-controls input { + appearance: none; +} + +.example-controls input[type='checkbox'], +.example-controls input[type='radio'] { + border: 1px solid var(--button-border); + cursor: pointer; +} + +.example-controls input[type='radio'] { + width: 20px; + height: 20px; + border-radius: 50%; +} + +.example-controls input[type='radio']:checked { + border-width: 0; + box-shadow: inset 0 0 0 6px var(--input-accent); +} + +.example-controls input[type='radio']:checked:focus-visible { + box-shadow: + inset 0 0 0 2px var(--input-focus-border), + inset 0 0 0 3px var(--main-bg), + inset 0 0 0 6px var(--input-accent); +} + +.example-controls input[type='checkbox'] { + width: 24px; + height: 24px; + border-radius: 6px; + cursor: pointer; +} + +.example-controls input[type='checkbox']:checked { + background: var(--input-accent) no-repeat center/14px var(--checkbox-tick-icon); + border-color: var(--input-accent); +} + +.example-controls input[type='range'] { + appearance: none; + min-width: 160px; + border-radius: 8px; + cursor: pointer; + overflow: hidden; /* slider progress trick */ + background: var(--range-track-bg); +} + +.example-controls input[type='range']::-webkit-slider-runnable-track { + appearance: none; + height: 16px; + background: var(--range-track-bg); +} + +.example-controls input[type='range']::-moz-range-track { + appearance: none; + height: 16px; + background: var(--range-track-bg); +} + +.example-controls input[type='range']::-webkit-slider-thumb { + appearance: none; + height: 16px; + width: 16px; + background-color: var(--main-bg); + border-radius: 50%; + border: 2px solid var(--input-accent); + box-shadow: -1007px 0 0 1000px var(--input-accent); /* slider progress trick */ +} + +.example-controls input[type='range']::-moz-range-thumb { + appearance: none; + height: 16px; + width: 16px; + background-color: var(--main-bg); + border-radius: 50%; + border: 2px solid var(--input-accent); + box-shadow: -1007px 0 0 1000px var(--input-accent); /* slider progress trick */ +} + +.example-controls :is(button, input[type='submit'], select):hover { + background-color: var(--button-hover-bg); +} + +.example-controls :is(button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible) { + border-color: var(--input-focus-border) !important; + box-shadow: + inset 0 0 0 1px var(--input-focus-border), + inset 0 0 0 2px var(--main-bg); + outline: none; +} + +.controls-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--row-gap); + font-variant: tabular-nums; +} + +.controls-row + .controls-row { + margin-top: var(--row-gap); +} + +.controls-row.center { + justify-content: center; +} + +.controls-row .push-right { + margin-left: auto; +} + +.controls-row .push-left { + margin-right: auto; +} + +.controls-row .gap-right { + margin-right: calc(var(--row-gap) * 6); +} + +.controls-row .gap-left { + margin-left: calc(var(--row-gap) * 6); +} + +/* Additional Styles */ +#myGrid { + height: 100vh; + width: 100vw; +} + +/* Chat Tool Panel Styles */ +.chat-tool-panel { + display: flex; + flex-direction: column; + height: 100%; + background: var(--main-bg); + font-family: -apple-system, 'system-ui', sans-serif; + width: 400px; +} + +.chat-header { + padding: 12px; + border-bottom: 1px solid var(--chart-border); + background: var(--main-bg); +} + +.chat-title-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.chat-title { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--main-fg); +} + +.chat-subtitle { + margin: 0; + font-size: 13px; + font-weight: 400; + color: var(--main-fg); + opacity: 0.7; +} + +.chat-actions { + display: flex; + gap: 4px; +} + +.icon-btn { + appearance: none; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border-radius: 6px; + border: 1px solid transparent; + background: transparent; + color: var(--main-fg); + opacity: 0.6; + cursor: pointer; + transition: all 0.2s; +} + +.icon-btn:hover { + opacity: 1; + background: var(--button-hover-bg); + border-color: var(--button-border); +} + +.icon-btn:active { + transform: scale(0.95); +} + +.icon-btn svg { + flex-shrink: 0; +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.chat-message { + display: flex; + flex-direction: column; + max-width: 85%; + animation: slideIn 0.2s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.user-message { + align-self: flex-end; +} + +.assistant-message { + align-self: flex-start; +} + +.message-bubble { + padding: 10px 14px; + border-radius: 12px; + font-size: 14px; + line-height: 1.4; + word-wrap: break-word; +} + +.user-message .message-bubble { + background: var(--ag-accent-color); + color: white; + border-bottom-right-radius: 4px; +} + +.assistant-message .message-bubble { + background: color-mix(in srgb, var(--main-fg) 8%, transparent); + color: var(--main-fg); + border-bottom-left-radius: 4px; +} + +.error-message .message-bubble { + background: color-mix(in srgb, var(--error) 15%, transparent); + color: var(--error); + border: 1px solid var(--error); +} + +.loading-dots { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.loading-dots span { + animation: blink 1.4s infinite; + opacity: 0; +} + +.loading-dots span:nth-child(2) { + animation-delay: 0.2s; +} + +.loading-dots span:nth-child(3) { + animation-delay: 0.4s; +} + +.loading-dots span:nth-child(4) { + animation-delay: 0.6s; +} + +@keyframes blink { + 0%, + 20% { + opacity: 0; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + +.loading-disclaimer { + margin-top: 6px; + margin-left: 4px; + font-size: 11px; + font-style: italic; + opacity: 0.65; + line-height: 1.3; + display: flex; + align-items: start; + gap: 4px; +} + +.info-icon { + font-style: normal; + font-size: 12px; + opacity: 0.8; +} + +.chat-input-form { + display: flex; + align-items: end; + padding: 12px; + gap: 8px; + border-top: 1px solid var(--chart-border); + background: var(--main-bg); +} + +.chat-input { + flex: 1; + padding: 10px 12px; + font-size: 12px; + width: 50%; + border: 1px solid var(--button-border); + border-radius: 8px; + background: var(--main-bg); + color: var(--main-fg); + outline: none; + transition: border-color 0.2s; +} + +.chat-input:focus { + border-color: var(--input-focus-border); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--input-focus-border) 20%, transparent); +} + +.chat-input::placeholder { + color: color-mix(in srgb, var(--main-fg) 40%, transparent); + font-style: italic; +} + +.chat-submit { + appearance: none; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + border: 1px solid var(--button-border); + background: var(--ag-accent-color); + color: white; + font-size: 18px; + cursor: pointer; + transition: all 0.2s; + flex-shrink: 0; +} + +.chat-submit:hover { + background: color-mix(in srgb, var(--input-accent) 85%, black); + transform: translateX(2px); +} + +.chat-submit:active { + transform: scale(0.95) translateX(2px); +} + +.chat-submit:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.chat-submit:disabled:hover { + background: var(--input-accent); + transform: none; +} diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/systemPrompt.ts b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/systemPrompt.ts new file mode 100644 index 00000000000..219f356add4 --- /dev/null +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/_examples/tool-panel-chat-assistant/systemPrompt.ts @@ -0,0 +1,24 @@ +export const generateSystemPrompt = (state: any) => ` +You are an assistant for a table displaying financial transaction data. You help users modify grid configuration to fit their needs. + +The data includes transactions with the following fields: +- country: GB, IE, FR, DE, ES, NL, US +- amount: Positive for credits (income), negative for debits (expenses) +- status: True or False indicating if the transaction is cleared +- transactionDate: When the transaction occurred +- category: Groceries, Rent, Utilities, Dining, Transport, Shopping, Travel, Health, Salary, Transfers, Insurance, Entertainment +- merchant: The business or entity involved +- currency: GBP, EUR, or USD + +The schema provided can be used to manipulate multiple features of the table to help the user with their query. + +Current grid state: ${JSON.stringify(state)} + +Respond with only the necessary state changes, not the complete state. Provide a clear explanation of what you changed. + +Any unchanged properties that are present in the current state must be included in \`propertiesToIgnore\`. Otherwise they will be removed from the state. + +You are not able to make any changes to the grids configuration, e.g. enabling features, you are only able to modify state. + +Important: Only modify the properties that the user specifically requested. If they ask to "filter by category", only include filter in your response, not other unrelated properties. +Where possible, augment the provided state `; diff --git a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/index.mdoc b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/index.mdoc index 8a5a5432693..eb00ef36805 100644 --- a/documentation/ag-grid-docs/src/content/docs/ai-toolkit/index.mdoc +++ b/documentation/ag-grid-docs/src/content/docs/ai-toolkit/index.mdoc @@ -258,6 +258,19 @@ If you choose to do this, make sure that the result is still a valid `GridState` - Monitor total prompt size; keep within your model’s context window with a buffer for the model’s response. +## Example: AI Chat Assistant + +This example demonstrates an AI chat assistant embedded within the grid's [Side Bar](./side-bar/) as a [Custom Tool Panel](./component-tool-panel/). The assistant maintains conversation history, enabling multi-turn interactions where users can reference previous responses and refine their requests. + +Try the following sequence of prompts to see how the assistant remembers context: + +1. **"Suggest 3 different ways to analyse spending patterns in this data"** +2. **"Apply suggestion 2"** +3. **"Actually, undo that and try suggestion 3 instead"** +4. **"Now add a filter to only show transactions over £100"** + +{% gridExampleRunner title="AI Chat Assistant Embedded within Tool Panel" name="tool-panel-chat-assistant" exampleHeight=600 /%} + ## API {% apiDocumentation source="grid-api/api.json" section="ai-toolkit" names=["getStructuredSchema"] /%} diff --git a/documentation/ag-grid-docs/src/content/docs/cell-data-types/_examples/enable-cell-data-types/main.ts b/documentation/ag-grid-docs/src/content/docs/cell-data-types/_examples/enable-cell-data-types/main.ts index 67e90e37f3a..b10274b34f5 100644 --- a/documentation/ag-grid-docs/src/content/docs/cell-data-types/_examples/enable-cell-data-types/main.ts +++ b/documentation/ag-grid-docs/src/content/docs/cell-data-types/_examples/enable-cell-data-types/main.ts @@ -1,5 +1,6 @@ import type { GridApi, GridOptions } from 'ag-grid-community'; import { + BigIntFilterModule, CheckboxEditorModule, ClientSideRowModelModule, DateEditorModule, @@ -14,6 +15,7 @@ import { } from 'ag-grid-community'; ModuleRegistry.registerModules([ + BigIntFilterModule, NumberEditorModule, NumberFilterModule, CheckboxEditorModule, @@ -31,6 +33,7 @@ interface IOlympicDataTypes extends IOlympicData { dateTimeString: string; hasGold: boolean; hasSilver: boolean; + medalsBigInt: bigint; countryObject: { name: string; }; @@ -42,6 +45,7 @@ const gridOptions: GridOptions = { columnDefs: [ { field: 'athlete' }, { field: 'age', minWidth: 100 }, + { field: 'medalsBigInt', headerName: 'Total (BigInt)', minWidth: 160, cellDataType: 'bigint' }, { field: 'hasGold', minWidth: 100, headerName: 'Gold' }, { field: 'hasSilver', minWidth: 100, headerName: 'Silver', cellRendererParams: { disabled: true } }, { field: 'dateObject', headerName: 'Date' }, @@ -99,6 +103,7 @@ document.addEventListener('DOMContentLoaded', () => { }, hasGold: rowData.gold > 0, hasSilver: rowData.silver > 0, + medalsBigInt: BigInt(rowData.gold + rowData.silver + rowData.bronze), }; }) ) diff --git a/documentation/ag-grid-docs/src/content/docs/cell-data-types/index.mdoc b/documentation/ag-grid-docs/src/content/docs/cell-data-types/index.mdoc index e8187b276de..d6747a2883b 100644 --- a/documentation/ag-grid-docs/src/content/docs/cell-data-types/index.mdoc +++ b/documentation/ag-grid-docs/src/content/docs/cell-data-types/index.mdoc @@ -8,7 +8,7 @@ This allows different grid features to work without any additional configuration ## Enable Cell Data Types -There are a number of pre-defined cell data types: `'text'`, `'number'`, `'boolean'`, `'date'`, `'dateString'`, `'dateTime'`, `'dateTimeString'` and `'object'`. +There are a number of pre-defined cell data types: `'text'`, `'number'`, `'bigint'`, `'boolean'`, `'date'`, `'dateString'`, `'dateTime'`, `'dateTimeString'` and `'object'`. These are enabled by default, with the data type being inferred from the row data if possible (see [Inferring Data Types](#inferring-data-types)). @@ -30,6 +30,7 @@ The following example demonstrates the pre-defined cell data types (most of whic * The **Athlete** column has a `'text'` data type. * The **Age** column has a `'number'` data type. +* The **Total (BigInt)** column has a `'bigint'` data type. * The **Gold** column has a `'boolean'` data type. * The **Date** column has a `'date'` data type (cell values are `Date` objects). * The **DateTime** column has a `'dateTime'` data type (cell values are `Date` objects). This is explicitly set to `cellDataType: 'dateTime'` as `Date` objects are inferred to be `'date'` data type. @@ -89,6 +90,26 @@ The following properties are set: To show only a certain number of decimal places, you can [Override the Pre-Defined Cell Data Type Definition](#overriding-the-pre-defined-cell-data-type-definitions) and provide your own Value Formatter. It is also possible to control the number of decimal places allowed during editing, by providing a precision to the [Number Cell Editor](./provided-cell-editors-number/). +### BigInt + +The `'bigint'` cell data type is used for `bigint` values. + +The following properties are set: + +* The [Text Cell Editor](./provided-cell-editors-text/) is used for editing, with the Value Parser converting input to `bigint`. +* When the [Set Filter is Disabled by Default](./filter-set/#suppress-set-filter-by-default), the **BigInt Filter** is used. +* When the [Set Filter](./filter-set/) is used, `filterParams.comparator` is set to sort the filter list using `bigint` comparisons. +* A `comparator` is defined to allow [Custom Sorting](./row-sorting/#custom-sorting) using `bigint` values, including absolute sort. + +BigInt behaviour and limitations: + +* Inputs must be decimal integers. Both `500` and `500n` are accepted, but hex, binary, decimals, and scientific notation are rejected. +* Values are displayed as plain strings by default. Use a Value Formatter to add separators or custom formatting. +* Aggregation and pivoting support `sum`, `min`, `max`, `count`. `avg` uses integer division when any `bigint` values are present, so the fractional part is discarded. +* CSV and clipboard export use the exact integer string. Excel export defaults to Text to avoid precision loss; you can opt into Number via [Excel export styles](/javascript-data-grid/excel-export-data-types/), but large values may lose precision. + +If you need a custom input format, provide a custom Value Parser/Formatter by [Overriding the Pre-Defined Cell Data Type Definition](#overriding-the-pre-defined-cell-data-type-definitions). + ### Boolean The `'boolean'` cell data type is used for `boolean` values. diff --git a/documentation/ag-grid-docs/src/content/docs/component-cell-renderer/_examples/handling-mouse-events/provided/reactFunctionalTs/index.tsx b/documentation/ag-grid-docs/src/content/docs/component-cell-renderer/_examples/handling-mouse-events/provided/reactFunctionalTs/index.tsx index 6d15e3f2c55..546d8d06c73 100644 --- a/documentation/ag-grid-docs/src/content/docs/component-cell-renderer/_examples/handling-mouse-events/provided/reactFunctionalTs/index.tsx +++ b/documentation/ag-grid-docs/src/content/docs/component-cell-renderer/_examples/handling-mouse-events/provided/reactFunctionalTs/index.tsx @@ -1,6 +1,6 @@ 'use client'; -import { StrictMode, useCallback, useMemo, useState } from 'react'; +import React, { StrictMode, useCallback, useMemo, useState } from 'react'; import { createRoot } from 'react-dom/client'; import type { diff --git a/documentation/ag-grid-docs/src/content/docs/filter-set-filter-list/_examples/case-sensitive-set-filter-list/provided/reactFunctionalTs/index.tsx b/documentation/ag-grid-docs/src/content/docs/filter-set-filter-list/_examples/case-sensitive-set-filter-list/provided/reactFunctionalTs/index.tsx index c9649ce21e9..6e8c41516a8 100644 --- a/documentation/ag-grid-docs/src/content/docs/filter-set-filter-list/_examples/case-sensitive-set-filter-list/provided/reactFunctionalTs/index.tsx +++ b/documentation/ag-grid-docs/src/content/docs/filter-set-filter-list/_examples/case-sensitive-set-filter-list/provided/reactFunctionalTs/index.tsx @@ -109,20 +109,22 @@ const GridExample = () => { }, []); return ( -
-
-
- + +
+
+
+ +
-
+ ); }; diff --git a/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-row-editable-totals/main.ts b/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-row-editable-totals/main.ts index bd448f2d54f..bb82f0262b9 100644 --- a/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-row-editable-totals/main.ts +++ b/documentation/ag-grid-docs/src/content/docs/grouping-edit/_examples/group-row-editable-totals/main.ts @@ -3,6 +3,7 @@ import { ClientSideRowModelModule, ModuleRegistry, NumberFilterModule, + TextEditorModule, ValidationModule, createGrid, } from 'ag-grid-community'; @@ -23,6 +24,7 @@ ModuleRegistry.registerModules([ ClientSideRowModelModule, NumberFilterModule, SetFilterModule, + TextEditorModule, ...(process.env.NODE_ENV !== 'production' ? [ValidationModule] : []), ]); diff --git a/documentation/ag-grid-docs/src/content/module-mappings/modules.json b/documentation/ag-grid-docs/src/content/module-mappings/modules.json index 492f15e8c60..750a1cdab4b 100644 --- a/documentation/ag-grid-docs/src/content/module-mappings/modules.json +++ b/documentation/ag-grid-docs/src/content/module-mappings/modules.json @@ -148,6 +148,11 @@ "name": "Date Filter", "path": "filter-date" }, + { + "moduleName": "BigIntFilterModule", + "name": "BigInt Filter", + "path": "filter-bigint" + }, { "moduleName": "SetFilterModule", "name": "Set Filter", diff --git a/documentation/ag-grid-docs/src/pages/sitemap.astro b/documentation/ag-grid-docs/src/pages/sitemap.astro index ae97c2fe30a..f9518859273 100644 --- a/documentation/ag-grid-docs/src/pages/sitemap.astro +++ b/documentation/ag-grid-docs/src/pages/sitemap.astro @@ -3,15 +3,14 @@ import { Sitemap } from '@ag-website-shared/components/sitemap/Sitemap'; import parseSitemap from '@ag-website-shared/components/sitemap/utils/sitemaputils'; import { getSitemapXml } from '@ag-website-shared/utils/getSitemapXml'; import Layout from '../layouts/Layout.astro'; -import { PRODUCTION_SITE_URL } from '@constants'; +import { LIVE_SITEMAP_URL, PRODUCTION_GRID_SITE_URL } from '@constants'; import { SITEMAP_CACHE_DIR } from '@ag-website-shared/constants'; -const sitemapUrl = `${PRODUCTION_SITE_URL.replace(/\/$/, '')}/sitemap-0.xml`; +const sitemapUrl = LIVE_SITEMAP_URL || `${PRODUCTION_GRID_SITE_URL}/sitemap-0.xml`; const xmlSitemap = await getSitemapXml({ cacheDir: SITEMAP_CACHE_DIR, sitemapUrl, }); - const parsedSitemap = parseSitemap(xmlSitemap); --- diff --git a/external/ag-website-shared/.gitrepo b/external/ag-website-shared/.gitrepo index 5051e74bd5d..372058de850 100644 --- a/external/ag-website-shared/.gitrepo +++ b/external/ag-website-shared/.gitrepo @@ -6,7 +6,7 @@ [subrepo] remote = git@github.com:ag-grid/ag-website-shared.git branch = latest - commit = ae945f9700101f42fc4c02bcead70bde238d1594 - parent = 795d2a5a4985c1c250235d97b092c3657d7de3a9 + commit = a013f797c3aaffd289e128a6785a098a73483c53 + parent = ef4717b49c5f199a9275a1f7e981bbb629020c0f method = rebase cmdver = 0.4.9 diff --git a/external/ag-website-shared/scripts/buildWithSitemapCache.ts b/external/ag-website-shared/scripts/buildWithSitemapCache.ts index c2c682dd099..bb217c5eb45 100644 --- a/external/ag-website-shared/scripts/buildWithSitemapCache.ts +++ b/external/ag-website-shared/scripts/buildWithSitemapCache.ts @@ -1,4 +1,16 @@ #!/usr/bin/env tsx + +/** + * Build helper for AG Charts website. + * + * Runs a full Astro build to generate the sitemap cache, then optionally runs a + * second build that uses the sitemap cache to generate the sitemap page. + * + * Flags: + * - --run-second-build / --no-run-second-build / --run-second-build=false + * - --clean-cache / --no-clean-cache / --clean-cache=false + * - All other params are passed through to Astro + */ import { spawnSync } from 'node:child_process'; import { rmSync } from 'node:fs'; import path from 'node:path'; @@ -6,13 +18,35 @@ import path from 'node:path'; import { SITEMAP_CACHE_DIR } from '../src/constants'; const rawArgs = process.argv.slice(2); -const hasFlag = (flag: string) => - rawArgs.includes(flag) || rawArgs.some((arg) => arg.startsWith(`${flag}=`) && arg !== `${flag}=false`); -const skipSecondBuild = hasFlag('--skip-second-build'); +const normaliseFlag = (flag: string) => flag.replace(/^--/, ''); +const isTruthyValue = (value: string) => !['0', 'false', 'no', 'off'].includes(value.toLowerCase()); +const getFlagValue = (flag: string) => { + const flagName = normaliseFlag(flag); + let value: boolean | undefined; + + for (const arg of rawArgs) { + if (arg === `--${flagName}` || arg === flag) { + value = true; + continue; + } + if (arg === `--no-${flagName}`) { + value = false; + continue; + } + if (arg.startsWith(`--${flagName}=`) || arg.startsWith(`${flag}=`)) { + const [, rawValue = ''] = arg.split('='); + value = isTruthyValue(rawValue); + } + } + + return value; +}; +const hasFlag = (flag: string) => getFlagValue(flag) ?? false; +const runSecondBuild = hasFlag('--run-second-build'); const cleanCache = hasFlag('--clean-cache'); const astroArgs = [ 'build', - ...rawArgs.filter((arg) => !arg.startsWith('--skip-second-build') && !arg.startsWith('--clean-cache')), + ...rawArgs.filter((arg) => !arg.startsWith('--run-second-build') && !arg.startsWith('--clean-cache')), ]; const cleanSitemapCache = async () => { @@ -34,10 +68,8 @@ if (cleanCache) { runBuild(); -if (!skipSecondBuild) { - if (!rawArgs.includes('--silent')) { - console.log('♻️ Building again to use latest sitemap'); - } +if (runSecondBuild) { + console.log('♻️ Building again to use latest sitemap'); runBuild(); } diff --git a/external/ag-website-shared/src/utils/getSitemapXml.ts b/external/ag-website-shared/src/utils/getSitemapXml.ts index b967073e1e7..238e45e5b41 100644 --- a/external/ag-website-shared/src/utils/getSitemapXml.ts +++ b/external/ag-website-shared/src/utils/getSitemapXml.ts @@ -57,7 +57,7 @@ export const getSitemapXml = async ({ if (xmlSitemap == null) { const response = await fetch(sitemapUrl); xmlSitemap = await response.text(); - logger.log('⚠️ No cached sitemap found, fetched from live site.'); + logger.log(`⚠️ No cached sitemap found, fetched from live site: ${sitemapUrl}`); } return xmlSitemap; diff --git a/packages/ag-grid-community/src/agStack/utils/bigInt.test.ts b/packages/ag-grid-community/src/agStack/utils/bigInt.test.ts new file mode 100644 index 00000000000..a3f9861ba0e --- /dev/null +++ b/packages/ag-grid-community/src/agStack/utils/bigInt.test.ts @@ -0,0 +1,34 @@ +import { _parseBigIntOrNull } from './bigInt'; + +describe('_parseBigIntOrNull', () => { + it('returns bigint for decimal strings', () => { + expect(_parseBigIntOrNull('0')).toBe(0n); + expect(_parseBigIntOrNull('123')).toBe(123n); + expect(_parseBigIntOrNull('-42')).toBe(-42n); + expect(_parseBigIntOrNull('+7')).toBe(7n); + expect(_parseBigIntOrNull('0010')).toBe(10n); + }); + + it('accepts optional trailing n suffix', () => { + expect(_parseBigIntOrNull('500n')).toBe(500n); + expect(_parseBigIntOrNull('-12n')).toBe(-12n); + }); + + it('rejects non-integer formats', () => { + expect(_parseBigIntOrNull('')).toBeNull(); + expect(_parseBigIntOrNull(' ')).toBeNull(); + expect(_parseBigIntOrNull('1.2')).toBeNull(); + expect(_parseBigIntOrNull('1e5')).toBeNull(); + expect(_parseBigIntOrNull('0x10')).toBeNull(); + expect(_parseBigIntOrNull('0b10')).toBeNull(); + expect(_parseBigIntOrNull('12a')).toBeNull(); + expect(_parseBigIntOrNull('n')).toBeNull(); + expect(_parseBigIntOrNull('+')).toBeNull(); + expect(_parseBigIntOrNull('-')).toBeNull(); + expect(_parseBigIntOrNull('10nn')).toBeNull(); + }); + + it('handles bigint input', () => { + expect(_parseBigIntOrNull(999n)).toBe(999n); + }); +}); diff --git a/packages/ag-grid-community/src/agStack/utils/bigInt.ts b/packages/ag-grid-community/src/agStack/utils/bigInt.ts new file mode 100644 index 00000000000..e65d957631a --- /dev/null +++ b/packages/ag-grid-community/src/agStack/utils/bigInt.ts @@ -0,0 +1,29 @@ +export const _parseBigIntOrNull = (value: unknown): bigint | null => { + if (typeof value === 'bigint') { + return value; + } + let trimmed; + if (typeof value === 'number') { + trimmed = value; + } else if (typeof value === 'string') { + trimmed = value.trim(); + if (trimmed === '') { + return null; + } + if (trimmed.endsWith('n')) { + trimmed = trimmed.slice(0, -1); + } + // we don't support binary, octal, or hex notations for bigint for v1 + if (!/^[+-]?\d+$/.test(trimmed)) { + return null; + } + } + if (trimmed == null) { + return null; + } + try { + return BigInt(trimmed); + } catch { + return null; + } +}; diff --git a/packages/ag-grid-community/src/allCommunityModule.ts b/packages/ag-grid-community/src/allCommunityModule.ts index fdcaf01d64d..19cc2572746 100644 --- a/packages/ag-grid-community/src/allCommunityModule.ts +++ b/packages/ag-grid-community/src/allCommunityModule.ts @@ -17,6 +17,7 @@ import { UndoRedoEditModule, } from './edit/editModule'; import { + BigIntFilterModule, CustomFilterModule, DateFilterModule, ExternalFilterModule, @@ -63,6 +64,7 @@ export const AllCommunityModule: _ModuleWithoutApi = { UndoRedoEditModule, TextFilterModule, NumberFilterModule, + BigIntFilterModule, DateFilterModule, CustomFilterModule, QuickFilterModule, diff --git a/packages/ag-grid-community/src/clientSideRowModel/clientSideNodeManager.ts b/packages/ag-grid-community/src/clientSideRowModel/clientSideNodeManager.ts index e338b6a7608..06309e69991 100644 --- a/packages/ag-grid-community/src/clientSideRowModel/clientSideNodeManager.ts +++ b/packages/ag-grid-community/src/clientSideRowModel/clientSideNodeManager.ts @@ -41,8 +41,7 @@ export class ClientSideNodeManager extends BeanStub { if (!data) { continue; } - const node = this.createRowNode(data, level); - node.sourceRowIndex = writeIdx; + const node = this.createRowNode(data, level, writeIdx); allLeafs[writeIdx++] = node; if (processedNested && !processedNested.has(data)) { processedNested.add(data); @@ -302,9 +301,8 @@ export class ClientSideNodeManager extends BeanStub { const addedNodes: RowNode[] = new Array(addLength); const adds = changedRowNodes.adds; for (let i = 0; i < addLength; i++) { - const node = this.createRowNode(add[i], 0); + const node = this.createRowNode(add[i], 0, addIndex); adds.add(node); - node.sourceRowIndex = addIndex; allLeafs[addIndex] = node; addedNodes[i] = node; // Write new nodes addIndex++; @@ -335,12 +333,15 @@ export class ClientSideNodeManager extends BeanStub { } } - private createRowNode(data: TData, level: number): RowNode { + private createRowNode(data: TData, level: number, sourceRowIndex?: number): RowNode { const node = new RowNode(this.beans); node.parent = this.rootNode; node.level = level; node.group = false; node.expanded = false; + if (sourceRowIndex != null) { + node.sourceRowIndex = sourceRowIndex; + } node.setDataAndId(data, String(this.nextId++)); const id = node.id!; const allNodesMap = this.allNodesMap; diff --git a/packages/ag-grid-community/src/columns/dataTypeService.ts b/packages/ag-grid-community/src/columns/dataTypeService.ts index 79f90016b85..1713e011f8c 100644 --- a/packages/ag-grid-community/src/columns/dataTypeService.ts +++ b/packages/ag-grid-community/src/columns/dataTypeService.ts @@ -1,5 +1,6 @@ import { KeyCode } from '../agStack/constants/keyCode'; import type { IEventListener } from '../agStack/interfaces/iEventEmitter'; +import { _parseBigIntOrNull } from '../agStack/utils/bigInt'; import { _isValidDate, _isValidDateTime, _parseDateTimeFromString, _serialiseDate } from '../agStack/utils/date'; import { _toStringOrNull } from '../agStack/utils/generic'; import { _getValueUsingField } from '../agStack/utils/value'; @@ -50,6 +51,7 @@ const SORTED_CELL_DATA_TYPES_FOR_MATCHING: readonly Exclude Partial > = { number() { return { cellEditor: 'agNumberCellEditor' }; }, + bigint({ filterModuleBean }) { + if (filterModuleBean) { + return { + cellEditor: 'agTextCellEditor', + }; + } + return { + cellEditor: 'agTextCellEditor', + comparator: { + default: bigintComparator, + absolute: bigintAbsoluteComparator, + }, + }; + }, boolean() { return { cellEditor: 'agCheckboxCellEditor', @@ -598,6 +615,7 @@ export class DataTypeService extends BeanStub implements NamedBean { dataTypeDefinition, colId, formatValue, + filterModuleBean: this.beans.filterManager, }); // if the user enabled formula and did not manually provide an editor @@ -663,6 +681,29 @@ export class DataTypeService extends BeanStub implements NamedBean { }, dataTypeMatcher: (value: any) => typeof value === 'number', }, + bigint: { + baseDataType: 'bigint', + valueParser: (params: ValueParserLiteParams) => { + const { newValue } = params; + if (newValue == null) { + return null; + } + if (typeof newValue === 'string' && newValue.trim() === '') { + return null; + } + return _parseBigIntOrNull(newValue); + }, + valueFormatter: (params: ValueFormatterLiteParams) => { + if (params.value == null) { + return ''; + } + if (typeof params.value !== 'bigint') { + return translate('invalidBigInt', 'Invalid BigInt'); + } + return String(params.value); + }, + dataTypeMatcher: (value: any) => typeof value === 'bigint', + }, text: { baseDataType: 'text', valueParser: (params: ValueParserLiteParams) => @@ -751,6 +792,8 @@ function validateDataTypeDefinition( return true; } +const numberOrBigint = (v: unknown) => typeof v === 'bigint' || typeof v === 'number'; + function createGroupSafeValueFormatter( dataTypeDefinition: DataTypeDefinition | CoreDataTypeDefinition, gos: GridOptionsService @@ -758,38 +801,37 @@ function createGroupSafeValueFormatter( if (!dataTypeDefinition.valueFormatter) { return undefined; } + return (params: ValueFormatterParams) => { - if (params.node?.group) { - const aggFunc = (params.colDef.pivotValueColumn ?? params.column).getAggFunc(); + const { node, colDef, column, value } = params; + + if (node?.group) { + const aggFunc = (colDef.pivotValueColumn ?? column).getAggFunc(); if (aggFunc) { // the resulting type of these will be the same, so we call valueFormatter anyway if (aggFunc === 'first' || aggFunc === 'last') { return dataTypeDefinition.valueFormatter!(params); } - if (dataTypeDefinition.baseDataType === 'number' && aggFunc !== 'count') { - if (typeof params.value === 'number') { + const { baseDataType } = dataTypeDefinition; + if (numberOrBigint(baseDataType) && aggFunc !== 'count') { + if (numberOrBigint(value)) { return dataTypeDefinition.valueFormatter!(params); } - if (typeof params.value === 'object') { - if (!params.value) { - return undefined; - } - - if ('toNumber' in params.value) { - return dataTypeDefinition.valueFormatter!({ - ...params, - value: params.value.toNumber(), - }); - } - - if ('value' in params.value) { - return dataTypeDefinition.valueFormatter!({ - ...params, - value: params.value.value, - }); - } + if (typeof value !== 'object' || !value) { + return undefined; + } + let val = value.value; + if (typeof value.toNumber === 'function') { + val = value.toNumber(); + } else if ('value' in value && (numberOrBigint(val) || val == null)) { + val = value.value; + } else { + val = null; + } + if (val) { + return dataTypeDefinition.valueFormatter!({ ...params, value: val }); } } @@ -825,6 +867,50 @@ function doesColDefPropPreventInference( } } +function bigintComparator(valueA: any, valueB: any): number { + if (valueA == null) { + return valueB == null ? 0 : -1; + } + if (valueB == null) { + return 1; + } + const bigA = _parseBigIntOrNull(valueA); + const bigB = _parseBigIntOrNull(valueB); + if (bigA != null && bigB != null) { + if (bigA === bigB) { + return 0; + } + return bigA > bigB ? 1 : -1; + } + return 0; +} + +function bigintAbsoluteComparator(valueA: any, valueB: any): number { + if (valueA == null) { + return valueB == null ? 0 : -1; + } + if (valueB == null) { + return 1; + } + const bigA = toAbsoluteBigInt(valueA); + const bigB = toAbsoluteBigInt(valueB); + if (bigA != null && bigB != null) { + if (bigA === bigB) { + return 0; + } + return bigA > bigB ? 1 : -1; + } + return 0; +} + +function toAbsoluteBigInt(value: any): bigint | null { + const bigIntValue = _parseBigIntOrNull(value); + if (bigIntValue == null) { + return null; + } + return bigIntValue < 0n ? -bigIntValue : bigIntValue; +} + function doColDefPropsPreventInference( colDef: ColDef, propsToCheckForInference: { [key in keyof ColDef]: boolean } diff --git a/packages/ag-grid-community/src/context/context.ts b/packages/ag-grid-community/src/context/context.ts index 9baee1870fd..8b44ce50558 100644 --- a/packages/ag-grid-community/src/context/context.ts +++ b/packages/ag-grid-community/src/context/context.ts @@ -152,6 +152,7 @@ export type DynamicBeanName = | 'agMultiColumnFilterHandler' | 'agGroupColumnFilterHandler' | 'agNumberColumnFilterHandler' + | 'agBigIntColumnFilterHandler' | 'agDateColumnFilterHandler' | 'agTextColumnFilterHandler'; @@ -180,10 +181,12 @@ export type UserComponentName = | 'agReadOnlyFloatingFilter' | 'agTextColumnFilter' | 'agNumberColumnFilter' + | 'agBigIntColumnFilter' | 'agDateColumnFilter' | 'agDateInput' | 'agTextColumnFloatingFilter' | 'agNumberColumnFloatingFilter' + | 'agBigIntColumnFloatingFilter' | 'agDateColumnFloatingFilter' | 'agMultiColumnFilter' | 'agMultiColumnFloatingFilter' diff --git a/packages/ag-grid-community/src/entities/dataType.ts b/packages/ag-grid-community/src/entities/dataType.ts index c9689b24632..9f6b86c9ea2 100644 --- a/packages/ag-grid-community/src/entities/dataType.ts +++ b/packages/ag-grid-community/src/entities/dataType.ts @@ -27,6 +27,8 @@ export type ValueFormatterLiteFunc = ( * * `'number'` is type `number`. * + * `'bigint'` is type `bigint`. + * * `'boolean'` is type `boolean`. * * `'date'` is type `Date`. @@ -42,6 +44,7 @@ export type ValueFormatterLiteFunc = ( export type BaseCellDataType = | 'text' | 'number' + | 'bigint' | 'boolean' | 'date' | 'dateString' @@ -54,7 +57,8 @@ interface BaseDataTypeDefinition export interface NumberDataTypeDefinition extends BaseDataTypeDefinition<'number', TData, number, TContext> {} +/** Represents a `'bigint'` data type (type `bigint`). */ +interface BigIntDataTypeDefinition + extends BaseDataTypeDefinition<'bigint', TData, bigint, TContext> {} + /** Represents a `'boolean'` data type (type `boolean`). */ export interface BooleanDataTypeDefinition extends BaseDataTypeDefinition<'boolean', TData, boolean, TContext> {} @@ -153,6 +161,7 @@ export type CheckDataTypes, K extends keyof any = Bas export type DataTypeDefinition = | TextDataTypeDefinition | NumberDataTypeDefinition + | BigIntDataTypeDefinition | BooleanDataTypeDefinition | DateDataTypeDefinition | DateStringDataTypeDefinition diff --git a/packages/ag-grid-community/src/filter/columnFilterUtils.ts b/packages/ag-grid-community/src/filter/columnFilterUtils.ts index fcfd826be5b..e3c3f499cfe 100644 --- a/packages/ag-grid-community/src/filter/columnFilterUtils.ts +++ b/packages/ag-grid-community/src/filter/columnFilterUtils.ts @@ -22,6 +22,7 @@ export const FILTER_HANDLER_MAP = { agMultiColumnFilter: 'agMultiColumnFilterHandler', agGroupColumnFilter: 'agGroupColumnFilterHandler', agNumberColumnFilter: 'agNumberColumnFilterHandler', + agBigIntColumnFilter: 'agBigIntColumnFilterHandler', agDateColumnFilter: 'agDateColumnFilterHandler', agTextColumnFilter: 'agTextColumnFilterHandler', } as const; diff --git a/packages/ag-grid-community/src/filter/filterDataTypeUtils.ts b/packages/ag-grid-community/src/filter/filterDataTypeUtils.ts index e1151f90bf7..db3715d9e1b 100644 --- a/packages/ag-grid-community/src/filter/filterDataTypeUtils.ts +++ b/packages/ag-grid-community/src/filter/filterDataTypeUtils.ts @@ -1,4 +1,5 @@ import type { LocaleTextFunc } from '../agStack/interfaces/iLocaleService'; +import { _parseBigIntOrNull } from '../agStack/utils/bigInt'; import { _getDateParts } from '../agStack/utils/date'; import { _exists } from '../agStack/utils/generic'; import type { BeanCollection, UserComponentName } from '../context/context'; @@ -12,6 +13,7 @@ import type { DateStringDataTypeDefinition, } from '../entities/dataType'; import type { ISetFilterParams } from '../interfaces/iSetFilter'; +import type { IBigIntFilterParams } from './provided/bigInt/iBigIntFilter'; import type { IDateFilterParams } from './provided/date/iDateFilter'; import type { ISimpleFilterParams } from './provided/iSimpleFilter'; import type { INumberFilterParams } from './provided/number/iNumberFilter'; @@ -56,6 +58,24 @@ function setFilterNumberComparator(a: TValue | null, b: TValue | n return Number.parseFloat(a as string) - Number.parseFloat(b as string); } +function setFilterBigIntComparator(a: TValue | null, b: TValue | null): number { + if (a == null) { + return -1; + } + if (b == null) { + return 1; + } + const valueA = _parseBigIntOrNull(a); + const valueB = _parseBigIntOrNull(b); + if (valueA != null && valueB != null) { + if (valueA === valueB) { + return 0; + } + return valueA > valueB ? 1 : -1; + } + return String(a).localeCompare(String(b)); +} + function isValidDate(value: any): boolean { return value instanceof Date && !isNaN(value.getTime()); } @@ -72,6 +92,7 @@ type FilterParamCallback

= ( type FilterParamsDefMap = CheckDataTypes<{ number: FilterParamCallback; + bigint: FilterParamCallback; boolean: FilterParamCallback; date: FilterParamCallback; dateString: FilterParamCallback; @@ -84,6 +105,7 @@ type FilterParamsDefMap = CheckDataTypes<{ // using an object here to enforce dev to not forget to implement new types as they are added const filterParamsForEachDataType: FilterParamsDefMap = { number: () => undefined, + bigint: () => undefined, boolean: () => ({ maxNumConditions: 1, debounceMs: 0, @@ -128,6 +150,7 @@ const filterParamsForEachDataType: FilterParamsDefMap = { // using an object here to enforce dev to not forget to implement new types as they are added const setFilterParamsForEachDataType: FilterParamsDefMap = { number: () => ({ comparator: setFilterNumberComparator }), + bigint: () => ({ comparator: setFilterBigIntComparator }), boolean: ({ t }) => ({ valueFormatter: (params: ValueFormatterParams) => _exists(params.value) ? t(String(params.value), params.value ? 'True' : 'False') : t('blanks', '(Blanks)'), @@ -221,6 +244,7 @@ const defaultFilters: Record = { dateString: 'agDateColumnFilter', dateTime: 'agDateColumnFilter', dateTimeString: 'agDateColumnFilter', + bigint: 'agBigIntColumnFilter', number: 'agNumberColumnFilter', object: 'agTextColumnFilter', text: 'agTextColumnFilter', @@ -232,6 +256,7 @@ const defaultFloatingFilters: Record = { dateString: 'agDateColumnFloatingFilter', dateTime: 'agDateColumnFloatingFilter', dateTimeString: 'agDateColumnFloatingFilter', + bigint: 'agBigIntColumnFloatingFilter', number: 'agNumberColumnFloatingFilter', object: 'agTextColumnFloatingFilter', text: 'agTextColumnFloatingFilter', diff --git a/packages/ag-grid-community/src/filter/filterLocaleText.ts b/packages/ag-grid-community/src/filter/filterLocaleText.ts index 4007a4abe29..07e9eb0c66f 100644 --- a/packages/ag-grid-community/src/filter/filterLocaleText.ts +++ b/packages/ag-grid-community/src/filter/filterLocaleText.ts @@ -8,6 +8,7 @@ const FILTER_LOCALE_TEXT = { cancelFilter: 'Cancel', textFilter: 'Text Filter', numberFilter: 'Number Filter', + bigintFilter: 'BigInt Filter', dateFilter: 'Date Filter', setFilter: 'Set Filter', filterOoo: 'Filter...', diff --git a/packages/ag-grid-community/src/filter/filterModule.ts b/packages/ag-grid-community/src/filter/filterModule.ts index 6f70206282b..2a6aba24ad3 100644 --- a/packages/ag-grid-community/src/filter/filterModule.ts +++ b/packages/ag-grid-community/src/filter/filterModule.ts @@ -26,6 +26,9 @@ import { FilterManager } from './filterManager'; import { FilterMenuFactory } from './filterMenuFactory'; import { FilterValueService } from './filterValueService'; import { ReadOnlyFloatingFilter } from './floating/provided/readOnlyFloatingFilter'; +import { BigIntFilter } from './provided/bigInt/bigIntFilter'; +import { BigIntFilterHandler } from './provided/bigInt/bigIntFilterHandler'; +import { BigIntFloatingFilter } from './provided/bigInt/bigIntFloatingFilter'; import { DateFilter } from './provided/date/dateFilter'; import { DateFilterHandler } from './provided/date/dateFilterHandler'; import { DateFloatingFilter } from './provided/date/dateFloatingFilter'; @@ -155,6 +158,27 @@ export const NumberFilterModule: _ModuleWithoutApi = { }, }; +/** + * @feature Filtering -> BigInt Filter + */ +export const BigIntFilterModule: _ModuleWithoutApi = { + moduleName: 'BigIntFilter', + version: VERSION, + dependsOn: [ColumnFilterModule], + userComponents: { + agBigIntColumnFilter: { + classImp: BigIntFilter, + params: { + useForm: true, + } as FilterWrapperParams, + }, + agBigIntColumnFloatingFilter: BigIntFloatingFilter, + }, + dynamicBeans: { + agBigIntColumnFilterHandler: BigIntFilterHandler, + }, +}; + /** * @feature Filtering -> Date Filter */ diff --git a/packages/ag-grid-community/src/filter/floating/floatingFilterMapper.ts b/packages/ag-grid-community/src/filter/floating/floatingFilterMapper.ts index 9bf24c4c0ca..02a96a24e8d 100644 --- a/packages/ag-grid-community/src/filter/floating/floatingFilterMapper.ts +++ b/packages/ag-grid-community/src/filter/floating/floatingFilterMapper.ts @@ -21,6 +21,7 @@ export function _getDefaultFloatingFilterType( agMultiColumnFilter: 'agMultiColumnFloatingFilter', agGroupColumnFilter: 'agGroupColumnFloatingFilter', agNumberColumnFilter: 'agNumberColumnFloatingFilter', + agBigIntColumnFilter: 'agBigIntColumnFloatingFilter', agDateColumnFilter: 'agDateColumnFloatingFilter', agTextColumnFilter: 'agTextColumnFloatingFilter', }; diff --git a/packages/ag-grid-community/src/filter/floating/provided/simpleFloatingFilter.ts b/packages/ag-grid-community/src/filter/floating/provided/simpleFloatingFilter.ts index b3a20b77824..f7ec357cd10 100644 --- a/packages/ag-grid-community/src/filter/floating/provided/simpleFloatingFilter.ts +++ b/packages/ag-grid-community/src/filter/floating/provided/simpleFloatingFilter.ts @@ -33,7 +33,7 @@ export abstract class SimpleFloatingFilter>; + +export class BigIntFilter extends SimpleFilter< + BigIntFilterModel, + bigint, + GridInputTextField, + BigIntFilterDisplayParams +> { + private readonly eValuesFrom: GridInputTextField[] = []; + private readonly eValuesTo: GridInputTextField[] = []; + + public readonly filterType = 'bigint' as const; + + constructor() { + super('bigintFilter', mapValuesFromBigIntFilterModel, DEFAULT_BIGINT_FILTER_OPTIONS); + } + + protected override defaultDebounceMs = 500; + + public override afterGuiAttached(params?: IAfterGuiAttachedParams | undefined): void { + super.afterGuiAttached(params); + + this.refreshInputValidation(); + } + + protected override shouldKeepInvalidInputState(): boolean { + return !_isBrowserFirefox() && this.hasInvalidInputs() && this.getConditionTypes().includes('inRange'); + } + + private refreshInputValidation(): void { + for (let i = 0; i < this.eValuesFrom.length; i++) { + const from = this.eValuesFrom[i]; + const to = this.eValuesTo[i]; + this.refreshInputPairValidation(from, to); + } + } + + private refreshInputPairValidation(from: GridInputTextField, to: GridInputTextField, isFrom = false): void { + const localeKey = getValidityMessageKey(_parseBigIntOrNull(from), _parseBigIntOrNull(to), isFrom); + const validityMessage = localeKey + ? this.translate(localeKey, [String(isFrom ? to.getValue() : from.getValue())]) + : ''; + (isFrom ? from : to).setCustomValidity(validityMessage); + (isFrom ? to : from).setCustomValidity(''); + if (validityMessage.length > 0) { + this.beans.ariaAnnounce.announceValue(validityMessage, 'dateFilter'); + } + } + + protected override getState(): { isInvalid: boolean } { + return { isInvalid: this.hasInvalidInputs() }; + } + + protected override areStatesEqual(stateA?: { isInvalid: boolean }, stateB?: { isInvalid: boolean }): boolean { + return (stateA?.isInvalid ?? false) === (stateB?.isInvalid ?? false); + } + + public override refresh(legacyNewParams: ProvidedFilterParams): boolean { + const result = super.refresh(legacyNewParams); + + const { state: newState, additionalEventAttributes } = legacyNewParams as unknown as BigIntFilterDisplayParams; + const oldState = this.state; + + const fromAction = additionalEventAttributes?.fromAction; + const forceRefreshValidation = fromAction && fromAction != 'apply'; + + if ( + forceRefreshValidation || + newState.model !== oldState.model || + !this.areStatesEqual(newState.state, oldState.state) + ) { + this.refreshInputValidation(); + } + + return result; + } + + protected override setElementValue( + element: GridInputTextField, + value: bigint | null, + fromFloatingFilter?: boolean + ): void { + super.setElementValue(element, value as any, fromFloatingFilter); + if (value === null) { + element.setCustomValidity(''); + } + } + + protected createEValue(): HTMLElement { + const { params, eValuesFrom, eValuesTo } = this; + const allowedCharPattern = getAllowedCharPattern(params); + + const eCondition = _createElement({ tag: 'div', cls: 'ag-filter-body', role: 'presentation' }); + + const from = this.createFromToElement(eCondition, eValuesFrom, 'from', allowedCharPattern); + const to = this.createFromToElement(eCondition, eValuesTo, 'to', allowedCharPattern); + + const getFieldChangedListener = (fromEl: GridInputTextField, toEl: GridInputTextField, isFrom: boolean) => () => + this.refreshInputPairValidation(fromEl, toEl, isFrom); + + const fromListener = getFieldChangedListener(from, to, true); + from.onValueChange(fromListener); + from.addGuiEventListener('focusin', fromListener); + + const toListener = getFieldChangedListener(from, to, false); + to.onValueChange(toListener); + to.addGuiEventListener('focusin', toListener); + + return eCondition; + } + + private createFromToElement( + eCondition: HTMLElement, + eValues: GridInputTextField[], + fromTo: string, + allowedCharPattern: string | null + ): GridInputTextField { + const eValue = this.createManagedBean( + allowedCharPattern ? new AgInputTextField({ allowedCharPattern }) : new AgInputTextField() + ); + eValue.addCss(`ag-filter-${fromTo}`); + eValue.addCss('ag-filter-filter'); + eValues.push(eValue); + eCondition.appendChild(eValue.getGui()); + return eValue; + } + + protected removeEValues(startPosition: number, deleteCount?: number): void { + const removeComps = (eGui: GridInputTextField[]) => this.removeComponents(eGui, startPosition, deleteCount); + + removeComps(this.eValuesFrom); + removeComps(this.eValuesTo); + } + + protected getValues(position: number): Tuple { + const result: Tuple = []; + this.forEachPositionInput(position, (element, index, _elPosition, numberOfInputs) => { + if (index < numberOfInputs) { + result.push(_parseBigIntOrNull(element.getValue() ?? null)); + } + }); + + return result; + } + + protected areSimpleModelsEqual(aSimple: BigIntFilterModel, bSimple: BigIntFilterModel): boolean { + return ( + aSimple.filter === bSimple.filter && aSimple.filterTo === bSimple.filterTo && aSimple.type === bSimple.type + ); + } + + protected createCondition(position: number): BigIntFilterModel { + const type = this.getConditionType(position); + const model: BigIntFilterModel = { + filterType: this.filterType, + type, + }; + + const values = this.getValues(position); + if (values.length > 0) { + model.filter = String(values[0]); + } + if (values.length > 1) { + model.filterTo = String(values[1]); + } + + return model; + } + + protected override removeConditionsAndOperators(startPosition: number, deleteCount?: number | undefined): void { + if (this.hasInvalidInputs()) { + return; + } + + return super.removeConditionsAndOperators(startPosition, deleteCount); + } + + protected override getInputs(position: number): Tuple { + const { eValuesFrom, eValuesTo } = this; + if (position >= eValuesFrom.length) { + return [null, null]; + } + return [eValuesFrom[position], eValuesTo[position]]; + } + + protected override hasInvalidInputs(): boolean { + let invalidInputs = false; + this.forEachInput((element) => (invalidInputs ||= !element.getInputElement().validity.valid)); + return invalidInputs; + } + + protected override positionHasInvalidInputs(position: number): boolean { + let invalidInputs = false; + this.forEachPositionInput(position, (element) => (invalidInputs ||= !element.getInputElement().validity.valid)); + return invalidInputs; + } + + protected override canApply(_model: BigIntFilterModel | ICombinedSimpleModel | null): boolean { + return !this.hasInvalidInputs(); + } +} + +function getValidityMessageKey( + fromValue: bigint | null, + toValue: bigint | null, + isFrom: boolean +): FilterLocaleTextKey | null { + const isInvalid = fromValue != null && toValue != null && fromValue >= toValue; + if (!isInvalid) { + return null; + } + return `strict${isFrom ? 'Max' : 'Min'}ValueValidation`; +} diff --git a/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterConstants.ts b/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterConstants.ts new file mode 100644 index 00000000000..12e7009501e --- /dev/null +++ b/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterConstants.ts @@ -0,0 +1,13 @@ +import type { ISimpleFilterModelType } from '../iSimpleFilter'; + +export const DEFAULT_BIGINT_FILTER_OPTIONS: ISimpleFilterModelType[] = [ + 'equals', + 'notEqual', + 'greaterThan', + 'greaterThanOrEqual', + 'lessThan', + 'lessThanOrEqual', + 'inRange', + 'blank', + 'notBlank', +]; diff --git a/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterHandler.test.ts b/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterHandler.test.ts new file mode 100644 index 00000000000..cd81004b80d --- /dev/null +++ b/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterHandler.test.ts @@ -0,0 +1,104 @@ +import { BigIntFilterHandler } from './bigIntFilterHandler'; + +describe('BigIntFilterHandler', () => { + let handler: BigIntFilterHandler; + + beforeEach(() => { + handler = new BigIntFilterHandler(); + (handler as any).createManagedBean = (bean: any) => bean; + (handler as any).addDestroyFunc = () => {}; + }); + + const createParams = (filterParams: any = {}, model: any = null): any => ({ + filterParams: { + filterOptions: [ + 'equals', + 'notEqual', + 'lessThan', + 'lessThanOrEqual', + 'greaterThan', + 'greaterThanOrEqual', + 'inRange', + 'blank', + 'notBlank', + ], + ...filterParams, + }, + model, + getValue: (node: any) => node.value, + onModelChange: () => {}, + }); + + it('should pass for equal bigint values', () => { + const params = createParams({}, { filterType: 'bigint', type: 'equals', filter: '10' }); + handler.init(params); + + expect(handler.doesFilterPass({ node: { value: 10n }, model: params.model } as any)).toBe(true); + expect(handler.doesFilterPass({ node: { value: 11n }, model: params.model } as any)).toBe(false); + }); + + it('should handle null cell values with default settings', () => { + const params = createParams({}, { filterType: 'bigint', type: 'equals', filter: '10' }); + handler.init(params); + + expect(handler.doesFilterPass({ node: { value: null }, model: params.model } as any)).toBe(false); + }); + + it('should handle null cell values with includeBlanksInEquals', () => { + const params = createParams( + { includeBlanksInEquals: true }, + { filterType: 'bigint', type: 'equals', filter: '10' } + ); + handler.init(params); + + expect(handler.doesFilterPass({ node: { value: null }, model: params.model } as any)).toBe(true); + }); + + it('should handle invalid cell values (mixed data)', () => { + const params = createParams({}, { filterType: 'bigint', type: 'equals', filter: '10' }); + handler.init(params); + + // Equals should still return false for mixed types because of === in comparator + expect(handler.doesFilterPass({ node: { value: 10 }, model: params.model } as any)).toBe(false); + expect(handler.doesFilterPass({ node: { value: '10' }, model: params.model } as any)).toBe(false); + + // But lessThan should work if we updated isValid to be more robust + const lessThanParams = createParams({}, { filterType: 'bigint', type: 'lessThan', filter: '20' }); + handler.init(lessThanParams); + expect(handler.doesFilterPass({ node: { value: 10 }, model: lessThanParams.model } as any)).toBe(true); + expect(handler.doesFilterPass({ node: { value: '10' }, model: lessThanParams.model } as any)).toBe(true); + }); + + it('should handle null and undefined values in model', () => { + // null filter value should pass everything for equals (standard SimpleFilter behavior) + const params = createParams({}, { filterType: 'bigint', type: 'equals', filter: null }); + handler.init(params); + expect(handler.doesFilterPass({ node: { value: 10n }, model: params.model } as any)).toBe(true); + + const undefinedParams = createParams({}, { filterType: 'bigint', type: 'equals', filter: undefined }); + handler.init(undefinedParams); + expect(handler.doesFilterPass({ node: { value: 10n }, model: undefinedParams.model } as any)).toBe(true); + }); + + it('should handle inRange with null filterTo', () => { + const params = createParams({}, { filterType: 'bigint', type: 'inRange', filter: '10', filterTo: null }); + handler.init(params); + + // 15n should not pass inRange(10n, null) because null < 15n returns 1 (compareToResult > 0) + expect(handler.doesFilterPass({ node: { value: 15n }, model: params.model } as any)).toBe(false); + }); + + it('should handle blank and notBlank types correctly', () => { + const params = createParams({}, { filterType: 'bigint', type: 'blank' }); + handler.init(params); + + expect(handler.doesFilterPass({ node: { value: null }, model: params.model } as any)).toBe(true); + expect(handler.doesFilterPass({ node: { value: undefined }, model: params.model } as any)).toBe(true); + expect(handler.doesFilterPass({ node: { value: 10n }, model: params.model } as any)).toBe(false); + + const notBlankParams = createParams({}, { filterType: 'bigint', type: 'notBlank' }); + handler.init(notBlankParams); + expect(handler.doesFilterPass({ node: { value: null }, model: notBlankParams.model } as any)).toBe(false); + expect(handler.doesFilterPass({ node: { value: 10n }, model: notBlankParams.model } as any)).toBe(true); + }); +}); diff --git a/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterHandler.ts b/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterHandler.ts new file mode 100644 index 00000000000..e6ebd446137 --- /dev/null +++ b/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterHandler.ts @@ -0,0 +1,30 @@ +import { _parseBigIntOrNull } from '../../../agStack/utils/bigInt'; +import type { Comparator } from '../iScalarFilter'; +import { ScalarFilterHandler } from '../scalarFilterHandler'; +import { DEFAULT_BIGINT_FILTER_OPTIONS } from './bigIntFilterConstants'; +import { BigIntFilterModelFormatter } from './bigIntFilterModelFormatter'; +import { mapValuesFromBigIntFilterModel } from './bigIntFilterUtils'; +import type { BigIntFilterModel, IBigIntFilterParams } from './iBigIntFilter'; + +export class BigIntFilterHandler extends ScalarFilterHandler { + public readonly filterType = 'bigint' as const; + protected readonly FilterModelFormatterClass = BigIntFilterModelFormatter; + + constructor() { + super(mapValuesFromBigIntFilterModel, DEFAULT_BIGINT_FILTER_OPTIONS); + } + + protected override comparator(): Comparator { + return (left: bigint, right: bigint): number => { + if (left === right) { + return 0; + } + + return left < right ? 1 : -1; + }; + } + + protected override isValid(value: bigint): boolean { + return _parseBigIntOrNull(value) !== null; + } +} diff --git a/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterModelFormatter.ts b/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterModelFormatter.ts new file mode 100644 index 00000000000..1abf97972cd --- /dev/null +++ b/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterModelFormatter.ts @@ -0,0 +1,53 @@ +import { _parseBigIntOrNull } from '../../../agStack/utils/bigInt'; +import type { OptionsFactory } from '../optionsFactory'; +import { SCALAR_FILTER_TYPE_KEYS, SimpleFilterModelFormatter } from '../simpleFilterModelFormatter'; +import type { BigIntFilterModel, IBigIntFilterParams } from './iBigIntFilter'; + +export class BigIntFilterModelFormatter extends SimpleFilterModelFormatter< + IBigIntFilterParams, + typeof SCALAR_FILTER_TYPE_KEYS, + bigint +> { + protected readonly filterTypeKeys = SCALAR_FILTER_TYPE_KEYS; + + constructor(optionsFactory: OptionsFactory, filterParams: IBigIntFilterParams) { + super(optionsFactory, filterParams, filterParams.bigintFormatter); + } + + protected conditionToString( + condition: BigIntFilterModel, + forToolPanel: boolean, + isRange: boolean, + customDisplayKey: string | undefined, + customDisplayName: string | undefined + ): string { + const { filter, filterTo, type } = condition; + const format = this.formatValue.bind(this); + + const parsedFrom = _parseBigIntOrNull(filter); + const parsedTo = _parseBigIntOrNull(filterTo); + if (forToolPanel) { + const valueForToolPanel = this.conditionForToolPanel( + type, + isRange, + () => format(parsedFrom), + () => format(parsedTo), + customDisplayKey, + customDisplayName + ); + if (valueForToolPanel != null) { + return valueForToolPanel; + } + } + + if (isRange) { + return `${format(parsedFrom)}-${format(parsedTo)}`; + } + + if (filter != null) { + return format(parsedFrom); + } + + return `${type}`; + } +} diff --git a/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterUtils.ts b/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterUtils.ts new file mode 100644 index 00000000000..68ddbeacfe6 --- /dev/null +++ b/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFilterUtils.ts @@ -0,0 +1,17 @@ +import { _parseBigIntOrNull } from '../../../agStack/utils/bigInt'; +import type { Tuple } from '../iSimpleFilter'; +import type { OptionsFactory } from '../optionsFactory'; +import { getNumberOfInputs } from '../simpleFilterUtils'; +import type { BigIntFilterModel, IBigIntFilterParams } from './iBigIntFilter'; + +export function getAllowedCharPattern(filterParams?: IBigIntFilterParams): string | null { + return filterParams?.allowedCharPattern ?? null; +} + +export function mapValuesFromBigIntFilterModel( + filterModel: BigIntFilterModel | null, + optionsFactory: OptionsFactory +): Tuple { + const { filter, filterTo, type } = filterModel || {}; + return [_parseBigIntOrNull(filter), _parseBigIntOrNull(filterTo)].slice(0, getNumberOfInputs(type, optionsFactory)); +} diff --git a/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFloatingFilter.ts b/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFloatingFilter.ts new file mode 100644 index 00000000000..18069f9c442 --- /dev/null +++ b/packages/ag-grid-community/src/filter/provided/bigInt/bigIntFloatingFilter.ts @@ -0,0 +1,46 @@ +import { _parseBigIntOrNull } from '../../../agStack/utils/bigInt'; +import { FloatingFilterTextInputService } from '../../floating/provided/floatingFilterTextInputService'; +import type { FloatingFilterInputService } from '../../floating/provided/iFloatingFilterInputService'; +import { TextInputFloatingFilter } from '../../floating/provided/textInputFloatingFilter'; +import { DEFAULT_BIGINT_FILTER_OPTIONS } from './bigIntFilterConstants'; +import { BigIntFilterModelFormatter } from './bigIntFilterModelFormatter'; +import { getAllowedCharPattern } from './bigIntFilterUtils'; +import type { BigIntFilterModel, BigIntFilterParams, IBigIntFloatingFilterParams } from './iBigIntFilter'; + +export class BigIntFloatingFilter extends TextInputFloatingFilter { + protected readonly FilterModelFormatterClass = BigIntFilterModelFormatter; + private allowedCharPattern: string | null; + private bigintParser: BigIntFilterParams['bigintParser'] | undefined; + protected readonly filterType = 'bigint'; + protected readonly defaultOptions = DEFAULT_BIGINT_FILTER_OPTIONS; + + protected override updateParams(params: IBigIntFloatingFilterParams): void { + const filterParams = params.filterParams as BigIntFilterParams; + const allowedCharPattern = getAllowedCharPattern(filterParams); + if (allowedCharPattern !== this.allowedCharPattern) { + this.recreateFloatingFilterInputService(params); + } + this.bigintParser = filterParams?.bigintParser; + super.updateParams(params); + } + + protected createFloatingFilterInputService(params: IBigIntFloatingFilterParams): FloatingFilterInputService { + const filterParams = params.filterParams as BigIntFilterParams; + this.allowedCharPattern = getAllowedCharPattern(filterParams); + this.bigintParser = filterParams?.bigintParser; + + const config = this.allowedCharPattern ? { allowedCharPattern: this.allowedCharPattern } : undefined; + return this.createManagedBean(new FloatingFilterTextInputService({ config })); + } + + protected override convertValue(value: string | null | undefined): TValue | null { + if (value == null || value === '') { + return null; + } + + if (this.bigintParser) { + return this.bigintParser(value) as TValue | null; + } + return _parseBigIntOrNull(value) as TValue | null; + } +} diff --git a/packages/ag-grid-community/src/filter/provided/bigInt/iBigIntFilter.ts b/packages/ag-grid-community/src/filter/provided/bigInt/iBigIntFilter.ts new file mode 100644 index 00000000000..95fd6892d2f --- /dev/null +++ b/packages/ag-grid-community/src/filter/provided/bigInt/iBigIntFilter.ts @@ -0,0 +1,47 @@ +import type { IFilterParams } from '../../../interfaces/iFilter'; +import type { IScalarFilterParams } from '../iScalarFilter'; +import type { ISimpleFilterModel } from '../iSimpleFilter'; +import type { ITextInputFloatingFilterParams } from '../text/iTextFilter'; + +export interface BigIntFilterModel extends ISimpleFilterModel { + /** Filter type is always `'bigint'` */ + filterType?: 'bigint'; + /** + * The bigint value(s) associated with the filter. + * Custom filters can have no values (hence both are optional). + * Range filter has two values (from and to), where `filter` acts as a `from` value. + */ + filter?: string | null; + /** + * Range filter `to` value. + */ + filterTo?: string | null; +} + +/** + * Parameters provided by the grid to the `init` method of a `BigIntFilter`. + * Do not use in `colDef.filterParams` - see `IBigIntFilterParams` instead. + */ +export type BigIntFilterParams = IBigIntFilterParams & IFilterParams; + +/** + * Parameters used in `colDef.filterParams` to configure a BigInt Filter (`agBigIntColumnFilter`). + */ +export interface IBigIntFilterParams extends IScalarFilterParams { + /** + * When specified, the input field will be of type `text`, and this will be used as a regex of all the characters that are allowed to be typed. + * This will be compared against any typed character and prevent the character from appearing in the input if it does not match. + */ + allowedCharPattern?: string; + /** + * Typically used alongside `allowedCharPattern`, this provides a custom parser to convert the value entered in the filter inputs into a bigint that can be used for comparisons. + */ + bigintParser?: (text: string | null) => bigint | null; + /** + * Typically used alongside `allowedCharPattern`, this provides a custom formatter to convert the bigint value in the filter model + * into a string to be used in the filter input. This is the inverse of the `bigintParser`. + */ + bigintFormatter?: (value: bigint | null) => string | null; +} + +export interface IBigIntFloatingFilterParams extends ITextInputFloatingFilterParams {} diff --git a/packages/ag-grid-community/src/filter/provided/iProvidedFilter.ts b/packages/ag-grid-community/src/filter/provided/iProvidedFilter.ts index 227ba5c3a41..806370e5dbc 100644 --- a/packages/ag-grid-community/src/filter/provided/iProvidedFilter.ts +++ b/packages/ag-grid-community/src/filter/provided/iProvidedFilter.ts @@ -45,7 +45,7 @@ export interface IProvidedFilterParams { export interface IProvidedFilter extends IFilter { /** The type of filter. Matches the `filterType` property in the filter model */ - readonly filterType: 'text' | 'number' | 'date' | 'set' | 'multi'; + readonly filterType: 'text' | 'number' | 'bigint' | 'date' | 'set' | 'multi'; /** * Applies the model shown in the UI (so that `getModel()` will now return what was in the UI * when `applyModel()` was called). diff --git a/packages/ag-grid-community/src/filter/provided/iSimpleFilter.ts b/packages/ag-grid-community/src/filter/provided/iSimpleFilter.ts index 0abdea07871..1a09dc187e5 100644 --- a/packages/ag-grid-community/src/filter/provided/iSimpleFilter.ts +++ b/packages/ag-grid-community/src/filter/provided/iSimpleFilter.ts @@ -18,7 +18,7 @@ export type JoinOperator = 'AND' | 'OR'; /** Interface contract for the public aspects of the SimpleFilter implementation(s). */ export interface ISimpleFilter extends IProvidedFilter, IFloatingFilterParent { - readonly filterType: 'text' | 'number' | 'date'; + readonly filterType: 'text' | 'number' | 'bigint' | 'date'; } export interface IFilterPlaceholderFunctionParams { diff --git a/packages/ag-grid-community/src/filter/provided/providedFilter.ts b/packages/ag-grid-community/src/filter/provided/providedFilter.ts index 03cbb95365b..25ead490cdf 100644 --- a/packages/ag-grid-community/src/filter/provided/providedFilter.ts +++ b/packages/ag-grid-community/src/filter/provided/providedFilter.ts @@ -71,7 +71,7 @@ export abstract class ProvidedFilter< protected abstract areNonNullModelsEqual(a: M, b: M): boolean; /** Used to get the filter type for filter models. */ - public abstract readonly filterType: 'text' | 'number' | 'date' | 'set' | 'multi'; + public abstract readonly filterType: 'text' | 'number' | 'bigint' | 'date' | 'set' | 'multi'; public postConstruct(): void { const element: ElementParams = { diff --git a/packages/ag-grid-community/src/filter/provided/simpleFilter.ts b/packages/ag-grid-community/src/filter/provided/simpleFilter.ts index ca1963b5150..6f1fa91c3bd 100644 --- a/packages/ag-grid-community/src/filter/provided/simpleFilter.ts +++ b/packages/ag-grid-community/src/filter/provided/simpleFilter.ts @@ -58,7 +58,7 @@ export abstract class SimpleFilter< extends ProvidedFilter, V, P> implements ISimpleFilter { - public abstract override readonly filterType: 'number' | 'text' | 'date'; + public abstract override readonly filterType: 'number' | 'bigint' | 'text' | 'date'; protected readonly eTypes: GridSelect[] = []; protected readonly eJoinPanels: HTMLElement[] = []; diff --git a/packages/ag-grid-community/src/filter/provided/simpleFilterHandler.ts b/packages/ag-grid-community/src/filter/provided/simpleFilterHandler.ts index 6120488e6c4..064f25e15c3 100644 --- a/packages/ag-grid-community/src/filter/provided/simpleFilterHandler.ts +++ b/packages/ag-grid-community/src/filter/provided/simpleFilterHandler.ts @@ -27,7 +27,7 @@ export abstract class SimpleFilterHandler< implements FilterHandler, TParams> { /** Used to get the filter type for filter models. */ - public abstract readonly filterType: 'text' | 'number' | 'date'; + public abstract readonly filterType: 'text' | 'number' | 'bigint' | 'date'; protected abstract readonly FilterModelFormatterClass: new ( optionsFactory: OptionsFactory, diff --git a/packages/ag-grid-community/src/interfaces/advancedFilterModel.ts b/packages/ag-grid-community/src/interfaces/advancedFilterModel.ts index a8c535e3f56..37cdbb6f486 100644 --- a/packages/ag-grid-community/src/interfaces/advancedFilterModel.ts +++ b/packages/ag-grid-community/src/interfaces/advancedFilterModel.ts @@ -55,6 +55,17 @@ export interface NumberAdvancedFilterModel { filter?: number; } +/** Represents a single filter condition for a bigint column */ +interface BigIntAdvancedFilterModel { + filterType: 'bigint'; + /** The ID of the column being filtered. */ + colId: string; + /** The filter option that is being applied. */ + type: ScalarAdvancedFilterModelType; + /** The value to filter on. */ + filter?: string; +} + /** Represents a single filter condition for a date column */ export interface DateAdvancedFilterModel { filterType: 'date'; @@ -125,6 +136,7 @@ export type ColumnAdvancedFilterModel = | DateStringAdvancedFilterModel | DateTimeAdvancedFilterModel | DateTimeStringAdvancedFilterModel + | BigIntAdvancedFilterModel | NumberAdvancedFilterModel | TextAdvancedFilterModel; diff --git a/packages/ag-grid-community/src/interfaces/iFilter.ts b/packages/ag-grid-community/src/interfaces/iFilter.ts index 5d626216d6d..70c77b1d31e 100644 --- a/packages/ag-grid-community/src/interfaces/iFilter.ts +++ b/packages/ag-grid-community/src/interfaces/iFilter.ts @@ -95,7 +95,7 @@ export type FilterHandlers { /** * Filter component to use for this column. - * - Set to the name of a provided filter: `agNumberColumnFilter`, `agTextColumnFilter`, `agDateColumnFilter`, `agMultiColumnFilter`, `agSetColumnFilter`. + * - Set to the name of a provided filter: `agNumberColumnFilter`, `agBigIntColumnFilter`, `agTextColumnFilter`, `agDateColumnFilter`, `agMultiColumnFilter`, `agSetColumnFilter`. * - Set to a custom filter `FilterDisplay` */ component: any; @@ -124,7 +124,7 @@ export interface IFilterDef { /** * Filter to use for this column. * - Set to `true` to use the default filter. - * - Set to the name of a provided filter: `agNumberColumnFilter`, `agTextColumnFilter`, `agDateColumnFilter`, `agMultiColumnFilter`, `agSetColumnFilter`. + * - Set to the name of a provided filter: `agNumberColumnFilter`, `agBigIntColumnFilter`, `agTextColumnFilter`, `agDateColumnFilter`, `agMultiColumnFilter`, `agSetColumnFilter`. * - Set to a custom filter `IFilterComp` when `enableFilterHandlers = false`. * - Set to a `ColumnFilter` when `enableFilterHandlers = true` */ diff --git a/packages/ag-grid-community/src/interfaces/iGroupEditService.ts b/packages/ag-grid-community/src/interfaces/iGroupEditService.ts index 134e627ccbe..21e4e277d4e 100644 --- a/packages/ag-grid-community/src/interfaces/iGroupEditService.ts +++ b/packages/ag-grid-community/src/interfaces/iGroupEditService.ts @@ -6,7 +6,6 @@ export interface IGroupEditService { isGroupingDrop(rowsDrop: RowsDrop): boolean; dropGroupEdit(rowsDrop: RowsDrop): boolean; canDropRow(row: IRowNode, rowsDrop: RowsDrop): boolean; - canDropStartGroup(target: IRowNode | null | undefined): boolean; fixRowsDrop(rowsDrop: RowsDrop, canSetParent: boolean, moving: boolean, yDelta: number): void; stopDragging(final: boolean): void; csrmFirstLeaf(parent: IRowNode | null): IRowNode | null; diff --git a/packages/ag-grid-community/src/interfaces/iModule.ts b/packages/ag-grid-community/src/interfaces/iModule.ts index 7742ce07a1d..eed2db6eaef 100644 --- a/packages/ag-grid-community/src/interfaces/iModule.ts +++ b/packages/ag-grid-community/src/interfaces/iModule.ts @@ -148,6 +148,7 @@ export type CommunityModuleName = | 'Locale' | 'NumberEditor' | 'NumberFilter' + | 'BigIntFilter' | 'Pagination' | 'PinnedRow' | 'QuickFilter' @@ -231,6 +232,7 @@ export type AgModuleName = | 'LocaleModule' | 'NumberEditorModule' | 'NumberFilterModule' + | 'BigIntFilterModule' | 'PaginationModule' | 'PinnedRowModule' | 'QuickFilterModule' diff --git a/packages/ag-grid-community/src/interfaces/iNewFiltersToolPanel.ts b/packages/ag-grid-community/src/interfaces/iNewFiltersToolPanel.ts index e0f81ab8d8d..907b6fec8b2 100644 --- a/packages/ag-grid-community/src/interfaces/iNewFiltersToolPanel.ts +++ b/packages/ag-grid-community/src/interfaces/iNewFiltersToolPanel.ts @@ -15,7 +15,7 @@ export interface SelectableFilterDef { /** * Filter to use for this column. * - Set to `true` to use the default filter. - * - Set to the name of a provided filter: `agNumberColumnFilter`, `agTextColumnFilter`, `agDateColumnFilter`, `agMultiColumnFilter`, `agSetColumnFilter`. + * - Set to the name of a provided filter: `agNumberColumnFilter`, `agBigIntColumnFilter`, `agTextColumnFilter`, `agDateColumnFilter`, `agMultiColumnFilter`, `agSetColumnFilter`. * - Set to a `ColumnFilter` */ filter: any; diff --git a/packages/ag-grid-community/src/main-internal.ts b/packages/ag-grid-community/src/main-internal.ts index 2557ea2ad17..7771fcc35b2 100644 --- a/packages/ag-grid-community/src/main-internal.ts +++ b/packages/ag-grid-community/src/main-internal.ts @@ -126,6 +126,7 @@ export { } from './agStack/utils/aria'; export { _EmptyArray, _areEqual, _flatten, _last, _removeAllFromArray, _removeFromArray } from './agStack/utils/array'; export { _isBrowserFirefox, _isBrowserSafari, _isIOSUserAgent } from './agStack/utils/browser'; +export { _parseBigIntOrNull } from './agStack/utils/bigInt'; export { MONTHS as _MONTHS, _getDateParts, _parseDateTimeFromString, _serialiseDate } from './agStack/utils/date'; export { _getActiveDomElement, diff --git a/packages/ag-grid-community/src/main.ts b/packages/ag-grid-community/src/main.ts index 021b830c7ca..20ff7587af2 100644 --- a/packages/ag-grid-community/src/main.ts +++ b/packages/ag-grid-community/src/main.ts @@ -193,6 +193,13 @@ export { NumberFilterParams, } from './filter/provided/number/iNumberFilter'; export type { NumberFilter } from './filter/provided/number/numberFilter'; +export { + IBigIntFilterParams, + IBigIntFloatingFilterParams, + BigIntFilterModel, + BigIntFilterParams, +} from './filter/provided/bigInt/iBigIntFilter'; +export type { BigIntFilter } from './filter/provided/bigInt/bigIntFilter'; export { ProvidedFilter } from './filter/provided/providedFilter'; export { ITextFilterParams, @@ -939,6 +946,7 @@ export { CustomFilterModule, DateFilterModule, ExternalFilterModule, + BigIntFilterModule, NumberFilterModule, QuickFilterModule, TextFilterModule, diff --git a/packages/ag-grid-community/src/sort/rowNodeSorter.ts b/packages/ag-grid-community/src/sort/rowNodeSorter.ts index 983b50d905c..ab16f303657 100644 --- a/packages/ag-grid-community/src/sort/rowNodeSorter.ts +++ b/packages/ag-grid-community/src/sort/rowNodeSorter.ts @@ -194,10 +194,13 @@ const defaultGetLeaf = (row: RowNode): RowNode | undefined => { } }; -const absoluteValueTransformer = (value: any): number | null => { +const absoluteValueTransformer = (value: any): number | bigint | null => { if (!value) { return value; } + if (typeof value === 'bigint') { + return value < 0n ? -value : value; + } const numberValue = Number(value); return isNaN(numberValue) ? value : Math.abs(numberValue); }; diff --git a/packages/ag-grid-community/src/validation/resolvableModuleNames.ts b/packages/ag-grid-community/src/validation/resolvableModuleNames.ts index 292013916d3..a320a9a3a31 100644 --- a/packages/ag-grid-community/src/validation/resolvableModuleNames.ts +++ b/packages/ag-grid-community/src/validation/resolvableModuleNames.ts @@ -9,6 +9,7 @@ import type { RowModelType } from '../interfaces/iRowModel'; const ALL_COLUMN_FILTERS = [ 'TextFilter', 'NumberFilter', + 'BigIntFilter', 'DateFilter', 'SetFilter', 'MultiFilter', diff --git a/packages/ag-grid-community/src/validation/rules/dynamicBeanValidations.ts b/packages/ag-grid-community/src/validation/rules/dynamicBeanValidations.ts index b6eeac075ee..62fe8521adb 100644 --- a/packages/ag-grid-community/src/validation/rules/dynamicBeanValidations.ts +++ b/packages/ag-grid-community/src/validation/rules/dynamicBeanValidations.ts @@ -21,6 +21,7 @@ export const DYNAMIC_BEAN_MODULES: Record agMultiColumnFilterHandler: 'MultiFilter', agGroupColumnFilterHandler: 'GroupFilter', agNumberColumnFilterHandler: 'NumberFilter', + agBigIntColumnFilterHandler: 'BigIntFilter', agDateColumnFilterHandler: 'DateFilter', agTextColumnFilterHandler: 'TextFilter', }; diff --git a/packages/ag-grid-community/src/validation/rules/userCompValidations.ts b/packages/ag-grid-community/src/validation/rules/userCompValidations.ts index 8649d61d3bc..eea655b9b87 100644 --- a/packages/ag-grid-community/src/validation/rules/userCompValidations.ts +++ b/packages/ag-grid-community/src/validation/rules/userCompValidations.ts @@ -31,10 +31,12 @@ export const USER_COMP_MODULES: Record agReadOnlyFloatingFilter: 'CustomFilter', agTextColumnFilter: 'TextFilter', agNumberColumnFilter: 'NumberFilter', + agBigIntColumnFilter: 'BigIntFilter', agDateColumnFilter: 'DateFilter', agDateInput: 'DateFilter', agTextColumnFloatingFilter: 'TextFilter', agNumberColumnFloatingFilter: 'NumberFilter', + agBigIntColumnFloatingFilter: 'BigIntFilter', agDateColumnFloatingFilter: 'DateFilter', agFormulaCellEditor: 'Formula', agCellEditor: 'TextEditor', diff --git a/packages/ag-grid-community/src/valueService/valueService.ts b/packages/ag-grid-community/src/valueService/valueService.ts index 07854a0f84b..b7bdeaebbc8 100644 --- a/packages/ag-grid-community/src/valueService/valueService.ts +++ b/packages/ag-grid-community/src/valueService/valueService.ts @@ -305,12 +305,17 @@ export class ValueService extends BeanStub implements NamedBean { return undefined; } - public parseValue(column: AgColumn, rowNode: IRowNode | null, newValue: any, oldValue: any): any { + public parseValue( + column: AgColumn, + rowNode: IRowNode | null, + newValue: TValueNew, + oldValue: TValueOld + ): TValue { const colDef = column.getColDef(); // we do not allow parsing of formulas if (colDef.allowFormula && this.beans.formula?.isFormula(newValue)) { - return newValue; + return newValue as TValue; } const valueParser = colDef.valueParser; @@ -320,7 +325,7 @@ export class ValueService extends BeanStub implements NamedBean { node: rowNode, data: rowNode?.data, oldValue, - newValue, + newValue: newValue as any, colDef, column, }); @@ -329,7 +334,7 @@ export class ValueService extends BeanStub implements NamedBean { } return this.expressionSvc?.evaluate(valueParser, params); } - return newValue; + return newValue as unknown as TValue; } public getDeleteValue(column: AgColumn, rowNode: IRowNode): any { diff --git a/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterExpressionService.ts b/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterExpressionService.ts index f3af8eea4b5..76f975a58fe 100644 --- a/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterExpressionService.ts +++ b/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterExpressionService.ts @@ -2,6 +2,7 @@ import type { AgColumn, BaseCellDataType, BeanCollection, + BooleanAdvancedFilterModel, ColumnAdvancedFilterModel, ColumnModel, ColumnNameService, @@ -35,14 +36,22 @@ export class AdvancedFilterExpressionService extends BeanStub implements NamedBe private colNames: ColumnNameService; private dataTypeSvc?: DataTypeService; - private readonly filterOperandGetters: Record string | null> = { + private readonly filterOperandGetters: Record< + BaseCellDataType, + (model: { filter?: string | number; colId: string }) => string | null + > = { number: (model) => _toStringOrNull(model.filter) ?? '', + bigint: (model) => _toStringOrNull(model.filter) ?? '', date: (model) => { const column = this.colModel.getColDefCol(model.colId); if (!column) { return null; } - return this.valueSvc.formatValue(column, null, _parseDateTimeFromString(model.filter)); + return this.valueSvc.formatValue( + column, + null, + _parseDateTimeFromString(_toStringOrNull(model.filter) ?? '') + ); }, dateTime: (model) => this.filterOperandGetters.date(model), dateString: (model) => { @@ -52,7 +61,8 @@ export class AdvancedFilterExpressionService extends BeanStub implements NamedBe } const { filter } = model; const dateFormatFn = this.dataTypeSvc?.getDateFormatterFunction(column); - const dateStringStringValue = dateFormatFn?.(_parseDateTimeFromString(filter) ?? undefined) ?? filter; + const dateStringStringValue = + dateFormatFn?.(_parseDateTimeFromString(_toStringOrNull(model.filter) ?? '') ?? undefined) ?? filter; return this.valueSvc.formatValue(column, null, dateStringStringValue); }, dateTimeString: (model) => this.filterOperandGetters.dateString(model), @@ -66,9 +76,10 @@ export class AdvancedFilterExpressionService extends BeanStub implements NamedBe (op: string, cln: AgColumn, dt: BaseCellDataType) => number | string | null > = { number: (operand) => (_exists(operand) ? Number(operand) : null), + bigint: (operand) => operand, date: (operand, column, baseCellDataType) => _serialiseDate( - this.valueSvc.parseValue(column, null, operand, undefined), + this.valueSvc.parseValue(column, null, operand, undefined) as Date, !!this.dataTypeSvc?.getDateIncludesTimeFlag(baseCellDataType) ), dateTime: (...args) => this.operandModelValueGetters.date(...args), @@ -138,13 +149,15 @@ export class AdvancedFilterExpressionService extends BeanStub implements NamedBe } public getOperandDisplayValue(model: ColumnAdvancedFilterModel, skipFormatting?: boolean): string { - const { filter } = model as any; + const { filter, filterType } = model as Exclude; if (filter == null) { return ''; } - let operand1 = this.filterOperandGetters[model.filterType](model); - if (model.filterType !== 'number') { + let operand1 = this.filterOperandGetters[filterType]( + model as Exclude + ); + if (filterType !== 'number' && filterType !== 'bigint') { operand1 ??= _toStringOrNull(filter) ?? ''; if (!skipFormatting) { operand1 = `"${operand1}"`; @@ -350,6 +363,7 @@ export class AdvancedFilterExpressionService extends BeanStub implements NamedBe boolean: new BooleanFilterExpressionOperators({ translate }), object: new TextFilterExpressionOperators({ translate }), number: new ScalarFilterExpressionOperators({ translate, equals: (v, o) => v === o }), + bigint: new ScalarFilterExpressionOperators({ translate, equals: (v, o) => v === o }), date: new ScalarFilterExpressionOperators(dateOperatorsParams), dateString: new ScalarFilterExpressionOperators(dateOperatorsParams), dateTime: new ScalarFilterExpressionOperators(dateOperatorsParams), diff --git a/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterLocaleText.ts b/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterLocaleText.ts index f251cfd6ae4..afb0f79da0f 100644 --- a/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterLocaleText.ts +++ b/packages/ag-grid-enterprise/src/advancedFilter/advancedFilterLocaleText.ts @@ -43,6 +43,7 @@ export const ADVANCED_FILTER_LOCALE_TEXT = { advancedFilterValidationInvalidOption: 'Option not found', advancedFilterValidationMissingQuote: 'Value is missing an end quote', advancedFilterValidationNotANumber: 'Value is not a number', + advancedFilterValidationNotABigInt: 'Value is not a big integer', advancedFilterValidationInvalidDate: 'Value is not a valid date', advancedFilterValidationMissingCondition: 'Condition is missing', advancedFilterValidationJoinOperatorMismatch: 'Join operators within a condition must be the same', diff --git a/packages/ag-grid-enterprise/src/advancedFilter/builder/conditionPillWrapperComp.ts b/packages/ag-grid-enterprise/src/advancedFilter/builder/conditionPillWrapperComp.ts index 5a0730091c4..0287c2479c9 100644 --- a/packages/ag-grid-enterprise/src/advancedFilter/builder/conditionPillWrapperComp.ts +++ b/packages/ag-grid-enterprise/src/advancedFilter/builder/conditionPillWrapperComp.ts @@ -116,7 +116,7 @@ export class ConditionPillWrapperComp extends Component; - const key = (typeof filter === 'number' ? _toStringOrNull(filter) : filter) ?? ''; + const key = (typeof filter === 'number' || typeof filter === 'bigint' ? _toStringOrNull(filter) : filter) ?? ''; this.eOperandPill = this.createPill({ key, // Convert from the input format to display format. diff --git a/packages/ag-grid-enterprise/src/advancedFilter/builder/inputPillComp.ts b/packages/ag-grid-enterprise/src/advancedFilter/builder/inputPillComp.ts index 9c9e26c138a..dfde65c5bb8 100644 --- a/packages/ag-grid-enterprise/src/advancedFilter/builder/inputPillComp.ts +++ b/packages/ag-grid-enterprise/src/advancedFilter/builder/inputPillComp.ts @@ -34,6 +34,7 @@ const inputComponentDescriptors: { [S in BaseCellDataType]: [SupportedComponent] | [SupportedComponent, (instance: SupportedInstances) => void]; } = { number: [AgInputNumberField], + bigint: [AgInputTextField], boolean: [AgInputTextField], object: [AgInputTextField], text: [AgInputTextField], @@ -180,7 +181,8 @@ export class InputPillComp extends Component { private renderValue(): void { let value: string; - const { displayValue, eLabel } = this; + const { displayValue, eLabel, params } = this; + const { type } = params; const { classList } = eLabel; classList.remove( 'ag-advanced-filter-builder-value-empty', @@ -190,7 +192,7 @@ export class InputPillComp extends Component { if (!_exists(displayValue)) { value = this.advFilterExpSvc.translate('advancedFilterBuilderEnterValue'); classList.add('ag-advanced-filter-builder-value-empty'); - } else if (this.params.type === 'number') { + } else if (type === 'number' || type === 'bigint') { value = displayValue; classList.add('ag-advanced-filter-builder-value-number'); } else { diff --git a/packages/ag-grid-enterprise/src/advancedFilter/colFilterExpressionParser.ts b/packages/ag-grid-enterprise/src/advancedFilter/colFilterExpressionParser.ts index 54a8cfb2022..5e6fe8640d4 100644 --- a/packages/ag-grid-enterprise/src/advancedFilter/colFilterExpressionParser.ts +++ b/packages/ag-grid-enterprise/src/advancedFilter/colFilterExpressionParser.ts @@ -1,4 +1,5 @@ import type { AdvancedFilterModel, AgColumn, BaseCellDataType } from 'ag-grid-community'; +import { _parseBigIntOrNull } from 'ag-grid-community'; import type { ADVANCED_FILTER_LOCALE_TEXT } from './advancedFilterLocaleText'; import type { AutocompleteEntry, AutocompleteListParams } from './autocomplete/autocompleteParams'; @@ -187,13 +188,22 @@ class OperandParser implements Parser { private modelValue: number | string; private validationMessage: string | null = null; - private readonly filterValidationSetters: Record any> = { + private readonly filterValidationSetters: Record< + BaseCellDataType, + (modelValue: string | number | bigint | null) => any + > = { number: () => { if (this.quotes || isNaN(this.modelValue as number)) { this.valid = false; this.validationMessage = this.params.advFilterExpSvc.translate('advancedFilterValidationNotANumber'); } }, + bigint: () => { + if (this.quotes || _parseBigIntOrNull(this.modelValue) === null) { + this.valid = false; + this.validationMessage = this.params.advFilterExpSvc.translate('advancedFilterValidationNotABigInt'); + } + }, date: (modelValue) => { if (modelValue == null) { this.valid = false; @@ -295,12 +305,24 @@ export class ColFilterExpressionParser { private operatorParser: OperatorParser | undefined; private operandParser: OperandParser | undefined; - private readonly operandValueGetters: Record any> = { + private readonly operandValueGetters: { + number: (a: string) => number; + bigint: (a: string) => bigint; + date: (a: string) => Date; + dateString: (a: string) => Date; + dateTime: (a: string) => Date; + dateTimeString: (a: string) => Date; + boolean: (a: string) => string; + object: (a: string) => string; + text: (a: string) => string; + } = { number: Number, - date: (operand) => this.params.valueSvc.parseValue(this.columnParser!.column!, null, operand, undefined), - dateString: (...args) => this.operandValueGetters.date(...args), - dateTime: (...args) => this.operandValueGetters.date(...args), - dateTimeString: (...args) => this.operandValueGetters.date(...args), + bigint: (operand) => _parseBigIntOrNull(operand)!, + date: (operand) => + this.params.valueSvc.parseValue(this.columnParser!.column!, null, operand, undefined) as Date, + dateString: (operand) => this.operandValueGetters.date(operand), + dateTime: (operand) => this.operandValueGetters.date(operand), + dateTimeString: (operand) => this.operandValueGetters.date(operand), boolean: (operand) => operand, object: (operand) => operand, text: (operand) => operand, @@ -614,7 +636,7 @@ export class ColFilterExpressionParser { } private doesOperandNeedQuotes(baseCellDataType?: BaseCellDataType): boolean { - return baseCellDataType !== 'number'; + return baseCellDataType !== 'number' && baseCellDataType !== 'bigint'; } private addToListAndGetIndex(list: T[], value: T): number { diff --git a/packages/ag-grid-enterprise/src/advancedFilter/filterExpressionOperators.ts b/packages/ag-grid-enterprise/src/advancedFilter/filterExpressionOperators.ts index 83a46cfb427..b36607561c9 100644 --- a/packages/ag-grid-enterprise/src/advancedFilter/filterExpressionOperators.ts +++ b/packages/ag-grid-enterprise/src/advancedFilter/filterExpressionOperators.ts @@ -40,6 +40,7 @@ export abstract class FilterExpressionOperators dateTimeString: DataTypeFilterExpressionOperators; text: DataTypeFilterExpressionOperators; number: DataTypeFilterExpressionOperators; + bigint: DataTypeFilterExpressionOperators; boolean: DataTypeFilterExpressionOperators; date: DataTypeFilterExpressionOperators; dateString: DataTypeFilterExpressionOperators; @@ -177,7 +178,7 @@ interface ScalarFilterExpressionOperatorsParams extends FilterE equals: (value: ConvertedTValue, operand: ConvertedTValue) => boolean; } -export class ScalarFilterExpressionOperators +export class ScalarFilterExpressionOperators implements DataTypeFilterExpressionOperators { public operators: { [operator: string]: FilterExpressionOperator }; diff --git a/packages/ag-grid-enterprise/src/aiToolkit/features/advancedFilterFeatureSchema.ts b/packages/ag-grid-enterprise/src/aiToolkit/features/advancedFilterFeatureSchema.ts index 0344eb84af5..7da36009ed9 100644 --- a/packages/ag-grid-enterprise/src/aiToolkit/features/advancedFilterFeatureSchema.ts +++ b/packages/ag-grid-enterprise/src/aiToolkit/features/advancedFilterFeatureSchema.ts @@ -18,6 +18,7 @@ export const buildAdvancedFilterFeatureSchema = ({ colModel, dataTypeSvc }: Bean dateTime: [], dateTimeString: [], number: [], + bigint: [], text: [], }; @@ -200,6 +201,32 @@ const buildNumberFilterSchema = (colIds: string[]) => { }); }; +const buildBigIntFilterSchema = (colIds: string[]) => { + return s.object({ + filterType: s.literal('bigint', 'Filter type identifier for bigint column filters'), + colId: s.enum(colIds, 'Column identifier for the bigint column to filter'), + filter: s + .string({ + pattern: '^-?\\d+$', + description: 'BigInt value to filter by', + }) + .nullable(), + type: s.enum( + [ + 'equals', + 'notEqual', + 'lessThan', + 'lessThanOrEqual', + 'greaterThan', + 'greaterThanOrEqual', + 'blank', + 'notBlank', + ], + 'BigInt filter operation type' + ), + }); +}; + const buildTextFilterSchema = (colIds: string[]) => { return s.object({ filterType: s.literal('text', 'Filter type identifier for text column filters'), @@ -220,5 +247,6 @@ const DataTypeSchemaBuilders: Record any dateTime: buildDateTimeFilterSchema, dateTimeString: buildDateTimeStringFilterSchema, number: buildNumberFilterSchema, + bigint: buildBigIntFilterSchema, text: buildTextFilterSchema, }; diff --git a/packages/ag-grid-enterprise/src/pivot/pivotStage.ts b/packages/ag-grid-enterprise/src/pivot/pivotStage.ts index 9db106d1003..35edc29bb54 100644 --- a/packages/ag-grid-enterprise/src/pivot/pivotStage.ts +++ b/packages/ag-grid-enterprise/src/pivot/pivotStage.ts @@ -12,7 +12,7 @@ import type { ValueService, _IRowNodePivotStage, } from 'ag-grid-community'; -import { BeanStub, _missing } from 'ag-grid-community'; +import { BeanStub, _jsonEquals, _missing } from 'ag-grid-community'; import type { PivotColDefService } from './pivotColDefService'; @@ -166,19 +166,14 @@ export class PivotStage extends BeanStub implements NamedBean, _IRowNodePivotSta } private setUniqueValues(newValues: Map): boolean { - const json1 = JSON.stringify(mapToObject(this.uniqueValues)); - const json2 = JSON.stringify(mapToObject(newValues)); - - const uniqueValuesChanged = json1 !== json2; - + const uniqueValuesChanged = !_jsonEquals(mapToObject(this.uniqueValues), mapToObject(newValues)); // we only continue the below if the unique values are different, as otherwise // the result will be the same as the last time we did it if (uniqueValuesChanged) { this.uniqueValues = newValues; return true; - } else { - return false; } + return false; } private currentUniqueCount = 0; diff --git a/packages/ag-grid-enterprise/src/rowHierarchy/groupEditService.ts b/packages/ag-grid-enterprise/src/rowHierarchy/groupEditService.ts index 3075f3e76de..7ff599ae7fd 100644 --- a/packages/ag-grid-enterprise/src/rowHierarchy/groupEditService.ts +++ b/packages/ag-grid-enterprise/src/rowHierarchy/groupEditService.ts @@ -116,11 +116,13 @@ export class GroupEditService extends BeanStub implements _IGroupEditService { } const sourceLevel = rowNode.group ? rowNode.level : currentParent.level ?? -1; - const targetLevel = target - ? target.group - ? target.level - : target.parent?.level ?? -1 - : comparisonParent?.level ?? -1; + + let targetLevel = -1; + if (target) { + targetLevel = target.group ? target.level : target.parent?.level ?? -1; + } else if (comparisonParent) { + targetLevel = comparisonParent.level; + } if (sourceLevel >= 0 && targetLevel >= 0 && targetLevel !== sourceLevel) { return false; @@ -148,9 +150,9 @@ export class GroupEditService extends BeanStub implements _IGroupEditService { const rootNode = rowsDrop.rootNode as IRowNode; const rowModel = this.beans.rowModel; - const canStartGroup = target ? this.canDropStartGroup(target) : false; - this.updateDropTarget(canStartGroup ? target : null, fromNudge, rowsDrop); + const canStartGroup = this.canStartGroup(target, treeData); + this.updateDropTarget(rowsDrop, fromNudge, canStartGroup); const lastRowIndex = this.beans.pageBounds?.getLastRow?.() ?? rowModel.getRowCount() - 1; if (canSetParent) { @@ -163,16 +165,9 @@ export class GroupEditService extends BeanStub implements _IGroupEditService { if (!newParent) { newParent = target?.parent ?? rootNode; } + } - if ( - !fromNudge && - target && - canStartGroup && - (!newParent || (!target.expanded && !!target.childrenAfterSort?.length)) - ) { - this.startDropGroupDelay(target); - } - } else if (!fromNudge && target && canStartGroup) { + if (!fromNudge && target && canStartGroup && !(target.group && target.expanded)) { this.startDropGroupDelay(target); } @@ -209,7 +204,9 @@ export class GroupEditService extends BeanStub implements _IGroupEditService { } } - private updateDropTarget(target: IRowNode | null, canExpand: boolean, rowsDrop: _RowsDrop): void { + private updateDropTarget(rowsDrop: _RowsDrop, fromNudge: boolean, canStartGroup: boolean): void { + const target = canStartGroup ? rowsDrop.target : null; + if (this.dropGroupTarget && this.dropGroupTarget !== target) { this.resetDragGroup(); } @@ -218,7 +215,7 @@ export class GroupEditService extends BeanStub implements _IGroupEditService { return; } - if (canExpand && this.dropGroupThrottled && !target.expanded && target.isExpandable?.()) { + if (fromNudge && this.dropGroupThrottled && !target.expanded && target.isExpandable?.()) { target.setExpanded(true, undefined, true); } @@ -403,14 +400,16 @@ export class GroupEditService extends BeanStub implements _IGroupEditService { return true; } - public canDropStartGroup(candidate: IRowNode | null | undefined) { - return ( - !!candidate && - candidate.level >= 0 && - !candidate.footer && - !candidate.detail && - (candidate.isExpandable?.() || !!candidate.childrenAfterSort?.length) - ); + private canStartGroup(target: IRowNode | null, treeData: boolean): boolean { + if (!target || target.level < 0 || target.footer || target.detail) { + return false; // cannot group into root, footer, or detail rows + } + + if (target.group) { + return true; + } + + return treeData; // in tree data any leaf can become a group } /** Flushes any pending group edits for batch processing */ diff --git a/packages/ag-grid-enterprise/src/serverSideRowModel/listeners/filterListener.ts b/packages/ag-grid-enterprise/src/serverSideRowModel/listeners/filterListener.ts index d1eb48f4a24..34db9fa4c1c 100644 --- a/packages/ag-grid-enterprise/src/serverSideRowModel/listeners/filterListener.ts +++ b/packages/ag-grid-enterprise/src/serverSideRowModel/listeners/filterListener.ts @@ -6,7 +6,7 @@ import type { NamedBean, StoreRefreshAfterParams, } from 'ag-grid-community'; -import { BeanStub, _isServerSideRowModel } from 'ag-grid-community'; +import { BeanStub, _isServerSideRowModel, _jsonEquals } from 'ag-grid-community'; import type { ServerSideRowModel } from '../serverSideRowModel'; import type { ListenerUtils } from './listenerUtils'; @@ -96,9 +96,7 @@ export class FilterListener extends BeanStub implements NamedBean { const res: string[] = []; for (const key of Object.keys(allColKeysMap)) { - const oldJson = JSON.stringify(oldModel[key]); - const newJson = JSON.stringify(newModel[key]); - const filterChanged = oldJson != newJson; + const filterChanged = !_jsonEquals(oldModel[key], newModel[key]); if (filterChanged) { res.push(key); } diff --git a/testing/behavioural/src/cell-editing/bigint-value-parser-formatter.test.ts b/testing/behavioural/src/cell-editing/bigint-value-parser-formatter.test.ts new file mode 100644 index 00000000000..77df979c87c --- /dev/null +++ b/testing/behavioural/src/cell-editing/bigint-value-parser-formatter.test.ts @@ -0,0 +1,61 @@ +import { userEvent } from '@testing-library/user-event'; + +import { ClientSideRowModelModule, TextEditorModule, getGridElement } from 'ag-grid-community'; + +import { GridRows, TestGridsManager, waitForEvent, waitForInput } from '../test-utils'; + +describe('BigInt value parser and formatter', () => { + const gridMgr = new TestGridsManager({ + modules: [ClientSideRowModelModule, TextEditorModule], + }); + + afterEach(() => { + gridMgr.reset(); + }); + + test('parses bigint editor input', async () => { + const api = gridMgr.createGrid('bigint-parser', { + columnDefs: [{ field: 'value', cellDataType: 'bigint', editable: true }], + rowData: [{ id: 'r1', value: 10n }], + getRowId: (params) => params.data?.id, + }); + + const gridDiv = getGridElement(api)! as HTMLElement; + const cell = gridDiv.querySelector('[row-index="0"] [col-id="value"]')!; + + const editingStarted = waitForEvent('cellEditingStarted', api); + api.startEditingCell({ rowIndex: 0, colKey: 'value' }); + await editingStarted; + + const input = await waitForInput(gridDiv, cell); + const user = userEvent.setup(); + await user.clear(input); + await user.type(input, '500n'); + + const editingStopped = waitForEvent('cellEditingStopped', api); + api.stopEditing(); + await editingStopped; + + const rowNode = api.getRowNode('r1')!; + expect(rowNode.data.value).toBe(500n); + }); + + test('uses valueFormatter for bigint display', async () => { + const api = gridMgr.createGrid('bigint-formatter', { + columnDefs: [ + { + field: 'value', + cellDataType: 'bigint', + valueFormatter: (params) => (params.value == null ? '' : `formatted-${params.value}`), + }, + ], + rowData: [{ id: 'r1', value: 12n }], + getRowId: (params) => params.data?.id, + }); + + await new GridRows(api, 'bigint formatted').check(` + ROOT id:ROOT_NODE_ID + └── LEAF id:r1 value:"formatted-12" + `); + }); +}); diff --git a/testing/behavioural/src/cell-editing/examples/full-row-editing-doc.test.ts b/testing/behavioural/src/cell-editing/examples/full-row-editing-doc.test.ts index 4527123312d..7110e407d6a 100644 --- a/testing/behavioural/src/cell-editing/examples/full-row-editing-doc.test.ts +++ b/testing/behavioural/src/cell-editing/examples/full-row-editing-doc.test.ts @@ -233,5 +233,5 @@ describe('Full-row editing documentation examples', () => { ├── LEAF id:1 make-0-0:"Ford" model-1-1:"XYZ" field4-2-2:"S-YY" └── LEAF id:2 make-0-0:"Porsche" model-1-1:"Boxster 0" field4-2-2:"S-ZZ" `); - }); + }, 15000); }); diff --git a/testing/behavioural/src/drag-n-drop/grouping/managed-row-group-drag-edit-basic.test.ts b/testing/behavioural/src/drag-n-drop/grouping/managed-row-group-drag-edit-basic.test.ts index 6228433362c..16e16aba904 100644 --- a/testing/behavioural/src/drag-n-drop/grouping/managed-row-group-drag-edit-basic.test.ts +++ b/testing/behavioural/src/drag-n-drop/grouping/managed-row-group-drag-edit-basic.test.ts @@ -432,6 +432,83 @@ describe.each([false, true])('drag refreshAfterGroupEdit basics (suppress move % expect(api.getRowNode('2')?.data.group).toBe('B'); }); + test('rowDragInsertDelay does not promote leaf targets in row grouping', async () => { + const gridOptions: GridOptions = { + animateRows: true, + columnDefs: [ + { field: 'group', rowGroup: true, hide: true }, + { field: 'value', rowDrag: true }, + ], + autoGroupColumnDef: { headerName: 'Group' }, + rowData: [ + { id: '1', group: 'A', value: 'A1' }, + { id: '2', group: 'B', value: 'B1' }, + ], + rowDragManaged: true, + suppressMoveWhenRowDragging, + refreshAfterGroupEdit: true, + rowDragInsertDelay: 60, + groupDefaultExpanded: -1, + getRowId: (params) => params.data.id, + }; + + const api = gridsManager.createGrid('row-group-edit-insert-delay-leaf', gridOptions); + + const dispatcher = new RowDragDispatcher({ api }); + await dispatcher.start('1'); + await waitFor(() => expect(dispatcher.getDragGhostLabel()).toBe('A1')); + await dispatcher.move('2', { center: true }); + await asyncSetTimeout(80); + await dispatcher.move('2', { center: true }); + + const lastMove = dispatcher.rowDragMoveEvents[dispatcher.rowDragMoveEvents.length - 1]; + expect(lastMove?.rowsDrop?.position).not.toBe('inside'); + expect(lastMove?.rowsDrop?.newParent?.id).toBe('row-group-group-B'); + + if (suppressMoveWhenRowDragging) { + const indicator = api.getRowDropPositionIndicator(); + expect(indicator.dropIndicatorPosition).not.toBe('inside'); + } + + await dispatcher.finish(); + }); + + test('rowDragInsertDelay skips expanded group targets', async () => { + const gridOptions: GridOptions = { + animateRows: true, + columnDefs: [ + { field: 'group', rowGroup: true, hide: true }, + { field: 'value', rowDrag: true }, + ], + autoGroupColumnDef: { headerName: 'Group' }, + rowData: [ + { id: '1', group: 'A', value: 'A1' }, + { id: '2', group: 'A', value: 'A2' }, + { id: '3', group: 'B', value: 'B1' }, + ], + rowDragManaged: true, + suppressMoveWhenRowDragging, + refreshAfterGroupEdit: true, + rowDragInsertDelay: 10000, + groupDefaultExpanded: -1, + getRowId: (params) => params.data.id, + }; + + const api = gridsManager.createGrid('row-group-edit-insert-delay-expanded', gridOptions); + + const dispatcher = new RowDragDispatcher({ api }); + await dispatcher.start('2'); + await waitFor(() => expect(dispatcher.getDragGhostLabel()).toBe('A2')); + await dispatcher.move('row-group-group-B', { center: true }); + await dispatcher.finish(); + await asyncSetTimeout(0); + + const dropInfo = dispatcher.rowDragEndEvents[0]?.rowsDrop; + expect(dropInfo?.position).toBe('above'); + expect(dropInfo?.newParent?.id).toBe('row-group-group-B'); + expect(api.getRowNode('2')?.data.group).toBe('B'); + }); + test.each([0, -0.9, 0.9] as const)('moves a leaf in collapsed sibling group immediately y=%f', async (y) => { const gridOptions: GridOptions = { animateRows: true, diff --git a/testing/behavioural/src/drag-n-drop/tree-data/tree-data-managed-row-drag-multi.test.ts b/testing/behavioural/src/drag-n-drop/tree-data/tree-data-managed-row-drag-multi.test.ts index 451b8251307..d8cbaad9f9b 100644 --- a/testing/behavioural/src/drag-n-drop/tree-data/tree-data-managed-row-drag-multi.test.ts +++ b/testing/behavioural/src/drag-n-drop/tree-data/tree-data-managed-row-drag-multi.test.ts @@ -182,6 +182,14 @@ describe.each([false, true])('tree drag multi flows (suppress move %s)', (suppre }); } + if (suppressMoveWhenRowDragging) { + await waitFor(() => { + const indicator = api.getRowDropPositionIndicator(); + expect(indicator.dropIndicatorPosition).not.toBe('none'); + expect(['root-ops', 'root-ops-logs']).toContain(indicator.row?.id); + }); + } + await asyncSetTimeout(10); await dispatcher.move(targetRowId, { clientX, clientY }); await dispatcher.finish(); @@ -263,4 +271,112 @@ describe.each([false, true])('tree drag multi flows (suppress move %s)', (suppre expect(dropInfo?.rows?.length ?? 0).toBeGreaterThan(0); expect(dropInfo?.newParent?.id ?? dropInfo?.overNode?.id).toBe('inbox'); }); + + test('rowDragInsertDelay promotes leaf targets without a validator', async () => { + const rowData = [ + { + id: 'root', + name: 'Root', + type: 'folder', + children: [ + { id: 'inbox', name: 'Inbox', type: 'folder', children: [] }, + { id: 'incoming', name: 'Incoming', type: 'file', children: [] }, + ], + }, + ]; + + const api = createGrid('tree-managed-insert-promote-default', rowData, { + rowDragInsertDelay: 60, + }); + + const initialRows = new GridRows(api, 'insert promote default initial'); + await initialRows.check(` + ROOT id:ROOT_NODE_ID + └─┬ root GROUP id:root ag-Grid-AutoColumn:"Root" type:"folder" + · ├── inbox LEAF id:inbox ag-Grid-AutoColumn:"Inbox" type:"folder" + · └── incoming LEAF id:incoming ag-Grid-AutoColumn:"Incoming" type:"file" + `); + + const sourceRowId = 'incoming'; + const targetRowId = 'inbox'; + expect(getRowHtmlElement(api, sourceRowId)).toBeTruthy(); + expect(getRowHtmlElement(api, targetRowId)).toBeTruthy(); + + const dispatcher = new RowDragDispatcher({ api }); + await dispatcher.start(sourceRowId); + await waitFor(() => expect(dispatcher.getDragGhostLabel()).toBe('Incoming')); + await dispatcher.move(targetRowId, { yOffsetPercent: 0.45 }); + await asyncSetTimeout(80); + await dispatcher.move(targetRowId, { center: true }); + + if (suppressMoveWhenRowDragging) { + await waitFor(() => { + const indicator = api.getRowDropPositionIndicator(); + expect(indicator.dropIndicatorPosition).toBe('inside'); + expect(indicator.row?.id).toBe('inbox'); + }); + } + + await dispatcher.finish(); + await asyncSetTimeout(0); + + const dropInfo = dispatcher.rowDragEndEvents[0]?.rowsDrop; + + const finalRows = new GridRows(api, 'insert promote default after'); + await finalRows.check(` + ROOT id:ROOT_NODE_ID + └─┬ root GROUP id:root ag-Grid-AutoColumn:"Root" type:"folder" + · └─┬ inbox GROUP id:inbox ag-Grid-AutoColumn:"Inbox" type:"folder" + · · └── incoming LEAF id:incoming ag-Grid-AutoColumn:"Incoming" type:"file" + `); + + expect(api.getRowNode('incoming')?.parent?.id).toBe('inbox'); + expect(api.getRowNode('inbox')?.childrenAfterSort?.some((node) => node.id === 'incoming')).toBe(true); + expect(dropInfo?.newParent?.id ?? dropInfo?.overNode?.id).toBe('inbox'); + expect(dropInfo?.position).toBe('inside'); + }); + + test('rowDragInsertDelay skips already expanded groups', async () => { + const rowData = [ + { + id: 'root', + name: 'Root', + type: 'folder', + children: [ + { + id: 'alpha', + name: 'Alpha', + type: 'folder', + children: [{ id: 'alpha-item', name: 'Alpha Item', type: 'file', children: [] }], + }, + { + id: 'beta', + name: 'Beta', + type: 'folder', + children: [{ id: 'beta-item', name: 'Beta Item', type: 'file', children: [] }], + }, + ], + }, + ]; + + const api = createGrid('tree-managed-insert-expanded', rowData, { + rowDragInsertDelay: 10000, + groupDefaultExpanded: -1, + }); + + await asyncSetTimeout(0); + expect(api.getRowNode('beta')?.expanded).toBe(true); + + const dispatcher = new RowDragDispatcher({ api }); + await dispatcher.start('alpha-item'); + await waitFor(() => expect(dispatcher.getDragGhostLabel()).toBe('Alpha Item')); + await dispatcher.move('beta', { center: true }); + await dispatcher.finish(); + await asyncSetTimeout(0); + + const dropInfo = dispatcher.rowDragEndEvents[0]?.rowsDrop; + expect(dropInfo?.position).toBe('above'); + expect(dropInfo?.newParent?.id).toBe('beta'); + expect(api.getRowNode('alpha-item')?.parent?.id).toBe('beta'); + }); }); diff --git a/testing/behavioural/src/filters/aggregate-filters.test.ts b/testing/behavioural/src/filters/aggregate-filters.test.ts index f6cc4214325..1e16cc6280f 100644 --- a/testing/behavioural/src/filters/aggregate-filters.test.ts +++ b/testing/behavioural/src/filters/aggregate-filters.test.ts @@ -5,7 +5,14 @@ import { GridRows, TestGridsManager } from '../test-utils'; describe('Aggregate Filters', () => { const gridsManager = new TestGridsManager({ - modules: [ClientSideRowModelModule, TextFilterModule, NumberFilterModule, RowGroupingModule, PivotModule], + modules: [ + ClientSideRowModelModule, + TextFilterModule, + PivotModule, + NumberFilterModule, + RowGroupingModule, + PivotModule, + ], }); const rowData = [ diff --git a/testing/behavioural/src/grid-state/grid-state-full.test.ts b/testing/behavioural/src/grid-state/grid-state-full.test.ts index 0c4a10b6189..ae45a37f50a 100644 --- a/testing/behavioural/src/grid-state/grid-state-full.test.ts +++ b/testing/behavioural/src/grid-state/grid-state-full.test.ts @@ -33,7 +33,7 @@ describe('Grid State Full Snapshot', () => { }); await asyncSetTimeout(1); - expect({ + expect(api.getState()).toEqual({ aggregation: undefined, cellSelection: undefined, columnGroup: undefined, @@ -95,7 +95,7 @@ describe('Grid State Full Snapshot', () => { sort: undefined, ssrmRowGroupExpansion: undefined, version: VERSION, - }).toEqual(api.getState()); + }); }); test('should get state with multiple features active', async () => { diff --git a/testing/behavioural/src/grid-state/grid-state.test.ts b/testing/behavioural/src/grid-state/grid-state.test.ts index 6d4f175c123..e34f8d4fc28 100644 --- a/testing/behavioural/src/grid-state/grid-state.test.ts +++ b/testing/behavioural/src/grid-state/grid-state.test.ts @@ -377,6 +377,37 @@ describe('StateService - Grid State Management', () => { selectableFilters: {}, }); }); + + test('should serialise bigint filter state and rehydrate on setState', async () => { + const columnDefs = [{ field: 'id', cellDataType: 'bigint', filter: 'agBigIntColumnFilter' }]; + const rowData = [{ id: 1n }, { id: 2n }, { id: 3n }]; + + const api = gridsManager.createGrid('bigIntStateSource', { + columnDefs, + rowData, + }); + + api.setFilterModel({ + id: { filterType: 'bigint', type: 'equals', filter: '2n' }, + }); + + await asyncSetTimeout(20); + + const savedState = api.getState(); + expect(savedState.filter?.filterModel?.id?.filter).toBe('2n'); + + const api2 = gridsManager.createGrid('bigIntStateTarget', { + columnDefs, + rowData, + }); + + api2.setState(savedState as GridState); + + await asyncSetTimeout(20); + + const restoredFilterModel = api2.getFilterModel(); + expect(restoredFilterModel?.id?.filter).toBe('2n'); + }); }); // ===== CELL STATE TESTS ===== @@ -387,6 +418,8 @@ describe('StateService - Grid State Management', () => { rowData: defaultRowData, }); + await asyncSetTimeout(20); + // Focus a cell api.setFocusedCell(0, 'name'); @@ -667,6 +700,8 @@ describe('StateService - Grid State Management', () => { rowData: defaultRowData, }); + await asyncSetTimeout(20); + let eventFired = false; let eventState: any; diff --git a/testing/behavioural/src/group-cell-renderer/grouping/groups/csrm/grouping-values-bigint-formatter.test.ts b/testing/behavioural/src/group-cell-renderer/grouping/groups/csrm/grouping-values-bigint-formatter.test.ts new file mode 100644 index 00000000000..4aae1238bb3 --- /dev/null +++ b/testing/behavioural/src/group-cell-renderer/grouping/groups/csrm/grouping-values-bigint-formatter.test.ts @@ -0,0 +1,52 @@ +import type { GridOptions } from 'ag-grid-community'; +import { AllEnterpriseModule } from 'ag-grid-enterprise'; + +import { TestGridsManager } from '../../../../test-utils'; + +describe('ag-grid groupCellRenderer', () => { + const gridsManager = new TestGridsManager({ modules: [AllEnterpriseModule] }); + + beforeEach(() => { + gridsManager.reset(); + }); + + afterEach(() => { + gridsManager.reset(); + }); + + test('bigint valueFormatter is used for group totals', async () => { + const gridOptions: GridOptions = { + columnDefs: [ + { field: 'category', rowGroup: true, hide: true }, + { + field: 'amount', + cellDataType: 'bigint', + aggFunc: 'sum', + valueFormatter: (params) => `formatted-${params.value}`, + }, + ], + autoGroupColumnDef: { + cellClass: 'ag-cell-group', + }, + rowData: [ + { category: 'A', amount: 5n }, + { category: 'A', amount: 7n }, + ], + groupDefaultExpanded: -1, + groupTotalRow: 'bottom', + }; + + const div = document.createElement('div'); + document.body.appendChild(div); + vi.useFakeTimers(); + const api = gridsManager.createGrid(div, gridOptions); + vi.runAllTimers(); + vi.useRealTimers(); + + api.ensureIndexVisible(2); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const totalValueCell = div.querySelector('.ag-row-footer [col-id="amount"]'); + expect(totalValueCell?.textContent ?? '').toContain('formatted-12'); + }); +}); diff --git a/testing/behavioural/src/selection/source-row-index.test.ts b/testing/behavioural/src/selection/source-row-index.test.ts new file mode 100644 index 00000000000..094a48694b4 --- /dev/null +++ b/testing/behavioural/src/selection/source-row-index.test.ts @@ -0,0 +1,70 @@ +import { ClientSideRowModelModule } from 'ag-grid-community'; +import type { GridOptions } from 'ag-grid-community'; + +import { TestGridsManager } from '../test-utils'; + +describe('sourceRowIndex in isRowSelectable', () => { + const columnDefs = [{ field: 'sport' }]; + const rowData = [{ sport: 'football' }, { sport: 'rugby' }, { sport: 'tennis' }]; + + const gridMgr = new TestGridsManager({ + modules: [ClientSideRowModelModule], + }); + + beforeEach(() => { + gridMgr.reset(); + }); + + afterEach(() => { + gridMgr.reset(); + }); + + test('sourceRowIndex should be populated in isRowSelectable', () => { + const nodeLog: { id: string | undefined; sourceRowIndex: number; rowIndex: number | null }[] = []; + + const gridOptions: GridOptions = { + columnDefs, + rowData, + rowSelection: { + mode: 'multiRow', + isRowSelectable: (node) => { + nodeLog.push({ + id: node.id, + sourceRowIndex: node.sourceRowIndex, + rowIndex: node.rowIndex, + }); + return true; + }, + }, + }; + + gridMgr.createGrid('myGrid', gridOptions); + + // For 3 rows, we expect isRowSelectable to be called at least 3 times during initial load (once for each node) + // Then it should be called again when rowIndex is set. + expect(nodeLog.length).toBeGreaterThanOrEqual(3); + + // Group by node ID (or reference if we had it, but ID is fine here as it's set before updateRowSelectable) + const nodeMap: Record = {}; + nodeLog.forEach((log) => { + const id = log.id!; + if (!nodeMap[id]) { + nodeMap[id] = []; + } + nodeMap[id].push(log); + }); + + const ids = Object.keys(nodeMap); + expect(ids.length).toBe(3); + + ids.forEach((id) => { + const logs = nodeMap[id]; + + // All calls for this node should have a valid sourceRowIndex (not -1) + logs.forEach((log) => { + expect(log.sourceRowIndex).toBeGreaterThanOrEqual(0); + expect(log.sourceRowIndex).toBeLessThan(3); + }); + }); + }); +}); diff --git a/testing/behavioural/src/sorting/sorting.test.ts b/testing/behavioural/src/sorting/sorting.test.ts index 1396a6acc27..674eb80c472 100644 --- a/testing/behavioural/src/sorting/sorting.test.ts +++ b/testing/behavioural/src/sorting/sorting.test.ts @@ -159,6 +159,60 @@ describe('Sorting', () => { `); }); + test('sorts bigint values ascending, descending, and absolute', async () => { + const api = gridMgr.createGrid('bigintSort', { + columnDefs: [ + { + field: 'value', + cellDataType: 'bigint', + valueFormatter: (params) => (params.value == null ? '' : `${params.value}n`), + }, + ], + rowData: [ + { id: 'a', value: 9007199254740993n }, + { id: 'b', value: -5n }, + { id: 'c', value: 10n }, + ], + getRowId: (params) => params.data?.id, + }); + + await new GridRows(api, 'bigint initial').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:a value:"9007199254740993n" + ├── LEAF id:b value:"-5n" + └── LEAF id:c value:"10n" + `); + + api.applyColumnState({ state: [{ colId: 'value', sort: 'asc' }] }); + + await new GridRows(api, 'bigint asc').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:b value:"-5n" + ├── LEAF id:c value:"10n" + └── LEAF id:a value:"9007199254740993n" + `); + + api.applyColumnState({ state: [{ colId: 'value', sort: 'desc' }] }); + + await new GridRows(api, 'bigint desc').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:a value:"9007199254740993n" + ├── LEAF id:c value:"10n" + └── LEAF id:b value:"-5n" + `); + + api.applyColumnState({ + state: [{ colId: 'value', sort: 'asc', sortIndex: 0, sortType: 'absolute' }], + }); + + await new GridRows(api, 'bigint absolute asc').check(` + ROOT id:ROOT_NODE_ID + ├── LEAF id:b value:"-5n" + ├── LEAF id:c value:"10n" + └── LEAF id:a value:"9007199254740993n" + `); + }); + test('accentedSort toggles locale aware ordering', async () => { const namesRowData = [ { id: 'accent-1', name: 'Zorro' }, diff --git a/testing/behavioural/src/test-utils/gridRows/gridRowsDiagramTree.ts b/testing/behavioural/src/test-utils/gridRows/gridRowsDiagramTree.ts index eb74bc0b0e3..8b9b047f299 100644 --- a/testing/behavioural/src/test-utils/gridRows/gridRowsDiagramTree.ts +++ b/testing/behavioural/src/test-utils/gridRows/gridRowsDiagramTree.ts @@ -341,7 +341,11 @@ export class GridRowsDiagramTree { const diagramColumnId = isRowNumberCol(columnId) ? 'row-number' : columnId; if (value !== undefined || formattedValue) { - result += ' ' + diagramColumnId + ':' + JSON.stringify(formattedValue || value); + const serialisedValue = + typeof (formattedValue || value) === 'bigint' + ? JSON.stringify(`${formattedValue || value}n`) + : JSON.stringify(formattedValue || value); + result += ' ' + diagramColumnId + ':' + serialisedValue; } else if (!omitUndefined && row.data != null) { result += ' ' + diagramColumnId + ':undefined'; } @@ -352,7 +356,8 @@ export class GridRowsDiagramTree { if (dataProps?.length) { for (const prop of dataProps) { const dataValue = (row.data as any)?.[prop]; - const serialised = JSON.stringify(dataValue ?? ''); + const serialised = + typeof dataValue === 'bigint' ? JSON.stringify(`${dataValue}n`) : JSON.stringify(dataValue ?? ''); result += ` data.${prop}:${serialised}`; } } diff --git a/testing/module-size/moduleDefinitions.ts b/testing/module-size/moduleDefinitions.ts index b23b6be3795..47d3fddb3e4 100644 --- a/testing/module-size/moduleDefinitions.ts +++ b/testing/module-size/moduleDefinitions.ts @@ -6,11 +6,12 @@ import type { export const AllGridCommunityModules: Record<`${CommunityModuleName}Module`, number> = { AlignedGridsModule: 6.88, - AllCommunityModule: 501.39, + AllCommunityModule: 511.54, CellApiModule: 0.28, CellSpanModule: 8.08, CellStyleModule: 2.24, CheckboxEditorModule: 69.23, + BigIntFilterModule: 131.17, ClientSideRowModelApiModule: 1.88, ClientSideRowModelModule: 29.1, ColumnApiModule: 3.6, @@ -20,11 +21,11 @@ export const AllGridCommunityModules: Record<`${CommunityModuleName}Module`, num CustomEditorModule: 67.95, CustomFilterModule: 74.17, DateEditorModule: 74.39, - DateFilterModule: 132, + DateFilterModule: 139.28, DragAndDropModule: 1, EventApiModule: 2.64, ExternalFilterModule: 12.67, - GridStateModule: 15.84, + GridStateModule: 17.38, HighlightChangesModule: 5.09, InfiniteRowModelModule: 18, LargeTextEditorModule: 70.13, @@ -43,7 +44,7 @@ export const AllGridCommunityModules: Record<`${CommunityModuleName}Module`, num ScrollApiModule: 0.7, SelectEditorModule: 83.87, TextEditorModule: 71.4, - TextFilterModule: 124, + TextFilterModule: 128.66, TooltipModule: 25.06, UndoRedoEditModule: 74.12, ValidationModule: 74.37, @@ -51,7 +52,7 @@ export const AllGridCommunityModules: Record<`${CommunityModuleName}Module`, num }; export const AllEnterpriseModules: Record<`${EnterpriseModuleName}Module`, number> = { AdvancedFilterModule: 223.75, - AllEnterpriseModule: 1606.74, + AllEnterpriseModule: 1627.32, AiToolkitModule: 36, BatchEditModule: 84.54, CellSelectionModule: 62.78, @@ -62,14 +63,14 @@ export const AllEnterpriseModules: Record<`${EnterpriseModuleName}Module`, numbe ExcelExportModule: 87.14, FiltersToolPanelModule: 137.67, FindModule: 31, - FormulaModule: 88.65, + FormulaModule: 92.79, GridChartsModule: 76.93, GroupFilterModule: 118.66, IntegratedChartsModule: 412.18, MasterDetailModule: 87.16, MenuModule: 166.7, MultiFilterModule: 150.56, - NewFiltersToolPanelModule: 175.32, + NewFiltersToolPanelModule: 180.87, PivotModule: 112.4, RangeSelectionModule: 62.84, RichSelectModule: 132.51, diff --git a/testing/shared/moduleDefinitions.ts b/testing/shared/moduleDefinitions.ts index 610220ce146..53223a7822c 100644 --- a/testing/shared/moduleDefinitions.ts +++ b/testing/shared/moduleDefinitions.ts @@ -6,11 +6,12 @@ import type { export const AllGridCommunityModules: Record<`${CommunityModuleName}Module`, number> = { AlignedGridsModule: 6.88, - AllCommunityModule: 501.39, + AllCommunityModule: 511.54, CellApiModule: 0.28, CellSpanModule: 8.08, CellStyleModule: 2.24, CheckboxEditorModule: 69.23, + BigIntFilterModule: 131.17, ClientSideRowModelApiModule: 1.88, ClientSideRowModelModule: 29.1, ColumnApiModule: 3.6, @@ -20,11 +21,11 @@ export const AllGridCommunityModules: Record<`${CommunityModuleName}Module`, num CustomEditorModule: 67.95, CustomFilterModule: 74.17, DateEditorModule: 74.39, - DateFilterModule: 132, + DateFilterModule: 139.28, DragAndDropModule: 1, EventApiModule: 2.64, ExternalFilterModule: 12.67, - GridStateModule: 14.7, + GridStateModule: 17.38, HighlightChangesModule: 5.09, InfiniteRowModelModule: 18, LargeTextEditorModule: 70.13, @@ -43,7 +44,7 @@ export const AllGridCommunityModules: Record<`${CommunityModuleName}Module`, num ScrollApiModule: 0.7, SelectEditorModule: 83.87, TextEditorModule: 71.4, - TextFilterModule: 124, + TextFilterModule: 128.66, TooltipModule: 25.06, UndoRedoEditModule: 74.12, ValidationModule: 74.37, @@ -51,7 +52,7 @@ export const AllGridCommunityModules: Record<`${CommunityModuleName}Module`, num }; export const AllEnterpriseModules: Record<`${EnterpriseModuleName}Module`, number> = { AdvancedFilterModule: 222.75, - AllEnterpriseModule: 1606.74, + AllEnterpriseModule: 1627.32, AiToolkitModule: 36, BatchEditModule: 84.54, CellSelectionModule: 62.78, @@ -62,14 +63,14 @@ export const AllEnterpriseModules: Record<`${EnterpriseModuleName}Module`, numbe ExcelExportModule: 87.14, FiltersToolPanelModule: 137.67, FindModule: 31, - FormulaModule: 88.65, + FormulaModule: 92.79, GridChartsModule: 76.93, IntegratedChartsModule: 412.18, GroupFilterModule: 118.66, MasterDetailModule: 87.16, MenuModule: 165, MultiFilterModule: 150.24, - NewFiltersToolPanelModule: 175.32, + NewFiltersToolPanelModule: 180.87, PivotModule: 112.4, RangeSelectionModule: 62.84, RichSelectModule: 132.51,