@snowpact/react-tanstack-query-table 1.0.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/LICENSE +21 -0
- package/README.md +461 -0
- package/dist/index.cjs +57 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +469 -0
- package/dist/index.js +6425 -0
- package/dist/index.js.map +1 -0
- package/package.json +77 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Snowpact
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
# @snowpact/react-tanstack-query-table
|
|
2
|
+
|
|
3
|
+
Ultra-light, registry-based data table for React + TanStack Table + TanStack Query.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Zero heavy dependencies**: No clsx, no tailwind-merge, no lucide-react bundled
|
|
8
|
+
- **Registry-based**: Inject your own i18n, Link component, confirmation dialogs
|
|
9
|
+
- **TypeScript**: Full type support with generics
|
|
10
|
+
- **Two modes**: Client-side and Server-side pagination/filtering/sorting
|
|
11
|
+
- **Customizable**: Override styles via registry
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @snowpact/react-tanstack-query-table
|
|
17
|
+
# or
|
|
18
|
+
pnpm add @snowpact/react-tanstack-query-table
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Peer Dependencies
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install @tanstack/react-query @tanstack/react-table react react-dom
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
> **Tailwind CSS** is required. Make sure Tailwind is configured in your app.
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
### 1. Setup (once in your app)
|
|
32
|
+
|
|
33
|
+
```tsx
|
|
34
|
+
import { setupSnowTable } from '@snowpact/react-tanstack-query-table';
|
|
35
|
+
import { useTranslation } from 'react-i18next';
|
|
36
|
+
import { Link } from 'react-router-dom';
|
|
37
|
+
import { useConfirm } from './your-confirm-dialog';
|
|
38
|
+
|
|
39
|
+
setupSnowTable({
|
|
40
|
+
useTranslation: () => useTranslation(),
|
|
41
|
+
LinkComponent: Link,
|
|
42
|
+
useConfirm: () => useConfirm(),
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
> **Full setup example**: See [examples/full-setup.md](./examples/full-setup.md) for a complete Shadcn/UI integration with custom `useConfirm` hook, tooltips, and translations.
|
|
47
|
+
|
|
48
|
+
### 2. Use a Client Table
|
|
49
|
+
|
|
50
|
+
```tsx
|
|
51
|
+
import { SnowClientTable, SnowColumnConfig } from '@snowpact/react-tanstack-query-table';
|
|
52
|
+
import { Edit, Trash } from 'lucide-react';
|
|
53
|
+
|
|
54
|
+
type User = { id: string; name: string; email: string };
|
|
55
|
+
|
|
56
|
+
const columns: SnowColumnConfig<User>[] = [{ key: 'name' }, { key: 'email' }];
|
|
57
|
+
|
|
58
|
+
<SnowClientTable
|
|
59
|
+
queryKey={['users']}
|
|
60
|
+
fetchAllItemsEndpoint={() => fetchUsers()}
|
|
61
|
+
columnConfig={columns}
|
|
62
|
+
actions={[
|
|
63
|
+
{ type: 'click', icon: Edit, label: 'Edit', onClick: user => editUser(user) },
|
|
64
|
+
{ type: 'endpoint', icon: Trash, label: 'Delete', endpoint: user => deleteUser(user.id) },
|
|
65
|
+
]}
|
|
66
|
+
enableGlobalSearch
|
|
67
|
+
enablePagination
|
|
68
|
+
/>;
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Client vs Server Mode
|
|
72
|
+
|
|
73
|
+
| Mode | Component | Use case | Data handling |
|
|
74
|
+
| ---------- | ----------------- | ------------- | ----------------------------------------------- |
|
|
75
|
+
| **Client** | `SnowClientTable` | < 5,000 items | All data loaded, filtered/sorted locally |
|
|
76
|
+
| **Server** | `SnowServerTable` | > 5,000 items | Paginated API, server handles filtering/sorting |
|
|
77
|
+
|
|
78
|
+
### SnowClientTable
|
|
79
|
+
|
|
80
|
+
Best for small to medium datasets. Fetches all data once via React Query, then handles pagination, search, and sorting entirely in the browser.
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
<SnowClientTable
|
|
84
|
+
queryKey={['users']}
|
|
85
|
+
fetchAllItemsEndpoint={() => api.getUsers()} // Returns User[]
|
|
86
|
+
columnConfig={columns}
|
|
87
|
+
/>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### SnowServerTable
|
|
91
|
+
|
|
92
|
+
Best for large datasets. The server handles pagination, search, filtering, and sorting. Returns paginated results with a total count.
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
import { SnowServerTable, ServerFetchParams } from '@snowpact/react-tanstack-query-table';
|
|
96
|
+
|
|
97
|
+
const fetchUsers = async (params: ServerFetchParams) => {
|
|
98
|
+
// params: { limit, offset, search?, sortBy?, sortOrder?, filters?, prefilter? }
|
|
99
|
+
const response = await api.getUsers(params);
|
|
100
|
+
return {
|
|
101
|
+
items: response.data,
|
|
102
|
+
totalItemCount: response.total,
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
<SnowServerTable queryKey={['users']} fetchServerEndpoint={fetchUsers} columnConfig={columns} />;
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Actions
|
|
110
|
+
|
|
111
|
+
Actions appear as buttons in each row. Three types are available:
|
|
112
|
+
|
|
113
|
+
### Click Action
|
|
114
|
+
|
|
115
|
+
Simple callback when clicked.
|
|
116
|
+
|
|
117
|
+
```tsx
|
|
118
|
+
{
|
|
119
|
+
type: 'click',
|
|
120
|
+
icon: Edit,
|
|
121
|
+
label: 'Edit',
|
|
122
|
+
onClick: (item) => openEditModal(item),
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Link Action
|
|
127
|
+
|
|
128
|
+
Navigates to a URL. Uses your registered `LinkComponent`.
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
{
|
|
132
|
+
type: 'link',
|
|
133
|
+
icon: Eye,
|
|
134
|
+
label: 'View',
|
|
135
|
+
href: (item) => `/users/${item.id}`,
|
|
136
|
+
external: false, // true for target="_blank"
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Endpoint Action
|
|
141
|
+
|
|
142
|
+
Calls an async endpoint (API mutation). Integrates with React Query invalidation.
|
|
143
|
+
|
|
144
|
+
```tsx
|
|
145
|
+
{
|
|
146
|
+
type: 'endpoint',
|
|
147
|
+
icon: Trash,
|
|
148
|
+
label: 'Delete',
|
|
149
|
+
endpoint: (item) => api.deleteUser(item.id),
|
|
150
|
+
onSuccess: () => queryClient.invalidateQueries(['users']),
|
|
151
|
+
onError: (error) => toast.error(error.message),
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Confirmation Dialog
|
|
156
|
+
|
|
157
|
+
Any action can require confirmation before executing:
|
|
158
|
+
|
|
159
|
+
```tsx
|
|
160
|
+
{
|
|
161
|
+
type: 'endpoint',
|
|
162
|
+
icon: Trash,
|
|
163
|
+
label: 'Delete',
|
|
164
|
+
variant: 'danger',
|
|
165
|
+
endpoint: (item) => api.deleteUser(item.id),
|
|
166
|
+
confirm: {
|
|
167
|
+
title: 'Delete user?',
|
|
168
|
+
content: 'This action cannot be undone.',
|
|
169
|
+
confirmText: 'Delete',
|
|
170
|
+
cancelText: 'Cancel',
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
For forms inside confirm dialogs, use a function to access the `close` helper:
|
|
176
|
+
|
|
177
|
+
```tsx
|
|
178
|
+
{
|
|
179
|
+
type: 'click',
|
|
180
|
+
icon: Edit,
|
|
181
|
+
label: 'Change Status',
|
|
182
|
+
onClick: () => {},
|
|
183
|
+
confirm: {
|
|
184
|
+
title: 'Change Status',
|
|
185
|
+
hideButtons: true, // Form handles its own buttons
|
|
186
|
+
content: ({ close }) => (
|
|
187
|
+
<StatusForm
|
|
188
|
+
onSuccess={() => {
|
|
189
|
+
queryClient.invalidateQueries(['users']);
|
|
190
|
+
close();
|
|
191
|
+
}}
|
|
192
|
+
/>
|
|
193
|
+
),
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Action Options
|
|
199
|
+
|
|
200
|
+
| Option | Type | Description |
|
|
201
|
+
| ----------- | ----------------------------------------------------------- | ---------------------------------------- |
|
|
202
|
+
| `icon` | `ComponentType<SVGProps>` | Icon component (lucide-react or any SVG) |
|
|
203
|
+
| `label` | `string` | Button label (used for tooltip) |
|
|
204
|
+
| `variant` | `'default' \| 'danger' \| 'warning' \| 'info' \| 'success'` | Button color variant |
|
|
205
|
+
| `display` | `'button' \| 'dropdown'` | Show as button or in dropdown menu |
|
|
206
|
+
| `hidden` | `boolean` | Conditionally hide the action |
|
|
207
|
+
| `disabled` | `boolean` | Disable the action |
|
|
208
|
+
| `showLabel` | `boolean` | Show label text next to icon |
|
|
209
|
+
|
|
210
|
+
### Dynamic Actions
|
|
211
|
+
|
|
212
|
+
Actions can be functions that return the action config, allowing per-row customization:
|
|
213
|
+
|
|
214
|
+
```tsx
|
|
215
|
+
actions={[
|
|
216
|
+
(item) => ({
|
|
217
|
+
type: 'click',
|
|
218
|
+
icon: item.isActive ? Pause : Play,
|
|
219
|
+
label: item.isActive ? 'Deactivate' : 'Activate',
|
|
220
|
+
onClick: () => toggleStatus(item),
|
|
221
|
+
hidden: item.role === 'admin', // Hide for admins
|
|
222
|
+
}),
|
|
223
|
+
]}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Search & Filtering
|
|
227
|
+
|
|
228
|
+
### Global Search
|
|
229
|
+
|
|
230
|
+
Enable fuzzy search across all columns:
|
|
231
|
+
|
|
232
|
+
```tsx
|
|
233
|
+
<SnowClientTable enableGlobalSearch texts={{ searchPlaceholder: 'Search users...' }} />
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
For custom search values (computed columns):
|
|
237
|
+
|
|
238
|
+
```tsx
|
|
239
|
+
const columns: SnowColumnConfig<User>[] = [
|
|
240
|
+
{
|
|
241
|
+
key: '_extra_fullName',
|
|
242
|
+
label: 'Full Name',
|
|
243
|
+
render: item => `${item.firstName} ${item.lastName}`,
|
|
244
|
+
searchableValue: item => `${item.firstName} ${item.lastName}`,
|
|
245
|
+
},
|
|
246
|
+
];
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Column Filters
|
|
250
|
+
|
|
251
|
+
Multi-select dropdown filters:
|
|
252
|
+
|
|
253
|
+
```tsx
|
|
254
|
+
<SnowClientTable
|
|
255
|
+
filters={[
|
|
256
|
+
{
|
|
257
|
+
key: 'status',
|
|
258
|
+
label: 'Status',
|
|
259
|
+
options: [
|
|
260
|
+
{ value: 'active', label: 'Active' },
|
|
261
|
+
{ value: 'inactive', label: 'Inactive' },
|
|
262
|
+
],
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
key: 'role',
|
|
266
|
+
label: 'Role',
|
|
267
|
+
options: [
|
|
268
|
+
{ value: 'admin', label: 'Admin' },
|
|
269
|
+
{ value: 'user', label: 'User' },
|
|
270
|
+
],
|
|
271
|
+
},
|
|
272
|
+
]}
|
|
273
|
+
/>
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### Prefilters (Tabs)
|
|
277
|
+
|
|
278
|
+
Quick segmentation via tabs:
|
|
279
|
+
|
|
280
|
+
```tsx
|
|
281
|
+
<SnowClientTable
|
|
282
|
+
prefilters={[
|
|
283
|
+
{ id: 'all', label: 'All' },
|
|
284
|
+
{ id: 'active', label: 'Active' },
|
|
285
|
+
{ id: 'archived', label: 'Archived' },
|
|
286
|
+
]}
|
|
287
|
+
prefilterFn={(item, prefilterId) => {
|
|
288
|
+
if (prefilterId === 'all') return true;
|
|
289
|
+
return item.status === prefilterId;
|
|
290
|
+
}}
|
|
291
|
+
/>
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
For server mode, the `prefilter` value is sent to your endpoint.
|
|
295
|
+
|
|
296
|
+
## Advanced Configuration
|
|
297
|
+
|
|
298
|
+
### Column Configuration
|
|
299
|
+
|
|
300
|
+
Users can show/hide columns via a settings button. Configuration is saved in cookies.
|
|
301
|
+
|
|
302
|
+
```tsx
|
|
303
|
+
<SnowClientTable
|
|
304
|
+
enableColumnConfiguration
|
|
305
|
+
columnConfig={[
|
|
306
|
+
{ key: 'name' }, // Always visible
|
|
307
|
+
{ key: 'email' },
|
|
308
|
+
{ key: 'details', meta: { defaultHidden: true } }, // Hidden by default
|
|
309
|
+
]}
|
|
310
|
+
/>
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
### URL State Persistence
|
|
314
|
+
|
|
315
|
+
Persist table state (pagination, search, filters, sorting) in URL query params:
|
|
316
|
+
|
|
317
|
+
```tsx
|
|
318
|
+
<SnowClientTable
|
|
319
|
+
persistState // State saved in URL
|
|
320
|
+
/>
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
URL params used:
|
|
324
|
+
|
|
325
|
+
- `dt_page`, `dt_pageSize` - Pagination
|
|
326
|
+
- `dt_search` - Search query
|
|
327
|
+
- `dt_prefilter` - Active prefilter
|
|
328
|
+
- `dt_filters` - Column filters
|
|
329
|
+
- `dt_sortBy`, `dt_sortDesc` - Sorting
|
|
330
|
+
|
|
331
|
+
### Column Meta Properties
|
|
332
|
+
|
|
333
|
+
```tsx
|
|
334
|
+
{
|
|
335
|
+
key: 'actions',
|
|
336
|
+
meta: {
|
|
337
|
+
width: '100px', // Fixed width
|
|
338
|
+
minWidth: '80px', // Minimum width
|
|
339
|
+
center: true, // Center content
|
|
340
|
+
defaultHidden: true, // Hidden by default
|
|
341
|
+
disableColumnClick: true, // Disable row click for this column
|
|
342
|
+
},
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### Sorting
|
|
347
|
+
|
|
348
|
+
```tsx
|
|
349
|
+
<SnowClientTable enableSorting defaultSortBy="createdAt" defaultSortOrder="desc" />
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### Pagination
|
|
353
|
+
|
|
354
|
+
```tsx
|
|
355
|
+
<SnowClientTable
|
|
356
|
+
enablePagination
|
|
357
|
+
defaultPageSize={25}
|
|
358
|
+
displayTotalNumber // Show "X elements"
|
|
359
|
+
enableElementLabel // Show element label
|
|
360
|
+
/>
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### Row Click
|
|
364
|
+
|
|
365
|
+
```tsx
|
|
366
|
+
<SnowClientTable
|
|
367
|
+
onRowClick={item => navigate(`/users/${item.id}`)}
|
|
368
|
+
activeRowId={selectedUserId} // Highlight active row
|
|
369
|
+
/>
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### Custom Styles
|
|
373
|
+
|
|
374
|
+
Override default Tailwind classes via the registry:
|
|
375
|
+
|
|
376
|
+
```tsx
|
|
377
|
+
setupSnowTable({
|
|
378
|
+
// ... other options
|
|
379
|
+
styles: {
|
|
380
|
+
table: {
|
|
381
|
+
wrapper: 'border border-gray-200 rounded-lg',
|
|
382
|
+
header: 'bg-gray-50',
|
|
383
|
+
row: 'hover:bg-gray-100',
|
|
384
|
+
},
|
|
385
|
+
button: {
|
|
386
|
+
visual: 'bg-blue-500 text-white',
|
|
387
|
+
hover: 'hover:bg-blue-600',
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
### Action Hover (Tooltips)
|
|
394
|
+
|
|
395
|
+
Integrate with your tooltip system:
|
|
396
|
+
|
|
397
|
+
```tsx
|
|
398
|
+
setupSnowTable({
|
|
399
|
+
// ... other options
|
|
400
|
+
onActionHover: ({ label, element }) => showTooltip(label, element),
|
|
401
|
+
onActionUnhover: () => hideTooltip(),
|
|
402
|
+
});
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
## API Reference
|
|
406
|
+
|
|
407
|
+
### SnowClientTable Props
|
|
408
|
+
|
|
409
|
+
| Prop | Type | Default | Description |
|
|
410
|
+
| --------------------------- | ----------------------- | -------- | ------------------------------- |
|
|
411
|
+
| `queryKey` | `string[]` | Required | React Query cache key |
|
|
412
|
+
| `fetchAllItemsEndpoint` | `() => Promise<T[]>` | Required | Data fetching function |
|
|
413
|
+
| `columnConfig` | `SnowColumnConfig<T>[]` | Required | Column definitions |
|
|
414
|
+
| `actions` | `TableAction<T>[]` | `[]` | Row actions |
|
|
415
|
+
| `filters` | `FilterConfig<T>[]` | `[]` | Column filters |
|
|
416
|
+
| `prefilters` | `PreFilter[]` | `[]` | Tab filters |
|
|
417
|
+
| `prefilterFn` | `(item, id) => boolean` | - | Client-side prefilter logic |
|
|
418
|
+
| `persistState` | `boolean` | `false` | Persist state in URL |
|
|
419
|
+
| `enableGlobalSearch` | `boolean` | `false` | Enable search bar |
|
|
420
|
+
| `enablePagination` | `boolean` | `false` | Enable pagination |
|
|
421
|
+
| `enableSorting` | `boolean` | `false` | Enable column sorting |
|
|
422
|
+
| `enableColumnConfiguration` | `boolean` | `false` | Enable column visibility toggle |
|
|
423
|
+
| `enableResetFilters` | `boolean` | `false` | Show reset filters button |
|
|
424
|
+
| `defaultPageSize` | `number` | `10` | Initial page size |
|
|
425
|
+
| `defaultSortBy` | `string` | - | Initial sort column |
|
|
426
|
+
| `defaultSortOrder` | `'asc' \| 'desc'` | `'asc'` | Initial sort direction |
|
|
427
|
+
|
|
428
|
+
### SnowServerTable Props
|
|
429
|
+
|
|
430
|
+
Same as `SnowClientTable`, except:
|
|
431
|
+
|
|
432
|
+
| Prop | Type | Description |
|
|
433
|
+
| --------------------- | -------------------------------------------------------------------- | ------------------------ |
|
|
434
|
+
| `fetchServerEndpoint` | `(params: ServerFetchParams) => Promise<ServerPaginatedResponse<T>>` | Paginated fetch function |
|
|
435
|
+
|
|
436
|
+
### ServerFetchParams
|
|
437
|
+
|
|
438
|
+
```typescript
|
|
439
|
+
interface ServerFetchParams {
|
|
440
|
+
limit: number;
|
|
441
|
+
offset: number;
|
|
442
|
+
search?: string;
|
|
443
|
+
prefilter?: string;
|
|
444
|
+
filters?: Record<string, string[]>;
|
|
445
|
+
sortBy?: string;
|
|
446
|
+
sortOrder?: 'ASC' | 'DESC';
|
|
447
|
+
}
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
### ServerPaginatedResponse
|
|
451
|
+
|
|
452
|
+
```typescript
|
|
453
|
+
interface ServerPaginatedResponse<T> {
|
|
454
|
+
items: T[];
|
|
455
|
+
totalItemCount: number;
|
|
456
|
+
}
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
## License
|
|
460
|
+
|
|
461
|
+
MIT
|