|
1 | 1 | import type { CommandResult } from '@ts/grids/grid_core/ai_assistant/types'; |
| 2 | +import type { InternalGrid, RowKey } from '@ts/grids/grid_core/m_types'; |
2 | 3 | import { z } from 'zod'; |
3 | 4 |
|
4 | 5 | import { defineGridCommand } from './defineGridCommand'; |
5 | 6 | import { |
6 | | - compositeKeyPairSchema, isKeyShapeValid, normalizeKey, |
| 7 | + compositeKeyPairSchema, |
| 8 | + isKeyShapeValid, |
| 9 | + normalizeKey, |
| 10 | + splitIntoLoadWindows, |
7 | 11 | } from './utils'; |
8 | 12 |
|
| 13 | +const MAX_LOAD_WINDOW_PAGES = 5; |
| 14 | + |
9 | 15 | const selectByKeysCommandSchema = z.object({ |
10 | 16 | keys: z.array(z.union([ |
11 | 17 | z.string(), |
@@ -45,51 +51,167 @@ export const selectByKeysCommand = defineGridCommand({ |
45 | 51 | }, |
46 | 52 | }); |
47 | 53 |
|
48 | | -const selectByIndexesCommandSchema = z.object({ |
| 54 | +const selectionByIndexesCommandSchema = z.object({ |
49 | 55 | indexes: z.array(z.number().int().min(1)).min(1), |
50 | 56 | mode: z.enum(['select', 'deselect']), |
| 57 | + scope: z.enum(['allPages', 'page']), |
51 | 58 | }).strict(); |
52 | 59 |
|
53 | | -export const selectByIndexesCommand = defineGridCommand({ |
54 | | - name: 'selectByIndexes', |
55 | | - description: 'Select or deselect specific rows by their 1-based indexes within the current page. ' |
56 | | - + 'Index 1 is the first row on the visible page; group/header rows are not addressable. ' |
57 | | - + 'Set mode to "deselect" to remove the listed rows from the current selection (e.g. "unselect row 1"); set it to "select" to select them. ' |
58 | | - + 'When mode is "select", the listed rows replace the current selection. ' |
59 | | - + 'To target rows that are not on the current page, use selectByKeys, or call pageIndex first to switch the page. ' |
60 | | - + 'To clear selection only within the current selectAll scope, use deselectAll; to clear selection across all pages regardless of selectAllMode, use clearSelection.', |
61 | | - schema: selectByIndexesCommandSchema, |
| 60 | +// Maps 1-based indexes to the keys at those positions; |
| 61 | +// an index past the last entry rejects the whole set. |
| 62 | +const pickKeysByIndex = ( |
| 63 | + keys: RowKey[], |
| 64 | + indexes: number[], |
| 65 | +): RowKey[] | null => { |
| 66 | + const normalizedRowIndexes = indexes.map((index) => index - 1); |
| 67 | + const allIndexesValid = normalizedRowIndexes.every( |
| 68 | + (index) => index < keys.length, |
| 69 | + ); |
| 70 | + |
| 71 | + if (!allIndexesValid) { |
| 72 | + return null; |
| 73 | + } |
| 74 | + |
| 75 | + return normalizedRowIndexes.map((index) => keys[index]); |
| 76 | +}; |
| 77 | + |
| 78 | +const resolveKeysFromCurrentPage = ( |
| 79 | + component: InternalGrid, |
| 80 | + indexes: number[], |
| 81 | +): RowKey[] | null => { |
| 82 | + // Group/footer rows are not counted, so indexes address the Nth data row. |
| 83 | + const items = component.getController('data').items(); |
| 84 | + const dataItems = items.filter((item) => item.rowType === 'data'); |
| 85 | + const dataKeys = dataItems.map((item) => item.key); |
| 86 | + |
| 87 | + return pickKeysByIndex(dataKeys, indexes); |
| 88 | +}; |
| 89 | + |
| 90 | +const resolveKeysFromAllPagesRemote = async ( |
| 91 | + component: InternalGrid, |
| 92 | + indexes: number[], |
| 93 | +): Promise<RowKey[] | null> => { |
| 94 | + const dataSource = component.getDataSource(); |
| 95 | + const store = dataSource?.store(); |
| 96 | + |
| 97 | + if (!dataSource || !store) { |
| 98 | + return null; |
| 99 | + } |
| 100 | + |
| 101 | + const keyExpr = component.option('keyExpr') ?? store.key(); |
| 102 | + |
| 103 | + if (!keyExpr) { |
| 104 | + return null; |
| 105 | + } |
| 106 | + |
| 107 | + // Under grouping store.load returns group structures rather than flat rows, |
| 108 | + // so an index no longer maps to a single data row. Fail instead of resolving meaningless keys |
| 109 | + const grouping = dataSource.group(); |
| 110 | + const isGrouped = Array.isArray(grouping) ? grouping.length > 0 : !!grouping; |
| 111 | + |
| 112 | + if (isGrouped) { |
| 113 | + return null; |
| 114 | + } |
| 115 | + |
| 116 | + const dataController = component.getController('data'); |
| 117 | + const filter = dataController.getCombinedFilter(true); |
| 118 | + const baseLoadOptions = { |
| 119 | + ...dataSource.loadOptions(), |
| 120 | + filter, |
| 121 | + }; |
| 122 | + |
| 123 | + const windows = splitIntoLoadWindows(indexes, dataSource.pageSize() * MAX_LOAD_WINDOW_PAGES); |
| 124 | + |
| 125 | + const loadedWindows = await Promise.all(windows.map((window) => { |
| 126 | + const skip = window[0] - 1; |
| 127 | + const take = window[window.length - 1] - window[0] + 1; |
| 128 | + return store.load({ ...baseLoadOptions, skip, take }) |
| 129 | + .then((result) => { |
| 130 | + const rows = Array.isArray(result) ? result : (result as { data: unknown[] }).data; |
| 131 | + return { window, rows }; |
| 132 | + }); |
| 133 | + })); |
| 134 | + |
| 135 | + const keys: RowKey[] = []; |
| 136 | + |
| 137 | + for (const { window, rows } of loadedWindows) { |
| 138 | + if (!Array.isArray(rows)) { |
| 139 | + return null; |
| 140 | + } |
| 141 | + |
| 142 | + for (const index of window) { |
| 143 | + // The requested index maps to `window[0]` offset within the loaded rows |
| 144 | + const row = rows[index - window[0]]; |
| 145 | + |
| 146 | + if (row === undefined) { |
| 147 | + return null; |
| 148 | + } |
| 149 | + |
| 150 | + keys.push(store.keyOf(row)); |
| 151 | + } |
| 152 | + } |
| 153 | + |
| 154 | + return keys; |
| 155 | +}; |
| 156 | + |
| 157 | +const resolveKeysFromAllPagesLocal = async ( |
| 158 | + component: InternalGrid, |
| 159 | + indexes: number[], |
| 160 | +): Promise<RowKey[] | null> => { |
| 161 | + const keys = await component.getController('data').getAllDataRowKeys(); |
| 162 | + |
| 163 | + return pickKeysByIndex(keys, indexes); |
| 164 | +}; |
| 165 | + |
| 166 | +// Picks the "allPages" strategy by paging mode: |
| 167 | +// with local paging the full dataset is already on the client, so read the cache; |
| 168 | +// with remote paging rows are fetched by position. |
| 169 | +const resolveKeysFromAllPages = ( |
| 170 | + component: InternalGrid, |
| 171 | + indexes: number[], |
| 172 | +): Promise<RowKey[] | null> => { |
| 173 | + const dataController = component.getController('data'); |
| 174 | + const isRemotePaging = !!dataController.dataSource()?.remoteOperations()?.paging; |
| 175 | + |
| 176 | + return isRemotePaging |
| 177 | + ? resolveKeysFromAllPagesRemote(component, indexes) |
| 178 | + : resolveKeysFromAllPagesLocal(component, indexes); |
| 179 | +}; |
| 180 | + |
| 181 | +export const selectionByIndexesCommand = defineGridCommand({ |
| 182 | + name: 'selectionByIndexes', |
| 183 | + description: 'Select or deselect rows by their 1-based indexes. ' |
| 184 | + + 'Indexes start at 1: "the first row" is index 1 and "the 5th row" is index 5. Do NOT use 0-based counting here, this command is 1-based. ' |
| 185 | + + 'Always set scope to choose how indexes are interpreted: ' |
| 186 | + + '"allPages" — indexes are positions within the currently filtered and sorted dataset, NOT limited to the current page; index 1 is the first row of the dataset, regardless of pageIndex/pageSize. Use this when the user does NOT explicitly refer to the visible page (e.g. "select rows 1 to 100"). ' |
| 187 | + + '"page" — indexes are positions within the currently rendered page; index 1 is the first data row on the visible page and group/header rows are not counted. Use this ONLY when the user explicitly mentions the current/visible page (e.g. "select the first 3 rows on the current page", "deselect row 2 on this page"). ' |
| 188 | + + 'Set mode to "select" to add the listed rows to the current selection (multiple calls accumulate, so previously selected ranges are kept); set mode to "deselect" to remove the listed rows from the current selection (e.g. "unselect row 1"). ' |
| 189 | + + 'To clear selection only within the current selectAll scope, use deselectAll; to clear selection across all pages regardless of selectAllMode, use clearSelection. ' |
| 190 | + + 'To target rows by key value rather than by index, use selectByKeys.', |
| 191 | + schema: selectionByIndexesCommandSchema, |
62 | 192 | execute: (component, { success, failure }) => async (args): Promise<CommandResult> => { |
63 | 193 | const rowIndexes = args.indexes.join(', '); |
64 | 194 | const action = args.mode === 'deselect' ? 'Deselect' : 'Select'; |
65 | | - const defaultMessage = `${action} row(s) number ${rowIndexes} on the current page.`; |
| 195 | + const scopeSuffix = args.scope === 'page' ? ' on the current page' : ''; |
| 196 | + const defaultMessage = `${action} row(s) number ${rowIndexes}${scopeSuffix}.`; |
66 | 197 |
|
67 | 198 | if (component.option('selection.mode') === 'none') { |
68 | 199 | return failure(defaultMessage); |
69 | 200 | } |
70 | 201 |
|
71 | | - const items = component.getController('data').items(); |
72 | | - const normalizedRowIndexes = args.indexes.map((index) => index - 1); |
73 | | - const allIndexesValid = normalizedRowIndexes.every( |
74 | | - (index) => items[index]?.rowType === 'data', |
75 | | - ); |
| 202 | + try { |
| 203 | + const keys = args.scope === 'page' |
| 204 | + ? resolveKeysFromCurrentPage(component, args.indexes) |
| 205 | + : await resolveKeysFromAllPages(component, args.indexes); |
76 | 206 |
|
77 | | - if (!allIndexesValid) { |
78 | | - return failure(defaultMessage); |
79 | | - } |
| 207 | + if (keys === null) { |
| 208 | + return failure(defaultMessage); |
| 209 | + } |
80 | 210 |
|
81 | | - try { |
82 | | - switch (args.mode) { |
83 | | - case 'deselect': { |
84 | | - const itemKeys = normalizedRowIndexes.map((index) => items[index].key); |
85 | | - await component.deselectRows(itemKeys); |
86 | | - break; |
87 | | - } |
88 | | - case 'select': |
89 | | - await component.selectRowsByIndexes(normalizedRowIndexes); |
90 | | - break; |
91 | | - default: |
92 | | - return failure(defaultMessage); |
| 211 | + if (args.mode === 'deselect') { |
| 212 | + await component.deselectRows(keys); |
| 213 | + } else { |
| 214 | + await component.selectRows(keys, true); |
93 | 215 | } |
94 | 216 |
|
95 | 217 | return success(defaultMessage); |
|
0 commit comments