create-emsgrid 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.husky/pre-commit +1 -0
- package/.prettierignore +3 -0
- package/.prettierrc +6 -0
- package/README.md +33 -0
- package/eslint.config.js +44 -0
- package/index.html +12 -0
- package/package.json +67 -0
- package/plan.md +51 -0
- package/src/App.tsx +230 -0
- package/src/components/Grid/core/Grid.tsx +151 -0
- package/src/components/Grid/core/createTable.ts +152 -0
- package/src/components/Grid/core/settings.ts +5 -0
- package/src/components/Grid/core/types.ts +56 -0
- package/src/components/Grid/features/columns/useColumnReorder.ts +48 -0
- package/src/components/Grid/features/contextMenu/index.ts +1 -0
- package/src/components/Grid/features/export/exportXlsx.ts +55 -0
- package/src/components/Grid/features/filtering/FilterInput.tsx +34 -0
- package/src/components/Grid/features/pagination/index.ts +1 -0
- package/src/components/Grid/features/selection/index.ts +1 -0
- package/src/components/Grid/features/sorting/SortIndicator.tsx +11 -0
- package/src/components/Grid/features/toolbar/index.ts +1 -0
- package/src/components/Grid/features/tree/buildParentTree.ts +38 -0
- package/src/components/Grid/features/tree/index.ts +1 -0
- package/src/components/Grid/features/virtualization/useRowVirtualizer.ts +40 -0
- package/src/components/Grid/ui/Cell.tsx +93 -0
- package/src/components/Grid/ui/ContextMenu.tsx +43 -0
- package/src/components/Grid/ui/Header.tsx +68 -0
- package/src/components/Grid/ui/Pagination.tsx +65 -0
- package/src/components/Grid/ui/Panels/ColumnsPanel.tsx +3 -0
- package/src/components/Grid/ui/Panels/FiltersPanel.tsx +3 -0
- package/src/components/Grid/ui/Panels/GroupingPanel.tsx +3 -0
- package/src/components/Grid/ui/Row.tsx +50 -0
- package/src/components/Grid/ui/columns/SelectColumn.tsx +54 -0
- package/src/components/Grid/ui/index.ts +4 -0
- package/src/main.tsx +14 -0
- package/src/mocks/people.ts +34 -0
- package/src/store/gridApi.ts +76 -0
- package/src/store/store.ts +12 -0
- package/src/styles.css +259 -0
- package/tsconfig.json +24 -0
- package/vite.config.ts +13 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
yarn lint-staged
|
package/.prettierignore
ADDED
package/.prettierrc
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# EMS Grid
|
|
2
|
+
|
|
3
|
+
React grid demo with TanStack Table, virtualization, grouping, and exports.
|
|
4
|
+
|
|
5
|
+
## Run locally
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
yarn
|
|
9
|
+
yarn dev
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Create a new project
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
yarn create emsgrid my-app
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Publish
|
|
19
|
+
|
|
20
|
+
Package:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
npm login
|
|
24
|
+
npm publish --access public
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Creator:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
cd create-emsgrid
|
|
31
|
+
npm login
|
|
32
|
+
npm publish --access public
|
|
33
|
+
```
|
package/eslint.config.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import js from '@eslint/js';
|
|
2
|
+
import tseslint from '@typescript-eslint/eslint-plugin';
|
|
3
|
+
import tsParser from '@typescript-eslint/parser';
|
|
4
|
+
import react from 'eslint-plugin-react';
|
|
5
|
+
import reactHooks from 'eslint-plugin-react-hooks';
|
|
6
|
+
import reactRefresh from 'eslint-plugin-react-refresh';
|
|
7
|
+
import globals from 'globals';
|
|
8
|
+
|
|
9
|
+
export default [
|
|
10
|
+
{ ignores: ['dist', 'node_modules'] },
|
|
11
|
+
js.configs.recommended,
|
|
12
|
+
{
|
|
13
|
+
files: ['**/*.{ts,tsx}'],
|
|
14
|
+
languageOptions: {
|
|
15
|
+
parser: tsParser,
|
|
16
|
+
parserOptions: {
|
|
17
|
+
ecmaVersion: 'latest',
|
|
18
|
+
sourceType: 'module',
|
|
19
|
+
ecmaFeatures: { jsx: true },
|
|
20
|
+
},
|
|
21
|
+
globals: {
|
|
22
|
+
...globals.browser,
|
|
23
|
+
...globals.es2021,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
settings: { react: { version: 'detect' } },
|
|
27
|
+
plugins: {
|
|
28
|
+
'@typescript-eslint': tseslint,
|
|
29
|
+
react,
|
|
30
|
+
'react-hooks': reactHooks,
|
|
31
|
+
'react-refresh': reactRefresh,
|
|
32
|
+
},
|
|
33
|
+
rules: {
|
|
34
|
+
...tseslint.configs['flat/recommended'].rules,
|
|
35
|
+
...react.configs.flat.recommended.rules,
|
|
36
|
+
...reactHooks.configs.flat.recommended.rules,
|
|
37
|
+
...reactRefresh.configs.recommended.rules,
|
|
38
|
+
'no-unused-vars': 'off',
|
|
39
|
+
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
|
40
|
+
'react-hooks/incompatible-library': 'off',
|
|
41
|
+
'react-hooks/refs': 'off',
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
];
|
package/index.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="ru">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>TanStack Grid Mini</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-emsgrid",
|
|
3
|
+
"private": false,
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Create an EMS Grid project",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"create-emsgrid": "index.js"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/Resg/table.git"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/Resg/table",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/Resg/table/issues"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"type": "module",
|
|
22
|
+
"scripts": {
|
|
23
|
+
"dev": "vite",
|
|
24
|
+
"build": "tsc -b && vite build",
|
|
25
|
+
"preview": "vite preview",
|
|
26
|
+
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
|
27
|
+
"format": "prettier --check .",
|
|
28
|
+
"format:write": "prettier --write .",
|
|
29
|
+
"lint-staged": "lint-staged",
|
|
30
|
+
"prepare": "husky"
|
|
31
|
+
},
|
|
32
|
+
"lint-staged": {
|
|
33
|
+
"*.{ts,tsx}": [
|
|
34
|
+
"eslint --fix",
|
|
35
|
+
"prettier --write"
|
|
36
|
+
],
|
|
37
|
+
"*.{js,jsx,cjs,mjs,css,md,json,yml,yaml}": [
|
|
38
|
+
"prettier --write"
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@reduxjs/toolkit": "^2.2.7",
|
|
43
|
+
"@tanstack/react-table": "^8.20.5",
|
|
44
|
+
"@tanstack/react-virtual": "^3.10.8",
|
|
45
|
+
"react": "^18.3.1",
|
|
46
|
+
"react-dom": "^18.3.1",
|
|
47
|
+
"react-redux": "^9.1.2",
|
|
48
|
+
"xlsx": "^0.18.5"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^25.0.9",
|
|
52
|
+
"@types/react": "^18.3.3",
|
|
53
|
+
"@types/react-dom": "^18.3.0",
|
|
54
|
+
"@typescript-eslint/eslint-plugin": "^8.53.1",
|
|
55
|
+
"@typescript-eslint/parser": "^8.53.1",
|
|
56
|
+
"@vitejs/plugin-react": "^4.3.1",
|
|
57
|
+
"eslint": "^9.39.2",
|
|
58
|
+
"eslint-plugin-react": "^7.37.5",
|
|
59
|
+
"eslint-plugin-react-hooks": "^7.0.1",
|
|
60
|
+
"eslint-plugin-react-refresh": "^0.4.26",
|
|
61
|
+
"husky": "^9.1.7",
|
|
62
|
+
"lint-staged": "^16.2.7",
|
|
63
|
+
"prettier": "^3.8.0",
|
|
64
|
+
"typescript": "^5.5.4",
|
|
65
|
+
"vite": "^5.4.2"
|
|
66
|
+
}
|
|
67
|
+
}
|
package/plan.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# План разработки (TanStack Grid Mini)
|
|
2
|
+
|
|
3
|
+
Цель: разработать новый компонент таблицы на базе **@tanstack/react-table** + **@tanstack/react-virtual**.
|
|
4
|
+
Проект автономный (Vite + React + TS). Данные и настройки — через RTK Query (моки).
|
|
5
|
+
|
|
6
|
+
## Итерация 0 — инфраструктура (сделано)
|
|
7
|
+
|
|
8
|
+
- Vite + React + TS
|
|
9
|
+
- Redux store
|
|
10
|
+
- RTK Query mock API (данные + настройки)
|
|
11
|
+
- Базовый Grid с виртуализацией, сортировкой, selection, ресайзом колонок
|
|
12
|
+
|
|
13
|
+
## Итерация 1 — “MVP таблицы”
|
|
14
|
+
|
|
15
|
+
- Виртуализация строк (фиксированный rowHeight)
|
|
16
|
+
- Sticky header
|
|
17
|
+
- Сортировка (client)
|
|
18
|
+
- Выбор строк (multi, checkbox)
|
|
19
|
+
- Column sizing (ресайз)
|
|
20
|
+
- Column reordering (dnd)
|
|
21
|
+
- Сохранение настроек: columnSizing, columnVisibility, sorting, rowSelection
|
|
22
|
+
|
|
23
|
+
## Итерация 2 — фильтры
|
|
24
|
+
|
|
25
|
+
- Text filter (debounce)
|
|
26
|
+
- Set/exclude filter
|
|
27
|
+
- UI для фильтров в header или popover
|
|
28
|
+
|
|
29
|
+
## Итерация 3 — дерево Parent
|
|
30
|
+
|
|
31
|
+
- Построение дерева по parentId -> subRows
|
|
32
|
+
- Expand/collapse + expand all/collapse all
|
|
33
|
+
- Selection propagation (родители/дети)
|
|
34
|
+
|
|
35
|
+
## Итерация 4 — grouping tree
|
|
36
|
+
|
|
37
|
+
- Grouping по выбранным колонкам
|
|
38
|
+
- Групповые строки + счетчики
|
|
39
|
+
|
|
40
|
+
## Итерация 5 — контекстное меню, экспорт, хоткеи
|
|
41
|
+
|
|
42
|
+
- Контекстное меню (на строке/хедере)
|
|
43
|
+
- Экспорт (CSV)
|
|
44
|
+
- Клавиатурная навигация (базовая)
|
|
45
|
+
|
|
46
|
+
## Правила производительности
|
|
47
|
+
|
|
48
|
+
- columns/data — стабилизировать (useMemo)
|
|
49
|
+
- Row/Cell — memo
|
|
50
|
+
- тяжелые вычисления (tree/group) — memo + кеши
|
|
51
|
+
- overscan 10–20, rowHeight фиксированный по умолчанию
|
package/src/App.tsx
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { ColumnDef, Table } from '@tanstack/react-table';
|
|
3
|
+
import { Grid } from '@/components/Grid/core/Grid';
|
|
4
|
+
import type { GridMode, GridSettings } from '@/components/Grid/core/types';
|
|
5
|
+
import { createSelectColumn } from '@/components/Grid/ui';
|
|
6
|
+
import { useGetPeopleQuery, useGetSettingsQuery, useSaveSettingsMutation } from '@/store/gridApi';
|
|
7
|
+
import type { PersonRow } from '@/mocks/people';
|
|
8
|
+
import { exportTableToXlsx } from '@/components/Grid/features/export/exportXlsx';
|
|
9
|
+
|
|
10
|
+
const GRID_ID = 'people-grid';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_SETTINGS: GridSettings = {
|
|
13
|
+
version: 1,
|
|
14
|
+
columnSizing: { select: 46, name: 220, age: 80, country: 100, department: 140 },
|
|
15
|
+
columnOrder: [],
|
|
16
|
+
columnVisibility: {},
|
|
17
|
+
sorting: [],
|
|
18
|
+
rowSelection: {},
|
|
19
|
+
columnFilters: [],
|
|
20
|
+
expanded: {},
|
|
21
|
+
grouping: [],
|
|
22
|
+
pagination: { pageIndex: 0, pageSize: 100 },
|
|
23
|
+
mode: 'flat',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function useDebounced<T extends (..._args: any[]) => void>(fn: T, wait = 250) {
|
|
27
|
+
const fnRef = React.useRef(fn);
|
|
28
|
+
const timeoutRef = React.useRef<number | undefined>(undefined);
|
|
29
|
+
React.useEffect(() => {
|
|
30
|
+
fnRef.current = fn;
|
|
31
|
+
}, [fn]);
|
|
32
|
+
|
|
33
|
+
return React.useMemo(() => {
|
|
34
|
+
return (...args: Parameters<T>) => {
|
|
35
|
+
window.clearTimeout(timeoutRef.current);
|
|
36
|
+
timeoutRef.current = window.setTimeout(() => fnRef.current(...args), wait);
|
|
37
|
+
};
|
|
38
|
+
}, [wait]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function App() {
|
|
42
|
+
const [localSettings, setLocalSettings] = React.useState<GridSettings>(DEFAULT_SETTINGS);
|
|
43
|
+
const { data: peopleData, isFetching: isFetchingPeople } = useGetPeopleQuery({
|
|
44
|
+
page: localSettings.pagination.pageIndex,
|
|
45
|
+
size: localSettings.pagination.pageSize,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const { data: settingsData, isFetching: isFetchingSettings } = useGetSettingsQuery({
|
|
49
|
+
gridId: GRID_ID,
|
|
50
|
+
});
|
|
51
|
+
const [saveSettings] = useSaveSettingsMutation();
|
|
52
|
+
|
|
53
|
+
React.useEffect(() => {
|
|
54
|
+
if (settingsData) {
|
|
55
|
+
setLocalSettings({
|
|
56
|
+
...DEFAULT_SETTINGS,
|
|
57
|
+
...settingsData,
|
|
58
|
+
columnSizing: settingsData.columnSizing ?? DEFAULT_SETTINGS.columnSizing,
|
|
59
|
+
columnOrder: settingsData.columnOrder ?? DEFAULT_SETTINGS.columnOrder,
|
|
60
|
+
columnVisibility: settingsData.columnVisibility ?? DEFAULT_SETTINGS.columnVisibility,
|
|
61
|
+
sorting: settingsData.sorting ?? DEFAULT_SETTINGS.sorting,
|
|
62
|
+
rowSelection: settingsData.rowSelection ?? DEFAULT_SETTINGS.rowSelection,
|
|
63
|
+
columnFilters: settingsData.columnFilters ?? DEFAULT_SETTINGS.columnFilters,
|
|
64
|
+
expanded: settingsData.expanded ?? DEFAULT_SETTINGS.expanded,
|
|
65
|
+
grouping: settingsData.grouping ?? DEFAULT_SETTINGS.grouping,
|
|
66
|
+
pagination: settingsData.pagination ?? DEFAULT_SETTINGS.pagination,
|
|
67
|
+
mode: settingsData.mode ?? DEFAULT_SETTINGS.mode,
|
|
68
|
+
});
|
|
69
|
+
} else {
|
|
70
|
+
setLocalSettings(DEFAULT_SETTINGS);
|
|
71
|
+
}
|
|
72
|
+
}, [settingsData]);
|
|
73
|
+
|
|
74
|
+
const debouncedSave = useDebounced((next: GridSettings) => {
|
|
75
|
+
saveSettings({ gridId: GRID_ID, settings: next });
|
|
76
|
+
}, 300);
|
|
77
|
+
|
|
78
|
+
const settings = localSettings;
|
|
79
|
+
|
|
80
|
+
const columns = React.useMemo<ColumnDef<PersonRow, any>[]>(() => {
|
|
81
|
+
return [
|
|
82
|
+
createSelectColumn(),
|
|
83
|
+
{ accessorKey: 'name', id: 'name', header: 'Name', size: 220 },
|
|
84
|
+
{
|
|
85
|
+
accessorKey: 'age',
|
|
86
|
+
id: 'age',
|
|
87
|
+
header: 'Age',
|
|
88
|
+
size: 80,
|
|
89
|
+
filterFn: (row, columnId, value) =>
|
|
90
|
+
String(row.getValue(columnId) ?? '').includes(String(value ?? '')),
|
|
91
|
+
},
|
|
92
|
+
{ accessorKey: 'country', id: 'country', header: 'Country', size: 100 },
|
|
93
|
+
{ accessorKey: 'department', id: 'department', header: 'Department', size: 140 },
|
|
94
|
+
];
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
const people = peopleData?.rows ?? [];
|
|
98
|
+
const totalRows = peopleData?.total ?? 0;
|
|
99
|
+
const isLoading = isFetchingPeople || isFetchingSettings;
|
|
100
|
+
|
|
101
|
+
const onSettingsChange = React.useCallback(
|
|
102
|
+
(next: GridSettings) => {
|
|
103
|
+
setLocalSettings(next);
|
|
104
|
+
debouncedSave(next);
|
|
105
|
+
},
|
|
106
|
+
[debouncedSave]
|
|
107
|
+
);
|
|
108
|
+
const tableRef = React.useRef<Table<PersonRow> | null>(null);
|
|
109
|
+
const handleTableReady = React.useCallback((table: Table<PersonRow>) => {
|
|
110
|
+
tableRef.current = table;
|
|
111
|
+
}, []);
|
|
112
|
+
const handleExport = React.useCallback((scope: 'page' | 'allFiltered') => {
|
|
113
|
+
const table = tableRef.current;
|
|
114
|
+
if (!table) return;
|
|
115
|
+
exportTableToXlsx({ table, fileName: `people-${scope}`, scope });
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
const [showFilters, setShowFilters] = React.useState(true);
|
|
119
|
+
const [expandAllByDefault, setExpandAllByDefault] = React.useState(false);
|
|
120
|
+
const enableExportXlsx = true;
|
|
121
|
+
const handleModeChange = React.useCallback(
|
|
122
|
+
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
123
|
+
const nextMode = e.target.value as GridMode;
|
|
124
|
+
const nextSettings = { ...settings, mode: nextMode, expanded: {} };
|
|
125
|
+
if (settings.mode === 'group' && nextMode !== 'group') {
|
|
126
|
+
nextSettings.grouping = [];
|
|
127
|
+
}
|
|
128
|
+
onSettingsChange(nextSettings);
|
|
129
|
+
},
|
|
130
|
+
[onSettingsChange, settings]
|
|
131
|
+
);
|
|
132
|
+
const handleGroupingToggle = React.useCallback(
|
|
133
|
+
(columnId: string) => {
|
|
134
|
+
const hasColumn = settings.grouping.includes(columnId);
|
|
135
|
+
const next = hasColumn
|
|
136
|
+
? settings.grouping.filter((id) => id !== columnId)
|
|
137
|
+
: [...settings.grouping, columnId];
|
|
138
|
+
onSettingsChange({ ...settings, grouping: next, expanded: {} });
|
|
139
|
+
},
|
|
140
|
+
[onSettingsChange, settings]
|
|
141
|
+
);
|
|
142
|
+
const handleClearGrouping = React.useCallback(() => {
|
|
143
|
+
onSettingsChange({ ...settings, grouping: [], expanded: {} });
|
|
144
|
+
}, [onSettingsChange, settings]);
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div className="container">
|
|
148
|
+
<div className="card">
|
|
149
|
+
<h1 className="h1">TanStack Grid Mini</h1>
|
|
150
|
+
<div style={{ opacity: 0.75, marginBottom: 10 }}>
|
|
151
|
+
rows: {people.length.toLocaleString()} • loading: {String(isLoading)}
|
|
152
|
+
</div>
|
|
153
|
+
<button
|
|
154
|
+
className="filter-toggle"
|
|
155
|
+
type="button"
|
|
156
|
+
onClick={() => setShowFilters((prev) => !prev)}
|
|
157
|
+
>
|
|
158
|
+
{showFilters ? 'Hide filters' : 'Show filters'}
|
|
159
|
+
</button>
|
|
160
|
+
<label className="expand-toggle">
|
|
161
|
+
<input
|
|
162
|
+
type="checkbox"
|
|
163
|
+
checked={expandAllByDefault}
|
|
164
|
+
onChange={(e) => setExpandAllByDefault(e.target.checked)}
|
|
165
|
+
/>
|
|
166
|
+
Expand all
|
|
167
|
+
</label>
|
|
168
|
+
<select className="mode-toggle" value={settings.mode} onChange={handleModeChange}>
|
|
169
|
+
<option value="flat">Flat</option>
|
|
170
|
+
<option value="parent">Parent</option>
|
|
171
|
+
<option value="group">Group</option>
|
|
172
|
+
</select>
|
|
173
|
+
<button className="export-toggle" type="button" onClick={() => handleExport('page')}>
|
|
174
|
+
Export page (xlsx)
|
|
175
|
+
</button>
|
|
176
|
+
<button className="export-toggle" type="button" onClick={() => handleExport('allFiltered')}>
|
|
177
|
+
Export all (xlsx)
|
|
178
|
+
</button>
|
|
179
|
+
{settings.mode === 'group' && (
|
|
180
|
+
<div className="grouping-panel">
|
|
181
|
+
<span className="grouping-label">Group by:</span>
|
|
182
|
+
<label className="grouping-item">
|
|
183
|
+
<input
|
|
184
|
+
type="checkbox"
|
|
185
|
+
checked={settings.grouping.includes('country')}
|
|
186
|
+
onChange={() => handleGroupingToggle('country')}
|
|
187
|
+
/>
|
|
188
|
+
Country
|
|
189
|
+
</label>
|
|
190
|
+
<label className="grouping-item">
|
|
191
|
+
<input
|
|
192
|
+
type="checkbox"
|
|
193
|
+
checked={settings.grouping.includes('department')}
|
|
194
|
+
onChange={() => handleGroupingToggle('department')}
|
|
195
|
+
/>
|
|
196
|
+
Department
|
|
197
|
+
</label>
|
|
198
|
+
<button className="grouping-clear" type="button" onClick={handleClearGrouping}>
|
|
199
|
+
Clear grouping
|
|
200
|
+
</button>
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
|
|
204
|
+
<Grid<PersonRow>
|
|
205
|
+
gridId={GRID_ID}
|
|
206
|
+
data={people}
|
|
207
|
+
columns={columns}
|
|
208
|
+
isLoading={isLoading}
|
|
209
|
+
totalRows={totalRows}
|
|
210
|
+
settings={settings}
|
|
211
|
+
onSettingsChange={onSettingsChange}
|
|
212
|
+
showFilters={showFilters}
|
|
213
|
+
expandAllByDefault={expandAllByDefault}
|
|
214
|
+
enableExportXlsx={enableExportXlsx}
|
|
215
|
+
onTableReady={handleTableReady}
|
|
216
|
+
getRowContextMenuItems={({ row, close }) => [
|
|
217
|
+
<button key="details" type="button" onClick={() => close()}>
|
|
218
|
+
Details for {String(row.original?.id ?? row.id)}
|
|
219
|
+
</button>,
|
|
220
|
+
<button key="remove" type="button" onClick={() => close()}>
|
|
221
|
+
Remove row
|
|
222
|
+
</button>,
|
|
223
|
+
]}
|
|
224
|
+
rowHeight={36}
|
|
225
|
+
overscan={14}
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { GridProps } from './types';
|
|
3
|
+
import { useGridTable } from './createTable';
|
|
4
|
+
import { useRowVirtualizer } from '../features/virtualization/useRowVirtualizer';
|
|
5
|
+
import { useColumnReorder } from '../features/columns/useColumnReorder';
|
|
6
|
+
import { Header } from '../ui/Header';
|
|
7
|
+
import { Row } from '../ui/Row';
|
|
8
|
+
import { Pagination } from '../ui/Pagination';
|
|
9
|
+
import { buildParentTree } from '../features/tree/buildParentTree';
|
|
10
|
+
import { ContextMenu } from '../ui/ContextMenu';
|
|
11
|
+
|
|
12
|
+
export function Grid<TData>(props: GridProps<TData>) {
|
|
13
|
+
const {
|
|
14
|
+
data,
|
|
15
|
+
totalRows,
|
|
16
|
+
columns,
|
|
17
|
+
settings,
|
|
18
|
+
onSettingsChange,
|
|
19
|
+
isLoading = false,
|
|
20
|
+
rowHeight = 36,
|
|
21
|
+
overscan = 12,
|
|
22
|
+
showFilters = true,
|
|
23
|
+
renderAggregatedCell,
|
|
24
|
+
expandAllByDefault = false,
|
|
25
|
+
enableExportXlsx = true,
|
|
26
|
+
getRowContextMenuItems,
|
|
27
|
+
onTableReady,
|
|
28
|
+
} = props;
|
|
29
|
+
|
|
30
|
+
const isParentMode = settings.mode === 'parent';
|
|
31
|
+
const isGroupMode = settings.mode === 'group';
|
|
32
|
+
const tableData = React.useMemo(() => {
|
|
33
|
+
if (!isParentMode) return data;
|
|
34
|
+
return buildParentTree(
|
|
35
|
+
data,
|
|
36
|
+
(row) => String((row as { id: string }).id),
|
|
37
|
+
(row) => (row as { parentId?: string | null }).parentId
|
|
38
|
+
);
|
|
39
|
+
}, [data, isParentMode]);
|
|
40
|
+
|
|
41
|
+
const getSubRows = React.useCallback((row: TData) => {
|
|
42
|
+
const node = row as { subRows?: TData[] };
|
|
43
|
+
return node.subRows;
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
const expandedState = React.useMemo(() => {
|
|
47
|
+
if (!expandAllByDefault || !isParentMode) return settings.expanded;
|
|
48
|
+
if (settings.expanded === true) return settings.expanded;
|
|
49
|
+
const expandedMap = settings.expanded as Record<string, boolean> | undefined;
|
|
50
|
+
const isEmpty = !expandedMap || Object.keys(expandedMap).length === 0;
|
|
51
|
+
return isEmpty ? true : settings.expanded;
|
|
52
|
+
}, [expandAllByDefault, isParentMode, settings.expanded]);
|
|
53
|
+
|
|
54
|
+
const table = useGridTable<TData>({
|
|
55
|
+
data: tableData,
|
|
56
|
+
columns,
|
|
57
|
+
settings,
|
|
58
|
+
onSettingsChange,
|
|
59
|
+
showFilters,
|
|
60
|
+
getSubRows: isParentMode ? getSubRows : undefined,
|
|
61
|
+
expandedStateOverride: expandedState,
|
|
62
|
+
rowCount: totalRows,
|
|
63
|
+
});
|
|
64
|
+
React.useEffect(() => {
|
|
65
|
+
if (!enableExportXlsx || !onTableReady) return;
|
|
66
|
+
onTableReady(table);
|
|
67
|
+
}, [enableExportXlsx, onTableReady, table]);
|
|
68
|
+
|
|
69
|
+
const { handleHeaderDragStart, handleHeaderDrop, isReorderable } = useColumnReorder(table, {
|
|
70
|
+
disabledColumnIds: ['select'],
|
|
71
|
+
});
|
|
72
|
+
const { parentRef, rows, totalSize, virtualItems } = useRowVirtualizer({
|
|
73
|
+
table,
|
|
74
|
+
rowHeight,
|
|
75
|
+
overscan,
|
|
76
|
+
});
|
|
77
|
+
const firstContentColumnId = table.getVisibleLeafColumns().find((col) => col.id !== 'select')?.id;
|
|
78
|
+
const [menu, setMenu] = React.useState<{
|
|
79
|
+
open: boolean;
|
|
80
|
+
x: number;
|
|
81
|
+
y: number;
|
|
82
|
+
rowId?: string;
|
|
83
|
+
}>({ open: false, x: 0, y: 0 });
|
|
84
|
+
const closeMenu = React.useCallback(() => {
|
|
85
|
+
setMenu({ open: false, x: 0, y: 0 });
|
|
86
|
+
}, []);
|
|
87
|
+
const menuRow = React.useMemo(() => {
|
|
88
|
+
if (!menu.open || !menu.rowId) return undefined;
|
|
89
|
+
return table.getRowModel().rows.find((row) => row.id === menu.rowId);
|
|
90
|
+
}, [menu.open, menu.rowId, table]);
|
|
91
|
+
const menuItems = React.useMemo(() => {
|
|
92
|
+
if (!menu.open || !menuRow || !getRowContextMenuItems) return null;
|
|
93
|
+
return getRowContextMenuItems({ table, row: menuRow, close: closeMenu });
|
|
94
|
+
}, [closeMenu, getRowContextMenuItems, menu.open, menuRow, table]);
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div className="grid-shell">
|
|
98
|
+
<Header
|
|
99
|
+
table={table}
|
|
100
|
+
showFilters={showFilters}
|
|
101
|
+
onHeaderDragStart={handleHeaderDragStart}
|
|
102
|
+
onHeaderDrop={handleHeaderDrop}
|
|
103
|
+
isReorderableColumn={isReorderable}
|
|
104
|
+
/>
|
|
105
|
+
|
|
106
|
+
<div ref={parentRef} className="scroll">
|
|
107
|
+
<div style={{ height: totalSize, position: 'relative' }}>
|
|
108
|
+
{isLoading ? (
|
|
109
|
+
<div style={{ padding: 12, opacity: 0.8 }}>Загрузка…</div>
|
|
110
|
+
) : (
|
|
111
|
+
virtualItems.map((vi) => {
|
|
112
|
+
const row = rows[vi.index];
|
|
113
|
+
if (!row) return null;
|
|
114
|
+
return (
|
|
115
|
+
<Row
|
|
116
|
+
key={row.id}
|
|
117
|
+
row={row}
|
|
118
|
+
top={vi.start}
|
|
119
|
+
rowHeight={rowHeight}
|
|
120
|
+
firstContentColumnId={firstContentColumnId}
|
|
121
|
+
renderAggregatedCell={renderAggregatedCell}
|
|
122
|
+
isGroupMode={isGroupMode}
|
|
123
|
+
onContextMenu={(event) => {
|
|
124
|
+
if (!getRowContextMenuItems) return;
|
|
125
|
+
event.preventDefault();
|
|
126
|
+
setMenu({
|
|
127
|
+
open: true,
|
|
128
|
+
x: event.clientX,
|
|
129
|
+
y: event.clientY,
|
|
130
|
+
rowId: row.id,
|
|
131
|
+
});
|
|
132
|
+
}}
|
|
133
|
+
/>
|
|
134
|
+
);
|
|
135
|
+
})
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
{settings.mode === 'flat' ? <Pagination table={table} /> : null}
|
|
140
|
+
{menuItems && menuItems.length > 0 ? (
|
|
141
|
+
<ContextMenu open={menu.open} x={menu.x} y={menu.y} onClose={closeMenu}>
|
|
142
|
+
{menuItems.map((node, index) => (
|
|
143
|
+
<div key={index} className="menu-item">
|
|
144
|
+
{node}
|
|
145
|
+
</div>
|
|
146
|
+
))}
|
|
147
|
+
</ContextMenu>
|
|
148
|
+
) : null}
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|