proje-react-panel 1.0.15 → 1.0.16
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/.vscode/launch.json +10 -0
- package/dist/components/components/FormField.d.ts +2 -1
- package/dist/components/components/InnerForm.d.ts +2 -2
- package/dist/components/components/list/Datagrid.d.ts +13 -0
- package/dist/components/components/list/EmptyList.d.ts +2 -0
- package/dist/components/components/list/FilterPopup.d.ts +11 -0
- package/dist/components/components/list/ListPage.d.ts +22 -0
- package/dist/components/components/list/Pagination.d.ts +11 -0
- package/dist/components/components/list/index.d.ts +0 -0
- package/dist/components/list/Datagrid.d.ts +8 -4
- package/dist/components/list/EmptyList.d.ts +2 -0
- package/dist/components/list/FilterPopup.d.ts +10 -0
- package/dist/components/pages/FormPage.d.ts +3 -2
- package/dist/components/pages/ListPage.d.ts +2 -1
- package/dist/decorators/form/Input.d.ts +7 -3
- package/dist/decorators/list/Cell.d.ts +14 -2
- package/dist/decorators/list/List.d.ts +24 -1
- package/dist/index.cjs.js +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.esm.js +1 -1
- package/package.json +9 -3
- package/src/assets/icons/svg/create.svg +9 -0
- package/src/assets/icons/svg/filter.svg +3 -0
- package/src/assets/icons/svg/pencil.svg +8 -0
- package/src/assets/icons/svg/search.svg +8 -0
- package/src/assets/icons/svg/trash.svg +8 -0
- package/src/components/components/FormField.tsx +41 -7
- package/src/components/components/InnerForm.tsx +8 -9
- package/src/components/components/list/Datagrid.tsx +121 -0
- package/src/components/components/list/EmptyList.tsx +26 -0
- package/src/components/components/list/FilterPopup.tsx +202 -0
- package/src/components/components/list/ListPage.tsx +178 -0
- package/src/components/pages/FormPage.tsx +4 -2
- package/src/decorators/form/Input.ts +4 -3
- package/src/decorators/list/Cell.ts +24 -14
- package/src/decorators/list/List.ts +23 -9
- package/src/index.ts +8 -3
- package/src/styles/filter-popup.scss +134 -0
- package/src/styles/index.scss +18 -22
- package/src/styles/list.scss +149 -8
- package/src/types/svg.d.ts +5 -0
- package/src/components/list/Datagrid.tsx +0 -101
- package/src/components/pages/ListPage.tsx +0 -85
- /package/src/components/{list → components/list}/Pagination.tsx +0 -0
- /package/src/components/{list → components/list}/index.ts +0 -0
- /package/src/styles/{_scrollbar.scss → utils/scrollbar.scss} +0 -0
@@ -0,0 +1,121 @@
|
|
1
|
+
import React from 'react';
|
2
|
+
import { CellOptions } from '../../../decorators/list/Cell';
|
3
|
+
import { Link } from 'react-router';
|
4
|
+
import { useAppStore } from '../../../store/store';
|
5
|
+
import { ImageCellOptions } from '../../../decorators/list/ImageCell';
|
6
|
+
import { ListData } from '../../../decorators/list/ListData';
|
7
|
+
import { EmptyList } from './EmptyList';
|
8
|
+
import SearchIcon from '../../../assets/icons/svg/search.svg';
|
9
|
+
import PencilIcon from '../../../assets/icons/svg/pencil.svg';
|
10
|
+
import TrashIcon from '../../../assets/icons/svg/trash.svg';
|
11
|
+
|
12
|
+
interface DatagridProps<T extends { id: string }> {
|
13
|
+
data: T[];
|
14
|
+
listData: ListData;
|
15
|
+
onRemoveItem?: (item: T) => Promise<void>;
|
16
|
+
}
|
17
|
+
|
18
|
+
export function Datagrid<T extends { id: string }>({ data, listData, onRemoveItem }: DatagridProps<T>) {
|
19
|
+
const cells = listData.cells;
|
20
|
+
const utilCells = listData.list?.utilCells;
|
21
|
+
|
22
|
+
return (
|
23
|
+
<div className="datagrid">
|
24
|
+
{!data || data.length === 0 ? (
|
25
|
+
<EmptyList />
|
26
|
+
) : (
|
27
|
+
<table className="datagrid-table">
|
28
|
+
<thead>
|
29
|
+
<tr>
|
30
|
+
{cells.map(cellOptions => (
|
31
|
+
<th key={cellOptions.name}>{cellOptions.title ?? cellOptions.name}</th>
|
32
|
+
))}
|
33
|
+
{utilCells?.details && <th>Details</th>}
|
34
|
+
{utilCells?.edit && <th>Edit</th>}
|
35
|
+
{utilCells?.delete && <th>Delete</th>}
|
36
|
+
</tr>
|
37
|
+
</thead>
|
38
|
+
<tbody>
|
39
|
+
{data.map((item, index) => (
|
40
|
+
<tr key={index}>
|
41
|
+
{cells.map(cellOptions => {
|
42
|
+
// @ts-ignore
|
43
|
+
const value = item[cellOptions.name];
|
44
|
+
let render = value ?? '-'; // Default value if the field is undefined or null
|
45
|
+
|
46
|
+
switch (cellOptions.type) {
|
47
|
+
case 'date':
|
48
|
+
if (value) {
|
49
|
+
const date = new Date(value);
|
50
|
+
render = `${date.getDate().toString().padStart(2, '0')}/${(
|
51
|
+
date.getMonth() + 1
|
52
|
+
)
|
53
|
+
.toString()
|
54
|
+
.padStart(
|
55
|
+
2,
|
56
|
+
'0'
|
57
|
+
)}/${date.getFullYear()} ${date.getHours().toString().padStart(2, '0')}:${date
|
58
|
+
.getMinutes()
|
59
|
+
.toString()
|
60
|
+
.padStart(2, '0')}`;
|
61
|
+
}
|
62
|
+
break;
|
63
|
+
|
64
|
+
case 'image': {
|
65
|
+
const imageCellOptions = cellOptions as ImageCellOptions;
|
66
|
+
render = (
|
67
|
+
<img
|
68
|
+
width={100}
|
69
|
+
height={100}
|
70
|
+
src={imageCellOptions.baseUrl + value}
|
71
|
+
style={{ objectFit: 'contain' }}
|
72
|
+
/>
|
73
|
+
);
|
74
|
+
break;
|
75
|
+
}
|
76
|
+
case 'string':
|
77
|
+
default:
|
78
|
+
render = value ? value.toString() : (cellOptions?.placeHolder ?? '-'); // Handles string type or default fallback
|
79
|
+
break;
|
80
|
+
}
|
81
|
+
/*
|
82
|
+
if (cellOptions.linkTo) {
|
83
|
+
render = <Link to={cellOptions.linkTo(item)}>{formattedValue}</Link>;
|
84
|
+
}
|
85
|
+
*/
|
86
|
+
return <td key={cellOptions.name}>{render}</td>;
|
87
|
+
})}
|
88
|
+
{utilCells?.details && (
|
89
|
+
<td>
|
90
|
+
<Link to={`${utilCells.details.path}/${item.id}`} className="util-cell-link">
|
91
|
+
<SearchIcon className="icon icon-search" />
|
92
|
+
<span className="util-cell-label">{utilCells.details.label}</span>
|
93
|
+
</Link>
|
94
|
+
</td>
|
95
|
+
)}
|
96
|
+
{utilCells?.edit && (
|
97
|
+
<td>
|
98
|
+
<Link to={`${utilCells.edit.path}/${item.id}`} className="util-cell-link">
|
99
|
+
<PencilIcon className="icon icon-pencil" />
|
100
|
+
<span className="util-cell-label">{utilCells.edit.label}</span>
|
101
|
+
</Link>
|
102
|
+
</td>
|
103
|
+
)}
|
104
|
+
{utilCells?.delete && (
|
105
|
+
<td>
|
106
|
+
<a onClick={() => {
|
107
|
+
onRemoveItem?.(item)
|
108
|
+
}} className="util-cell-link">
|
109
|
+
<TrashIcon className="icon icon-trash" />
|
110
|
+
<span className="util-cell-label">{utilCells.delete.label}</span>
|
111
|
+
</a>
|
112
|
+
</td>
|
113
|
+
)}
|
114
|
+
</tr>
|
115
|
+
))}
|
116
|
+
</tbody>
|
117
|
+
</table>
|
118
|
+
)}
|
119
|
+
</div>
|
120
|
+
);
|
121
|
+
}
|
@@ -0,0 +1,26 @@
|
|
1
|
+
import React from 'react';
|
2
|
+
|
3
|
+
export const EmptyList: React.FC = () => {
|
4
|
+
return (
|
5
|
+
<div className="empty-list">
|
6
|
+
<div className="empty-list-content">
|
7
|
+
<svg
|
8
|
+
xmlns="http://www.w3.org/2000/svg"
|
9
|
+
width="64"
|
10
|
+
height="64"
|
11
|
+
viewBox="0 0 24 24"
|
12
|
+
fill="none"
|
13
|
+
stroke="currentColor"
|
14
|
+
strokeWidth="2"
|
15
|
+
strokeLinecap="round"
|
16
|
+
strokeLinejoin="round"
|
17
|
+
>
|
18
|
+
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
|
19
|
+
<polyline points="13 2 13 9 20 9" />
|
20
|
+
</svg>
|
21
|
+
<h3>No Data Found</h3>
|
22
|
+
<p>There are no items to display at the moment.</p>
|
23
|
+
</div>
|
24
|
+
</div>
|
25
|
+
);
|
26
|
+
};
|
@@ -0,0 +1,202 @@
|
|
1
|
+
import React, { useEffect, useMemo, useRef } from 'react';
|
2
|
+
import { ListData } from '../../../decorators/list/ListData';
|
3
|
+
import { CellOptions, StaticSelectFilter } from '../../../decorators/list/Cell';
|
4
|
+
import Select from 'react-select';
|
5
|
+
|
6
|
+
interface FilterPopupProps {
|
7
|
+
isOpen: boolean;
|
8
|
+
onClose: () => void;
|
9
|
+
onApplyFilters: (filters: Record<string, string>) => void;
|
10
|
+
listData: ListData;
|
11
|
+
activeFilters?: Record<string, string>;
|
12
|
+
}
|
13
|
+
|
14
|
+
interface FilterFieldProps {
|
15
|
+
field: CellOptions;
|
16
|
+
value: string;
|
17
|
+
onChange: (value: string) => void;
|
18
|
+
}
|
19
|
+
|
20
|
+
function FilterField({ field, value, onChange }: FilterFieldProps): React.ReactElement {
|
21
|
+
switch (field.filter?.type) {
|
22
|
+
case 'static-select': {
|
23
|
+
const filter = field.filter as StaticSelectFilter;
|
24
|
+
return (
|
25
|
+
<Select
|
26
|
+
id={field.name}
|
27
|
+
menuPortalTarget={document.body}
|
28
|
+
styles={{
|
29
|
+
control: (baseStyles, state) => ({
|
30
|
+
...baseStyles,
|
31
|
+
backgroundColor: '#1f2937',
|
32
|
+
borderColor: state.isFocused ? '#6366f1' : '#374151',
|
33
|
+
boxShadow: state.isFocused ? '0 0 0 1px #6366f1' : 'none',
|
34
|
+
'&:hover': {
|
35
|
+
borderColor: '#6366f1',
|
36
|
+
},
|
37
|
+
borderRadius: '6px',
|
38
|
+
padding: '2px',
|
39
|
+
color: 'white',
|
40
|
+
}),
|
41
|
+
option: (baseStyles, state) => ({
|
42
|
+
...baseStyles,
|
43
|
+
backgroundColor: state.isSelected
|
44
|
+
? '#6366f1'
|
45
|
+
: state.isFocused
|
46
|
+
? '#374151'
|
47
|
+
: '#1f2937',
|
48
|
+
color: 'white',
|
49
|
+
'&:active': {
|
50
|
+
backgroundColor: '#6366f1',
|
51
|
+
},
|
52
|
+
'&:hover': {
|
53
|
+
backgroundColor: '#374151',
|
54
|
+
},
|
55
|
+
cursor: 'pointer',
|
56
|
+
}),
|
57
|
+
input: baseStyles => ({
|
58
|
+
...baseStyles,
|
59
|
+
color: 'white',
|
60
|
+
}),
|
61
|
+
placeholder: baseStyles => ({
|
62
|
+
...baseStyles,
|
63
|
+
color: '#9ca3af',
|
64
|
+
}),
|
65
|
+
singleValue: baseStyles => ({
|
66
|
+
...baseStyles,
|
67
|
+
color: 'white',
|
68
|
+
}),
|
69
|
+
menuPortal: baseStyles => ({
|
70
|
+
...baseStyles,
|
71
|
+
zIndex: 9999,
|
72
|
+
}),
|
73
|
+
menu: baseStyles => ({
|
74
|
+
...baseStyles,
|
75
|
+
backgroundColor: '#1f2937',
|
76
|
+
border: '1px solid #374151',
|
77
|
+
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
78
|
+
}),
|
79
|
+
menuList: baseStyles => ({
|
80
|
+
...baseStyles,
|
81
|
+
padding: '4px',
|
82
|
+
}),
|
83
|
+
dropdownIndicator: baseStyles => ({
|
84
|
+
...baseStyles,
|
85
|
+
color: '#9ca3af',
|
86
|
+
'&:hover': {
|
87
|
+
color: '#6366f1',
|
88
|
+
},
|
89
|
+
}),
|
90
|
+
clearIndicator: baseStyles => ({
|
91
|
+
...baseStyles,
|
92
|
+
color: '#9ca3af',
|
93
|
+
'&:hover': {
|
94
|
+
color: '#6366f1',
|
95
|
+
},
|
96
|
+
}),
|
97
|
+
}}
|
98
|
+
value={
|
99
|
+
value
|
100
|
+
? {
|
101
|
+
value: value,
|
102
|
+
label: filter.options.find(opt => opt.value === value)?.label || value,
|
103
|
+
}
|
104
|
+
: null
|
105
|
+
}
|
106
|
+
onChange={option => onChange(option?.value || '')}
|
107
|
+
options={filter.options.map(opt => ({
|
108
|
+
value: opt.value,
|
109
|
+
label: opt.label,
|
110
|
+
}))}
|
111
|
+
placeholder={`Filter by ${field.title || field.name}`}
|
112
|
+
isClearable
|
113
|
+
/>
|
114
|
+
);
|
115
|
+
}
|
116
|
+
default:
|
117
|
+
return (
|
118
|
+
<input
|
119
|
+
type={field.type === 'number' ? 'number' : 'text'}
|
120
|
+
id={field.name}
|
121
|
+
value={value || ''}
|
122
|
+
onChange={e => onChange(e.target.value)}
|
123
|
+
placeholder={`Filter by ${field.title || field.name}`}
|
124
|
+
/>
|
125
|
+
);
|
126
|
+
}
|
127
|
+
}
|
128
|
+
|
129
|
+
export function FilterPopup({
|
130
|
+
isOpen,
|
131
|
+
onClose,
|
132
|
+
onApplyFilters,
|
133
|
+
listData,
|
134
|
+
activeFilters,
|
135
|
+
}: FilterPopupProps): React.ReactElement | null {
|
136
|
+
const [filters, setFilters] = React.useState<Record<string, any>>(activeFilters ?? {});
|
137
|
+
const popupRef = useRef<HTMLDivElement>(null);
|
138
|
+
const fields = useMemo(() => listData.cells.filter(cell => !!cell.filter), [listData.cells]);
|
139
|
+
|
140
|
+
useEffect(() => {
|
141
|
+
const handleClickOutside = (event: MouseEvent) => {
|
142
|
+
if (popupRef.current && !popupRef.current.contains(event.target as Node)) {
|
143
|
+
onClose();
|
144
|
+
}
|
145
|
+
};
|
146
|
+
|
147
|
+
if (isOpen) {
|
148
|
+
document.addEventListener('mousedown', handleClickOutside);
|
149
|
+
}
|
150
|
+
|
151
|
+
return () => {
|
152
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
153
|
+
};
|
154
|
+
}, [isOpen, onClose]);
|
155
|
+
|
156
|
+
if (!isOpen) return null;
|
157
|
+
|
158
|
+
const handleFilterChange = (fieldName: string, value: any) => {
|
159
|
+
setFilters(prev => ({
|
160
|
+
...prev,
|
161
|
+
[fieldName]: value,
|
162
|
+
}));
|
163
|
+
};
|
164
|
+
|
165
|
+
const handleApply = () => {
|
166
|
+
onApplyFilters(filters);
|
167
|
+
onClose();
|
168
|
+
};
|
169
|
+
|
170
|
+
return (
|
171
|
+
<div className="filter-popup-overlay">
|
172
|
+
<div ref={popupRef} className="filter-popup">
|
173
|
+
<div className="filter-popup-header">
|
174
|
+
<h3>Filter</h3>
|
175
|
+
<button onClick={onClose} className="close-button">
|
176
|
+
×
|
177
|
+
</button>
|
178
|
+
</div>
|
179
|
+
<div className="filter-popup-content">
|
180
|
+
{fields.map((field: CellOptions) => (
|
181
|
+
<div key={field.name} className="filter-field">
|
182
|
+
<label htmlFor={field.name}>{field.title || field.name}</label>
|
183
|
+
<FilterField
|
184
|
+
field={field}
|
185
|
+
value={filters[field.name || '']}
|
186
|
+
onChange={value => handleFilterChange(field.name || '', value)}
|
187
|
+
/>
|
188
|
+
</div>
|
189
|
+
))}
|
190
|
+
</div>
|
191
|
+
<div className="filter-popup-footer">
|
192
|
+
<button onClick={onClose} className="cancel-button">
|
193
|
+
Cancel
|
194
|
+
</button>
|
195
|
+
<button onClick={handleApply} className="apply-button">
|
196
|
+
Apply Filters
|
197
|
+
</button>
|
198
|
+
</div>
|
199
|
+
</div>
|
200
|
+
</div>
|
201
|
+
);
|
202
|
+
}
|
@@ -0,0 +1,178 @@
|
|
1
|
+
import React, { useMemo, useCallback, useEffect, useState } from 'react';
|
2
|
+
import { Link, useParams, useNavigate } from 'react-router';
|
3
|
+
import { Datagrid } from './Datagrid';
|
4
|
+
import { ErrorComponent } from '../ErrorComponent';
|
5
|
+
import { LoadingScreen } from '../LoadingScreen';
|
6
|
+
import { AnyClass } from '../../../types/AnyClass';
|
7
|
+
import { getListFields } from '../../../decorators/list/getListFields';
|
8
|
+
import { Pagination } from './Pagination';
|
9
|
+
import { ListData } from '../../../decorators/list/ListData';
|
10
|
+
import CreateIcon from '../../../assets/icons/svg/create.svg';
|
11
|
+
import FilterIcon from '../../../assets/icons/svg/filter.svg';
|
12
|
+
import { FilterPopup } from './FilterPopup';
|
13
|
+
|
14
|
+
export interface GetDataParams {
|
15
|
+
page?: number;
|
16
|
+
limit?: number;
|
17
|
+
filters?: Record<string, any>;
|
18
|
+
}
|
19
|
+
|
20
|
+
export interface PaginatedResponse<T> {
|
21
|
+
data: T[];
|
22
|
+
total: number;
|
23
|
+
page: number;
|
24
|
+
limit: number;
|
25
|
+
}
|
26
|
+
|
27
|
+
export type GetDataForList<T> = (params: GetDataParams) => Promise<PaginatedResponse<T>>;
|
28
|
+
|
29
|
+
const ListHeader = ({
|
30
|
+
listData,
|
31
|
+
filtered,
|
32
|
+
onFilterClick,
|
33
|
+
customHeader,
|
34
|
+
}: {
|
35
|
+
listData: ListData;
|
36
|
+
filtered: boolean;
|
37
|
+
onFilterClick: () => void;
|
38
|
+
customHeader?: React.ReactNode;
|
39
|
+
}) => {
|
40
|
+
const fields = useMemo(() => listData.cells.filter(cell => !!cell.filter), [listData.cells]);
|
41
|
+
|
42
|
+
const header = listData.list?.headers;
|
43
|
+
return (
|
44
|
+
<div className="list-header">
|
45
|
+
<div className="header-title">{header?.title || 'List'}</div>
|
46
|
+
{customHeader && <div className="header-custom">{customHeader}</div>}
|
47
|
+
<div className="header-actions">
|
48
|
+
{!!fields.length && (
|
49
|
+
<button onClick={onFilterClick} className="filter-button">
|
50
|
+
<FilterIcon className={`icon icon-filter ${filtered ? 'active' : ''}`} />
|
51
|
+
Filter
|
52
|
+
</button>
|
53
|
+
)}
|
54
|
+
{header?.create && (
|
55
|
+
<Link to={header.create.path} className="create-button">
|
56
|
+
<CreateIcon className="icon icon-create" />
|
57
|
+
{header.create.label}
|
58
|
+
</Link>
|
59
|
+
)}
|
60
|
+
</div>
|
61
|
+
</div>
|
62
|
+
);
|
63
|
+
};
|
64
|
+
|
65
|
+
export function ListPage<T extends AnyClass & { id: string }>({
|
66
|
+
model,
|
67
|
+
getData,
|
68
|
+
onRemoveItem,
|
69
|
+
customHeader,
|
70
|
+
}: {
|
71
|
+
model: T;
|
72
|
+
getData: GetDataForList<T>;
|
73
|
+
customHeader?: React.ReactNode;
|
74
|
+
onRemoveItem?: (item: T) => Promise<void>;
|
75
|
+
}) {
|
76
|
+
const [loading, setLoading] = useState(true);
|
77
|
+
const [pagination, setPagination] = useState({ total: 0, page: 0, limit: 0 });
|
78
|
+
const [data, setData] = useState<any>(null);
|
79
|
+
const [error, setError] = useState<unknown>(null);
|
80
|
+
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
81
|
+
const [activeFilters, setActiveFilters] = useState<Record<string, string>>();
|
82
|
+
const listData = useMemo(() => getListFields(model), [model]);
|
83
|
+
const params = useParams();
|
84
|
+
const navigate = useNavigate();
|
85
|
+
|
86
|
+
const fetchData = useCallback(
|
87
|
+
async (page: number, filters?: Record<string, string>) => {
|
88
|
+
setLoading(true);
|
89
|
+
try {
|
90
|
+
const result = await getData({ page, filters: filters ?? activeFilters ?? {} });
|
91
|
+
setData(result.data);
|
92
|
+
setPagination({
|
93
|
+
total: result.total,
|
94
|
+
page: result.page,
|
95
|
+
limit: result.limit,
|
96
|
+
});
|
97
|
+
} catch (e) {
|
98
|
+
setError(e);
|
99
|
+
console.error(e);
|
100
|
+
} finally {
|
101
|
+
setLoading(false);
|
102
|
+
}
|
103
|
+
},
|
104
|
+
[getData, activeFilters]
|
105
|
+
);
|
106
|
+
|
107
|
+
useEffect(() => {
|
108
|
+
const searchParams = new URLSearchParams(location.search);
|
109
|
+
const filtersFromUrl: Record<string, string> = {};
|
110
|
+
searchParams.forEach((value, key) => {
|
111
|
+
filtersFromUrl[key] = value;
|
112
|
+
});
|
113
|
+
setActiveFilters(filtersFromUrl);
|
114
|
+
}, [location.search]);
|
115
|
+
|
116
|
+
useEffect(() => {
|
117
|
+
if (activeFilters) {
|
118
|
+
fetchData(parseInt(params.page as string) || 1, activeFilters);
|
119
|
+
}
|
120
|
+
}, [fetchData, params.page, activeFilters]);
|
121
|
+
|
122
|
+
const handleFilterApply = (filters: Record<string, any>) => {
|
123
|
+
setActiveFilters(filters);
|
124
|
+
|
125
|
+
// Convert filters to URLSearchParams
|
126
|
+
const searchParams = new URLSearchParams();
|
127
|
+
Object.entries(filters).forEach(([key, value]) => {
|
128
|
+
if (value !== undefined && value !== null && value !== '') {
|
129
|
+
searchParams.append(key, String(value));
|
130
|
+
}
|
131
|
+
});
|
132
|
+
const queryString = searchParams.toString();
|
133
|
+
const newUrl = `${location.pathname}${queryString ? `?${queryString}` : ''}`;
|
134
|
+
navigate(newUrl);
|
135
|
+
fetchData(1, filters); // Reset to first page when filters change
|
136
|
+
};
|
137
|
+
|
138
|
+
if (loading) return <LoadingScreen />;
|
139
|
+
if (error) return <ErrorComponent error={error} />;
|
140
|
+
|
141
|
+
return (
|
142
|
+
<div className="list">
|
143
|
+
<ListHeader
|
144
|
+
listData={listData}
|
145
|
+
filtered={!!(activeFilters && !!Object.keys(activeFilters).length)}
|
146
|
+
onFilterClick={() => setIsFilterOpen(true)}
|
147
|
+
customHeader={customHeader}
|
148
|
+
/>
|
149
|
+
<Datagrid
|
150
|
+
listData={listData}
|
151
|
+
data={data}
|
152
|
+
onRemoveItem={async (item: T) => {
|
153
|
+
if (onRemoveItem) {
|
154
|
+
alert({
|
155
|
+
title: 'Are you sure you want to delete this item?',
|
156
|
+
message: 'This action cannot be undone.',
|
157
|
+
onConfirm: async () => {
|
158
|
+
await onRemoveItem(item);
|
159
|
+
setData(data.filter((d: T) => d.id !== item.id));
|
160
|
+
await fetchData(pagination.page);
|
161
|
+
},
|
162
|
+
});
|
163
|
+
}
|
164
|
+
}}
|
165
|
+
/>
|
166
|
+
<div className="list-footer">
|
167
|
+
<Pagination pagination={pagination} onPageChange={fetchData} />
|
168
|
+
</div>
|
169
|
+
<FilterPopup
|
170
|
+
isOpen={isFilterOpen}
|
171
|
+
activeFilters={activeFilters}
|
172
|
+
onClose={() => setIsFilterOpen(false)}
|
173
|
+
onApplyFilters={handleFilterApply}
|
174
|
+
listData={listData}
|
175
|
+
/>
|
176
|
+
</div>
|
177
|
+
);
|
178
|
+
}
|
@@ -3,7 +3,7 @@ import { InnerForm } from '../components';
|
|
3
3
|
import { AnyClass } from '../../types/AnyClass';
|
4
4
|
import { getFormFields } from '../../decorators/form/getFormFields';
|
5
5
|
|
6
|
-
export type GetDetailsDataFN<T> = (param: string) => Promise<T>;
|
6
|
+
export type GetDetailsDataFN<T> = (param: Record<string, string>) => Promise<T>;
|
7
7
|
export type OnSubmitFN<T> = (data: T) => Promise<T>;
|
8
8
|
|
9
9
|
export interface FormPageProps<T extends AnyClass> {
|
@@ -11,6 +11,7 @@ export interface FormPageProps<T extends AnyClass> {
|
|
11
11
|
getDetailsData?: GetDetailsDataFN<T>;
|
12
12
|
redirect?: string;
|
13
13
|
onSubmit: OnSubmitFN<T>;
|
14
|
+
redirectBackOnSuccess?: boolean;
|
14
15
|
}
|
15
16
|
|
16
17
|
export function FormPage<T extends AnyClass>({
|
@@ -18,15 +19,16 @@ export function FormPage<T extends AnyClass>({
|
|
18
19
|
getDetailsData,
|
19
20
|
onSubmit,
|
20
21
|
redirect,
|
22
|
+
redirectBackOnSuccess = true,
|
21
23
|
...rest
|
22
24
|
}: FormPageProps<T>) {
|
23
25
|
const formOptions = useMemo(() => getFormFields(model), [model]);
|
24
26
|
return (
|
25
27
|
<InnerForm
|
26
28
|
getDetailsData={getDetailsData}
|
27
|
-
redirect={redirect}
|
28
29
|
onSubmit={onSubmit}
|
29
30
|
formOptions={formOptions}
|
31
|
+
redirectBackOnSuccess={redirectBackOnSuccess}
|
30
32
|
/>
|
31
33
|
);
|
32
34
|
}
|
@@ -8,13 +8,14 @@ const isFieldSensitive = (fieldName: string): boolean => {
|
|
8
8
|
};
|
9
9
|
|
10
10
|
export interface InputOptions {
|
11
|
+
type?: 'input' | 'select' | 'textarea' | 'file-upload' | 'checkbox' | 'hidden' | 'nested';
|
12
|
+
inputType?: 'text' | 'email' | 'tel' | 'password' | 'number' | 'date';
|
11
13
|
name?: string;
|
12
14
|
label?: string;
|
13
15
|
placeholder?: string;
|
14
|
-
inputType?: 'text' | 'email' | 'tel' | 'password' | 'number' | 'date';
|
15
|
-
type?: 'input' | 'select' | 'textarea' | 'file-upload' | 'checkbox' | 'hidden';
|
16
|
-
selectOptions?: string[]; //TODO: label/value
|
17
16
|
cancelPasswordValidationOnEdit?: boolean;
|
17
|
+
options?: { value: string; label: string }[];
|
18
|
+
nestedFields?: InputOptions[];
|
18
19
|
}
|
19
20
|
|
20
21
|
export function Input(options?: InputOptions): PropertyDecorator {
|
@@ -1,22 +1,32 @@
|
|
1
|
-
import
|
1
|
+
import 'reflect-metadata';
|
2
2
|
|
3
|
-
export const CELL_KEY = Symbol(
|
3
|
+
export const CELL_KEY = Symbol('cell');
|
4
|
+
|
5
|
+
interface Filter {
|
6
|
+
type: 'string' | 'number' | 'date' | 'static-select';
|
7
|
+
}
|
8
|
+
|
9
|
+
export interface StaticSelectFilter extends Filter {
|
10
|
+
type: 'static-select';
|
11
|
+
options: { value: string; label: string }[];
|
12
|
+
}
|
4
13
|
|
5
14
|
export interface CellOptions {
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
15
|
+
name?: string;
|
16
|
+
title?: string;
|
17
|
+
type?: 'string' | 'date' | 'image' | 'number';
|
18
|
+
placeHolder?: string;
|
19
|
+
filter?: Filter | StaticSelectFilter;
|
10
20
|
}
|
11
21
|
|
12
22
|
export function Cell(options?: CellOptions): PropertyDecorator {
|
13
|
-
|
14
|
-
|
15
|
-
|
23
|
+
return (target, propertyKey) => {
|
24
|
+
const existingCells: string[] = Reflect.getMetadata(CELL_KEY, target) || [];
|
25
|
+
Reflect.defineMetadata(CELL_KEY, [...existingCells, propertyKey.toString()], target);
|
16
26
|
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
27
|
+
if (options) {
|
28
|
+
const keyString = `${CELL_KEY.toString()}:${propertyKey.toString()}:options`;
|
29
|
+
Reflect.defineMetadata(keyString, options, target);
|
30
|
+
}
|
31
|
+
};
|
22
32
|
}
|
@@ -1,17 +1,31 @@
|
|
1
|
-
import
|
1
|
+
import 'reflect-metadata';
|
2
2
|
|
3
|
-
const LIST_KEY =
|
3
|
+
const LIST_KEY = 'List';
|
4
4
|
|
5
|
-
export interface
|
5
|
+
export interface ListHeaderOptions {
|
6
|
+
title?: string;
|
7
|
+
create?: { path: string; label: string };
|
8
|
+
}
|
9
|
+
|
10
|
+
export interface ListUtilCellOptions {
|
11
|
+
details?: { path: string; label: string };
|
12
|
+
edit?: { path: string; label: string };
|
13
|
+
delete?: { path: string; label: string };
|
14
|
+
}
|
15
|
+
|
16
|
+
export interface ListOptions {
|
17
|
+
headers?: ListHeaderOptions;
|
18
|
+
utilCells?: ListUtilCellOptions;
|
19
|
+
}
|
6
20
|
|
7
21
|
export function List(options?: ListOptions): ClassDecorator {
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
22
|
+
return (target: Function) => {
|
23
|
+
if (options) {
|
24
|
+
Reflect.defineMetadata(LIST_KEY, options, target);
|
25
|
+
}
|
26
|
+
};
|
13
27
|
}
|
14
28
|
|
15
29
|
export function getClassListData(entityClass: any): ListOptions | undefined {
|
16
|
-
|
30
|
+
return Reflect.getMetadata(LIST_KEY, entityClass);
|
17
31
|
}
|