v7.3.0
SupportExamplesFree Trial

What's new in Grid v6.0.0+

Grid v6.0.0

LockRows feature

This feature allows records which satisfy a certain condition to be locked at the top of the grid.

By default the condition is that a certain named field have a truthy value. The field which decides this status defaults to 'fixed', but that is configurable using the fieldName property.

When used with fieldName, the CellMenu context menu appears with an extra option to toggle the value of that field in the contextual record. For more granular control, use the filterFn to decide which records should be locked.

This feature is disabled by default.

const data = DataGenerator.generateData(50); data[0].fixed = true; const grid = new Grid({ appendTo : targetElement, height : '30em', features : { group : false, lockRows : true }, data, columns : [ { field : 'firstName', text : 'First name', width : 150 }, { field : 'fixed', text : 'Fixed', type : 'check' }, { field : 'surName', text : 'Surname', width : 150 }, { field : 'city', text : 'City', width : 150 }, { type : 'date', field : 'start', text : 'Start', width : 150 }, { type : 'date', field : 'finish', text : 'Finish', width : 150 }, { type : 'number', field : 'score', text : 'Score', width : 150 }, { type : 'number', field : 'age', text : 'Age', width : 150 }, { type : 'rating', field : 'rank', text : 'Rank', width : 150 } ] });

Lazy data loading (infinite scroll)

Stores now has support for lazy loading of data. In the Grid this means it is possible to continuously load records when they scroll into view, instead of loading the complete dataset at once.

Set the lazyLoad config on the Store to true to enable this behaviour. For the initial load, either use autoLoad or call the load function.

If you are using an AjaxStore to handle the API requests, it is just a matter of implementing the backend to support lazy loading, and you are good to go.

new Grid({
    store: {
        // This will create an AjaxStore
        readUrl  : 'backend/read',
        // This will activate the lazy load functionality
        lazyLoad : true,
        // This will load the Store initially upon creation
        autoLoad : true
    }
});

The backend will receive a request with startIndex and count params. Implement this params in your data query to only return this range of records.

For a detailed explanation of the lazy load functionality, please read our guide. There is also a new demo that uses an express.js backend to demonstrate the functionality.

Column Header Widgets

This feature allows you to embed widgets directly in the Column headers. You can place buttons, combos, and other widgets in your column headers.

Here is a basic example of how headerWidgets can be used:

new Grid({
    columns : [
        {
            field         : 'status',
            text          : 'Status',
            headerWidgets : [
                {
                    type    : 'button',
                    text    : 'Completed Status Only',
                    icon    : 'b-fa b-fa-filter',
                    onClick : function() {
                        this.store.filter({
                            property : 'status',
                            value    : 'Completed',
                            operator : '='
                        });
                    }
                }
            ]
        }
    ]
});

You can also check our Javascript demo or our React demo which use headerWidgets.

Date Range Field

A new field type for editing a range of dates, for example when booking flights or hotel reservations. The new widget makes it easy to add an intuitive user interface for entering a start and end date, with support for customizable rendering of calendar dates.

    items : [{
        type  : 'daterangefield',
        label : 'Date range'
    }]
//<code-header> CSSHelper.insertRule([ '.date-range-field-demo .b-date-picker-cell-inner { padding:0.5em 0.75em 1em 0.75em; }', '.date-range-field-demo .b-date-picker-cell-payload { margin-bottom:4px; }', '.b-calendar-panel-cell:not(.b-active-date) .b-date-picker-cell-payload { opacity:0.65; }', '.b-date-picker-cell-payload { font-size: 0.6em; white-space: nowrap; }', '.therm { padding-inline-end : .5em;; }' ], targetElement.getRootNode()); //</code-header> const picker = new DateRangeField({ appendTo : targetElement, autoExpand : true, value : ['today', 'today'], picker : { align : { align : 't-b50' }, cls : 'date-range-field-demo', datePickerDefaults : { shadePastDates : true }, cellRenderer : ({ innerCell, cellPayload, date }) => { const CF = /US|LR|MM/i.test(new Intl.Locale(navigator.languages[0]).region) ? 'F' : 'C', // US/Liberia/Myanmar convert = (CF === 'F') ? t => t : t => Math.floor((t - 32) * 5 / 9), therm = v => Math.min(4, Math.max(0, Math.floor((v - 30) / 15))), hiF = date % 31 + 47, // pseudo-random temperature loF = hiF - date % 29 - 11, hi = `${convert(hiF)} °${CF}`, lo = `${convert(loF)} °${CF}`; cellPayload.innerHTML = hi; innerCell.dataset.btip = `<div><span class="therm fa-thermometer-${therm(hiF)}"></span>High: ${hi}</div>` + `<div><span class="therm fa-thermometer-${therm(loF)}"></span>Low: ${lo}</div>`; } }, listeners : { change : ({ value }) => { Toast.show(`You picked ${DateHelper.format(value[0], 'MMM DD')} to ${DateHelper.format(value[1], 'MMM DD')}`); } } });

Grid v6.1.0

Remote paging, sorting and filtering added to Store

The ability to sort, filter and paginate records remotely was previously only available when using an AjaxStore configured with a readUrl. Now, it is possible to use the built-in functionality and UI controls when using a regular Store.

3 new configs has been added to Store:

Turning any of these functions on is a simple matter of setting the config to true, and then implementing a way of providing the correct data to the Store. For that, the requestData function needs to be implemented. Here is a simple example using paging, sorting and filtering:

const store = new Store({
   remotePaging : true, 
   remoteFilter : true,
   remoteSort   : true,
   requestData({ filters, sorters, page, pageSize }){
      let filteredRecords = [...allRecords];

      filters?.forEach(filter => {
         const { field, operator, value, caseSensitive } = filter;

         if(operator === '='){
             filteredRecords = filteredRecords.filter(r => r[field] === value);
         }
         else {
             /// ... implement other filter operators
         }
      });

      sorters?.forEach(sorter => filteredRecords.sort((a,b) => {
         const { field, ascending } = sorter;

         if (!ascending) {
             ([b, a] = [a, b]);
         }

         return a[field] > b[field] ? 1 : (a[field] < b[field] ? -1 : 0)
      }));

      const start = (page - 1) * pageSize;
      const data = filteredRecords.splice(start, start + pageSize);

      return {
         data,
         total : filteredRecords.length
      }

   }
});

For a more detailed explanation please see the updated Store guide. There is also a new demo that demonstrates the new functionality.

Calculated record fields

The Model class now supports fields that are calculated based on other fields in the model. This is done by adding a calculate property to the field definition. Here is an example:

class MyModel extends Model {
    static fields = [
        { name : 'name', type : 'string' },
        { name : 'age', type : 'number' },
        { name : 'isAdult', calculate : data => data.age >= 18 }
    ];
}

Fields used in the calculate function will when possible be automatically added as dependencies, so that the calculated field is updated whenever the dependent fields change. For complex calculations, it is also possible to specify the fields that the calculation depends on:

class MyModel extends Model {
    static fields = [
        { name : 'name', type : 'string' },
        { name : 'age', type : 'number' },
        { name : 'isAdult', calculate : { dependsOn : ['age'], fn : data => data.age >= 18 } }
    ];
}

Data transformation configs

AjaxStore now has a set of callbacks that lets you transform data being passed to or received from the backend:

  • transformLoadedData: Transforms data from the backend before loading it into the store.
  • transformCreationData: Transforms creation requests before sending to the backend.
  • transformModificationData: Transforms modification data before sending to the backend.
  • transformRemovalData: Transforms removal data before sending to the backend.

Please see the API docs for each config for more information.

Summaries for TreeGroup feature

You can now show summaries in each TreeGroup parent row by configuring columns with a sum.

new Grid({
    features : { treeGroup : true },
    columns : [
        {
            text  : 'Name',
            field : 'name',
            flex  : 3,
            type  : 'tree'
        },
        {
            type  : 'number',
            text  : 'Capacity',
            field : 'capacity',
            flex  : 1,
            sum   : 'add'
        },
        {
            type  : 'number',
            text  : 'Crew',
            field : 'crew',
            flex  : 1,
            sum   : 'add'
        }
    ]
});
new Grid({ appendTo : targetElement, features : { tree : true, treeGroup : { levels : [ 'airline' ] } }, autoHeight : true, columns : [ { type : 'tree', text : 'Airline', field : 'airline', flex : 1 }, { text : 'Flight', field : 'flight', flex : 1 }, { type : 'number', text : 'Capacity', field : 'capacity', flex : 1, sum : 'add' }, { type : 'number', text : 'Crew', field : 'crew', flex : 1, sum : 'add' } ], data : [ { airline : 'Delta', flight : 'DL123', capacity : 180, crew : 8 }, { airline : 'American Airlines', flight : 'AA456', capacity : 200, crew : 9 }, { airline : 'United Airlines', flight : 'UA789', capacity : 210, crew : 10 }, { airline : 'United Airlines', flight : 'UA101', capacity : 175, crew : 7 }, { airline : 'British Airways', flight : 'BA234', capacity : 250, crew : 12 }, { airline : 'Air France', flight : 'AF567', capacity : 220, crew : 11 }, { airline : 'Air France', flight : 'AF569', capacity : 240, crew : 12 }, { airline : 'Qantas', flight : 'QF672', capacity : 300, crew : 15 }, { airline : 'Qantas', flight : 'QF678', capacity : 260, crew : 13 }, { airline : 'Singapore Airlines', flight : 'SQ901', capacity : 280, crew : 14 } ] });

Excel export improvements

We replaced zipcelx library with more flexible write-excel-file which supports cell styling and formatting. We improved API to allow using any other library. We still ship older version of zipcelx as a fallback. Grid demo has been updated to show how to style cells.

Grid v6.1.2

New way of chaining tree stores

Store has a new better way of chaining tree stores. By using chainTree() instead of chain(), the chained store will be a full tree store with links to the nodes in the original store. This will let you expand / collapse and filter the tree in one store without affecting the other.

const originalStore = new Store({
    tree : true,
    data : [
        { 
            id : 1, 
            name : 'Parent', 
            children : [
                { id : 11, name : 'Child' }
            ]
        }
    ]
});

const chainedStore = originalStore.chainTree();

chainedStore in the snippet above will have the same structure as originalStore. But the nodes in it will be links to the nodes in originalStore. CRUD operation to the chained store will affect the original store, but UI operations such as expand / collapse and filtering will only affect the chained store (and vice versa).

Grid v6.1.4

Introducing thin trial NPM packages

Starting with the 6.1.4 release, there are now trial versions of thin NPM packages available on our NPM server. These can be used to evaluate combining multiple products before purchasing a license.

Please check the combining multiple products guides for the trial packages list and installation tips:

Grid v6.1.6

WebComponent support in CellEditing

In this release we have added support to change the input field tag name of a column's cell editor. This is especially useful when you want an Angular component as an editor. Please be advised that this component must fulfill the API of a regular input field, for example getters and setters for value, and also select and focus functions. Also, this tag must be a component defined as a Custom element.

const grid = new Grid({
    columns : [
        {
            field  : 'name',
            editor : {
                // Tag name for a registered WebComponent
                inputTag : 'custom-input'
            }
        },
        { field : 'city' }
    ]
});

Please take a look at this new Angular demo, which exemplifies the implementation in detail.

Grid v6.1.7

New scrollAction config in CellEditing

The scrollAction config specifies what action should be taken if an active cell editor is scrolled out of view.

It may be set to 'cancel', 'complete' or null.

The default value is null which means the edit is preserved.

Floating cell editor

The CellEdit feature now supports rendering the editor as a floating element outside the cell element. Set the cellEditor config to an object with a floating property set to true.

new Grid({
   columns : [
       {
           field : 'name',
           text : 'Name',
           editor : {
               type : 'my-custom-field'
           },
           cellEditor : {
               floating : true
           }
       }
   ] 
});

Grid v6.1.8

rtl config of Widget

The rtl config property of Widgets is now public. By default Widgets conform to the writing direction of the element they are rendered to. But with the now public rtl property, you can force a widget, and all descendant widgets to lay out from right to left.

Grid v6.2.0

TabPanel now supports adding custom widgets to the tabBar

The TabBar is a subclass of the Toolbar, meaning you can add additional widgets to it. You can add either tab-specific items only shown for the active tab, or you can add static extra items that are shown for all tabs.

new TabPanel({
    appendTo : targetElement,
    height   : '25em',

    onAddTabClick({ value }) {
        this.add({
            type  : 'panel',
            title : 'New tab',
            items : [
                {
                    type : 'button',
                    text : 'Click me',
                    onClick() {
                        Toast.show('Awesome!');
                    }
                }
            ]
        });
    },

    tabBar : {
        items : [
            {
                type    : 'button',
                text    : 'Add tab',
                onClick : 'up.onAddTabClick'
            }
        ]
    },

    items : {
        main : { ... },
        secondary : { ... }
    }
});
new TabPanel({ appendTo : targetElement, height : '25em', onRequirementSearchFieldChange({ value }) { this.activeItem.store.filter('name', value); }, onAddWorkOrder() { this.activeItem.store.add({ workOrderName : 'New work order' }); }, onRequirementsReload() { this.activeItem.mask('Fake reload...'); this.setTimeout(() => this.activeItem.unmask(), 2000); }, items : { main : { type : 'grid', title : 'Open Requirements', features : { sort : 'name', stripe : true }, tabBarItems : [ { type : 'textfield', placeholder : 'Search', field : 'name', clearable : true, height : '2.5em', onChange : 'up.onRequirementSearchFieldChange', keyStrokeChangeDelay : 300 }, { type : 'button', cls : 'b-transparent', icon : 'fa fa-rotate', onClick : 'up.onRequirementsReload' } ], columns : [ { text : 'Name', field : 'name', width : 200 }, { type : 'date', text : 'From Date', field : 'fromDate', width : 120 }, { type : 'date', text : 'To Date', field : 'toDate', width : 120 }, { text : 'Priority', field : 'priority', width : 100, htmlEncode : false, renderer : ({ value }) => { return `<span></span>${value}`; } }, { text : 'Duration', field : 'duration', width : 100 }, { text : 'Proposed Duration', field : 'proposedDuration', width : 150 }, { text : 'Fulfilled Duration', field : 'fulfilledDuration', width : 150 }, { text : 'Remaining Duration', field : 'remainingDuration', width : 150 }, { text : 'Territory', field : 'territory', width : 150 }, { text : 'Time From Promised', field : 'timeFromPromised', width : 150 }, { text : 'Time To Promised', field : 'timeToPromised', width : 150 } ], store : { fields : [ { name : 'fromDate', type : 'date' }, { name : 'toDate', type : 'date' }, { name : 'duration' }, { name : 'proposedDuration' }, { name : 'fulfilledDuration' }, { name : 'remainingDuration' }, { name : 'priority' }, { name : 'territory' }, { name : 'timeFromPromised' }, { name : 'timeToPromised' } ], data : [ { id : 1, name : 'TAB1 - Cable Burial', fromDate : '2024-09-12 10:00', toDate : '2024-09-12 10:30', duration : '30 mins', proposedDuration : '0 mins', fulfilledDuration : '0 mins', remainingDuration : '30 mins', priority : 'Critical', territory : 'Borders East', timeFromPromised : '5 mins', timeToPromised : '0 mins' }, { id : 2, name : 'SUB2 - Subsea cable', fromDate : '2024-09-12 09:00', toDate : '2024-09-12 10:00', duration : '1 hr', proposedDuration : '0 mins', fulfilledDuration : '0 mins', remainingDuration : '1 hr', priority : 'Critical', territory : 'Bedfordshire', timeFromPromised : '0 mins', timeToPromised : '15 mins' }, { id : 3, name : 'TOW43 - Cell Tower', fromDate : '2024-09-12 08:15', toDate : '2024-09-12 09:00', duration : '45 mins', proposedDuration : '0 mins', fulfilledDuration : '0 mins', remainingDuration : '45 mins', priority : 'Low', territory : 'Berkshire West', timeFromPromised : '10 mins', timeToPromised : '5 mins' } ] } }, secondary : { type : 'grid', title : 'Unscheduled Work Orders', features : { sort : 'workOrderName', stripe : true }, tabBarItems : [ { type : 'button', text : 'Add work order', onClick : 'up.onAddWorkOrder' } ], columns : [ { text : 'Work Order Name', field : 'workOrderName', width : 200 }, { text : 'Job Type', field : 'jobType', width : 150 }, { text : 'Assigned Crew', field : 'assignedCrew', width : 150 }, { text : 'Estimated Duration', field : 'estimatedDuration', width : 150 }, { text : 'Priority', field : 'priority', width : 100 }, { text : 'Location', field : 'location', width : 200 }, { text : 'Status', field : 'status', width : 120 } ], store : { fields : [ { name : 'workOrderName' }, { name : 'jobType' }, { name : 'assignedCrew' }, { name : 'estimatedDuration' }, { name : 'priority' }, { name : 'location' }, { name : 'status' } ], data : [ { id : 1, workOrderName : 'WO123 - Underground Cabling', jobType : 'Cable Installation', assignedCrew : 'Crew A', estimatedDuration : '3 hours', priority : 'Critical', location : 'Borders East', status : 'Pending' }, { id : 2, workOrderName : 'WO124 - Fiber Optic Laying', jobType : 'Fiber Optic', assignedCrew : 'Crew B', estimatedDuration : '2 hours', priority : 'Low', location : 'Bedfordshire', status : 'Pending' }, { id : 3, workOrderName : 'WO125 - Electrical Wiring', jobType : 'Electrical Work', assignedCrew : 'Crew C', estimatedDuration : '1.5 hours', priority : 'Critical', location : 'Berkshire West', status : 'Scheduled' } ] } } } });

Reordering tabs in a TabPanel

TabPanel now also supports reordering tabs using drag-drop. This is enabled by setting the enableReordering property to true on the tabBar config:

new TabPanel({
    tabBar : {
        enableReordering : true
    },

    items : [ ... ]
})
new TabPanel({ appendTo : targetElement, height : '25em', tabBar : { enableReordering : true }, items : { main : { title : 'Tab with widgets', tab : { icon : 'fa fa-puzzle-piece' }, style : 'padding : 1em', items : { forename : { type : 'text', label : 'First name' }, surname : { type : 'text', label : 'Last name' } } }, secondary : { title : 'Tab with basic HTML', tab : { icon : 'fa fa-code' }, items : { infoWidget : { type : 'widget', style : 'padding: 1em', html : 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.' } } }, tertiary : { title : 'Images', style : 'gap: 3em; padding: 4em; display: grid; grid-template-columns: repeat(3, 1fr)', tab : { icon : 'fa fa-image' }, defaults : { flex : 1, type : 'widget', tag : 'img', style : 'width: 186px; height: 140px' }, items : { gantt : { elementAttributes : { src : 'data/Core/images/thumb/gantt.png' } }, calendar : { elementAttributes : { src : 'data/Core/images/thumb/calendar.png' } }, taskboard : { elementAttributes : { src : 'data/Core/images/thumb/taskboard.png' } } } } } });

Introducing a MonthPicker widget

The month dropdown previously used in DatePicker and related widgets has been replaced by a modernized MonthPicker widget, which is visually consistent with the existing YearPicker.

const picker = new MonthPicker({
    appendTo  : targetElement,
    width     : '24em',
    listeners : {
        select : ({ value }) => {
            Toast.show(`You picked ${value}`);
        },
        titleClick : () => Toast.show('You clicked the title bar')
    }
});
const picker = new MonthPicker({ appendTo : targetElement, width : '24em', listeners : { select : ({ value }) => { Toast.show(`You picked ${value}`); }, titleClick : () => Toast.show('You clicked the title bar') } });
const picker = new DatePicker({ appendTo : targetElement, width : '24em', date : DateHelper.add(new Date(), '1d'), onSelectionChange : ({ selection }) => { Toast.show(`You picked ${DateHelper.format(selection[0], 'MMM DD')}`); } });

Grid cells now use ellipsis for overflowing text

Grid cells with overflowing text now use ellipsis by default. If you prefer the old behavior where text is cropped, set Grid's cellEllipsis property to false:

const grid = new Grid({
    cellEllipsis : false
});

New RowEdit feature

A new feature, RowEdit, allows editing of a whole record row at one time using a side docked editor panel.

This feature is disabled by default, and if enabled on a Grid, the CellEdit feature must explicitly be disabled:

    features : {
        cellEdit : false,
        rowEdit  : true
    }

The following settings are common to both RowEdit and CellEdit:

  • triggerEvent
  • autoEdit
  • continueEditingOnCellClick
  • ignoreCSSSelector
// grid with row editing const grid = new Grid({ appendTo : targetElement, height : 600, features : { // cellEditing is enabled by default, so this is necessary cellEdit : false, rowEdit : { // Dock the editor into the grid's element, not the browser viewport local : true } }, showDirty : true, tbar : { items : { instantUpdate : { type : 'checkbox', text : 'Instant Update', tooltip : 'Update record instantly after editing', value : false, onChange : ({ checked }) => { grid.features.rowEdit.instantUpdate = checked; } } } }, data : DataGenerator.generateData(5), columns : [ // basic columns has a TextField as editor by default { field : 'name', text : 'Name', flex : 1, // Invoked on final edit of input field, typically after pressing enter or blurring the field. finalizeCellEdit : ({ value }) => { // returning true will accept the new value otherwise it shows the return statement as error message return value.trim().length < 5 ? 'Name should be at least 5 characters' : true; } }, // a custom editor can be specified { field : 'city', text : 'City', flex : 1, editor : { type : 'combo', items : ['Stockholm', 'New York', 'Montreal'] } }, // column types may specify an editor // NumberColumn for example uses a NumberField { type : 'number', field : 'score', text : 'Score', flex : 1, finalizeCellEdit : ({ value, record }) => { // record contains sibling column's data const { city } = record; // Perform validation based on a sibling column if (city === 'Paris' && value > 999) { return "Score can't be higher than 999 for Paris"; } return true; } }, // specify editor: false to make a column "readonly" { type : 'number', field : 'age', text : 'Age (readonly)', flex : 1, readOnly : true } ] });

Panel drawer config.

The new drawer config marks a Panel as a docked, slide-in panel which by default slides in over the contents of the browser viewport.

new Panel({
    drawer :  true
});

Charts feature

The new Charts feature adds a pop-up chart designer connected to the Grid, for creating line, bar, pie, and other types of charts from selected data in the grid.

new Grid({
    features : { charts : true },
    // ...
});

With the feature enabled, make a selection of cells in the grid and then activate the cell context menu and choose New Chart to open the chart designer.

Check out the Charts example to see the new feature in action.

Chart column

The new ChartColumn supports displaying a chart inside a grid column. You can provide a chart config and supply data records for the chart in the specified field on your primary grid data record:

new Grid({
    columns : [
        {
             type: 'chart',
             chart : {
                 chartType : 'line',
                 series : [{
                     field : 'price'
                 },{
                     field : 'changePct'
                 }],
                 labels : {
                     field : 'symbol'
                 }
             }
        }
    ],
    // ...
});

Sparkline column

In addition to ChartColumn mentioned above, there is a new SparklineColumn specifically designed for sparklines (micro-charts that show general trends in a data series). This column type offers a simple API for this use-case. Data values are provided as arrays of numbers on your primary grid record. Line, bar, and pie chart types are supported.

const grid = new Grid({
    columns    : [
        {
            type  : 'sparkline',
            field : 'monthlySales'  // field values should be arrays of numbers
        }
    ],
    // ...
});

Grid v6.3.0

New Chart module

Bryntum products now ship with a new Chart module, which makes it easier to integrate Chart.js-based charts (see www.chartjs.org) with our products. The new module can be combined with all our products, by using the thin bundles / packages.

Building upon the new module, Grid now has a Charts feature, which leverages the new Chart and ChartDesigner widgets to enable developers and end users to easily create charts and visualize data directly from selected cells in a Grid.

Grid also has new columns that can show charts: SparklineColumn and ChartColumn.

Try the new feature out in the new charts demo, and see how to use the new SparklineColumn in the sparklines demo.

Using charts with your applications:

If you are using a vanilla JavaScript application, you need to import the chart thin bundle to enable support for the Charts feature:

import 'PATH_TO_DISTRIBUTION_FOLDER/build/thin/chart.module.thin.js';

For framework-based applications, the Bryntum npm repository provides the @bryntum/chart-thin package, along with chart component wrapper packages for major frameworks:

Angular:

import '@bryntum/chart-thin'
import { BryntumChart } from '@bryntum/chart-angular-thin'

React:

import '@bryntum/chart-thin'
import { BryntumChart } from '@bryntum/chart-react-thin'

Vue 3:

import '@bryntum/chart-thin'
import { BryntumChart } from '@bryntum/chart-vue-3-thin'

Streamed PDF export

The PdfExport feature now supports exporting large datasets by streaming data to the export server using a WebSocket connection. This allows for more efficient handling of large datasets during export operations, improving performance and reducing memory usage

Contents