openvsx-webui-test 0.18.0-dev.0 → 0.18.0-dev.1
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/lib/extension-registry-service.d.ts +22 -1
- package/lib/extension-registry-service.d.ts.map +1 -1
- package/lib/extension-registry-service.js +151 -1
- package/lib/extension-registry-service.js.map +1 -1
- package/lib/extension-registry-types.d.ts +41 -0
- package/lib/extension-registry-types.d.ts.map +1 -1
- package/lib/extension-registry-types.js +16 -0
- package/lib/extension-registry-types.js.map +1 -1
- package/lib/pages/admin-dashboard/admin-dashboard.d.ts +3 -0
- package/lib/pages/admin-dashboard/admin-dashboard.d.ts.map +1 -1
- package/lib/pages/admin-dashboard/admin-dashboard.js +33 -3
- package/lib/pages/admin-dashboard/admin-dashboard.js.map +1 -1
- package/lib/pages/admin-dashboard/components/data-grid-filter-operators.d.ts +28 -0
- package/lib/pages/admin-dashboard/components/data-grid-filter-operators.d.ts.map +1 -0
- package/lib/pages/admin-dashboard/components/data-grid-filter-operators.js +93 -0
- package/lib/pages/admin-dashboard/components/data-grid-filter-operators.js.map +1 -0
- package/lib/pages/admin-dashboard/components/index.d.ts +2 -0
- package/lib/pages/admin-dashboard/components/index.d.ts.map +1 -0
- package/lib/pages/admin-dashboard/components/index.js +14 -0
- package/lib/pages/admin-dashboard/components/index.js.map +1 -0
- package/lib/pages/admin-dashboard/customers/customer-form-dialog.d.ts +23 -0
- package/lib/pages/admin-dashboard/customers/customer-form-dialog.d.ts.map +1 -0
- package/lib/pages/admin-dashboard/customers/customer-form-dialog.js +225 -0
- package/lib/pages/admin-dashboard/customers/customer-form-dialog.js.map +1 -0
- package/lib/pages/admin-dashboard/customers/customers.d.ts +15 -0
- package/lib/pages/admin-dashboard/customers/customers.d.ts.map +1 -0
- package/lib/pages/admin-dashboard/customers/customers.js +175 -0
- package/lib/pages/admin-dashboard/customers/customers.js.map +1 -0
- package/lib/pages/admin-dashboard/customers/delete-customer-dialog.d.ts +23 -0
- package/lib/pages/admin-dashboard/customers/delete-customer-dialog.d.ts.map +1 -0
- package/lib/pages/admin-dashboard/customers/delete-customer-dialog.js +64 -0
- package/lib/pages/admin-dashboard/customers/delete-customer-dialog.js.map +1 -0
- package/lib/pages/admin-dashboard/publisher-admin.js +4 -4
- package/lib/pages/admin-dashboard/publisher-admin.js.map +1 -1
- package/lib/pages/admin-dashboard/tiers/delete-tier-dialog.d.ts +23 -0
- package/lib/pages/admin-dashboard/tiers/delete-tier-dialog.d.ts.map +1 -0
- package/lib/pages/admin-dashboard/tiers/delete-tier-dialog.js +55 -0
- package/lib/pages/admin-dashboard/tiers/delete-tier-dialog.js.map +1 -0
- package/lib/pages/admin-dashboard/tiers/tier-form-dialog.d.ts +23 -0
- package/lib/pages/admin-dashboard/tiers/tier-form-dialog.d.ts.map +1 -0
- package/lib/pages/admin-dashboard/tiers/tier-form-dialog.js +215 -0
- package/lib/pages/admin-dashboard/tiers/tier-form-dialog.js.map +1 -0
- package/lib/pages/admin-dashboard/tiers/tiers.d.ts +15 -0
- package/lib/pages/admin-dashboard/tiers/tiers.d.ts.map +1 -0
- package/lib/pages/admin-dashboard/tiers/tiers.js +174 -0
- package/lib/pages/admin-dashboard/tiers/tiers.js.map +1 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats-chart.d.ts +23 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats-chart.d.ts.map +1 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats-chart.js +106 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats-chart.js.map +1 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats-search.d.ts +26 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats-search.d.ts.map +1 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats-search.js +50 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats-search.js.map +1 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats-utils.d.ts +14 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats-utils.d.ts.map +1 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats-utils.js +16 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats-utils.js.map +1 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats.d.ts +15 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats.d.ts.map +1 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats.js +103 -0
- package/lib/pages/admin-dashboard/usage-stats/usage-stats.js.map +1 -0
- package/lib/pages/admin-dashboard/welcome.d.ts.map +1 -1
- package/lib/pages/admin-dashboard/welcome.js +4 -1
- package/lib/pages/admin-dashboard/welcome.js.map +1 -1
- package/lib/server-request.d.ts +1 -1
- package/lib/server-request.d.ts.map +1 -1
- package/lib/server-request.js +3 -3
- package/lib/server-request.js.map +1 -1
- package/package.json +8 -1
- package/src/extension-registry-service.ts +157 -1
- package/src/extension-registry-types.ts +50 -0
- package/src/pages/admin-dashboard/admin-dashboard.tsx +45 -2
- package/src/pages/admin-dashboard/components/data-grid-filter-operators.tsx +126 -0
- package/src/pages/admin-dashboard/components/index.ts +18 -0
- package/src/pages/admin-dashboard/customers/customer-form-dialog.tsx +348 -0
- package/src/pages/admin-dashboard/customers/customers.tsx +279 -0
- package/src/pages/admin-dashboard/customers/delete-customer-dialog.tsx +94 -0
- package/src/pages/admin-dashboard/publisher-admin.tsx +4 -4
- package/src/pages/admin-dashboard/tiers/delete-tier-dialog.tsx +83 -0
- package/src/pages/admin-dashboard/tiers/tier-form-dialog.tsx +372 -0
- package/src/pages/admin-dashboard/tiers/tiers.tsx +254 -0
- package/src/pages/admin-dashboard/usage-stats/usage-stats-chart.tsx +189 -0
- package/src/pages/admin-dashboard/usage-stats/usage-stats-search.tsx +83 -0
- package/src/pages/admin-dashboard/usage-stats/usage-stats-utils.ts +16 -0
- package/src/pages/admin-dashboard/usage-stats/usage-stats.tsx +127 -0
- package/src/pages/admin-dashboard/welcome.tsx +3 -0
- package/src/server-request.ts +3 -3
|
@@ -23,8 +23,16 @@ import HighlightOffIcon from '@mui/icons-material/HighlightOff';
|
|
|
23
23
|
import { Welcome } from './welcome';
|
|
24
24
|
import { PublisherAdmin } from './publisher-admin';
|
|
25
25
|
import PersonIcon from '@mui/icons-material/Person';
|
|
26
|
+
import PeopleIcon from '@mui/icons-material/People';
|
|
26
27
|
import { ScanAdmin } from './scan-admin';
|
|
27
28
|
import SecurityIcon from '@mui/icons-material/Security';
|
|
29
|
+
import StarIcon from '@mui/icons-material/Star';
|
|
30
|
+
import BarChartIcon from '@mui/icons-material/BarChart';
|
|
31
|
+
import { Tiers } from './tiers/tiers';
|
|
32
|
+
import { Customers } from './customers/customers';
|
|
33
|
+
import { UsageStatsView } from './usage-stats/usage-stats';
|
|
34
|
+
import { LoginComponent } from "../../default/login";
|
|
35
|
+
import AccountBoxIcon from "@mui/icons-material/AccountBox";
|
|
28
36
|
|
|
29
37
|
export namespace AdminDashboardRoutes {
|
|
30
38
|
export const ROOT = 'admin-dashboard';
|
|
@@ -33,6 +41,9 @@ export namespace AdminDashboardRoutes {
|
|
|
33
41
|
export const EXTENSION_ADMIN = createRoute([ROOT, 'extensions']);
|
|
34
42
|
export const PUBLISHER_ADMIN = createRoute([ROOT, 'publisher']);
|
|
35
43
|
export const SCANS_ADMIN = createRoute([ROOT, 'scans']);
|
|
44
|
+
export const TIERS = createRoute([ROOT, 'tiers']);
|
|
45
|
+
export const CUSTOMERS = createRoute([ROOT, 'customers']);
|
|
46
|
+
export const USAGE_STATS = createRoute([ROOT, 'usage']);
|
|
36
47
|
}
|
|
37
48
|
|
|
38
49
|
const Message: FunctionComponent<{message: string}> = ({ message }) => {
|
|
@@ -63,6 +74,9 @@ export const AdminDashboard: FunctionComponent<AdminDashboardProps> = props => {
|
|
|
63
74
|
<NavigationItem onOpenRoute={handleOpenRoute} active={currentPage === AdminDashboardRoutes.EXTENSION_ADMIN} label='Extensions' icon={<ExtensionSharpIcon />} route={AdminDashboardRoutes.EXTENSION_ADMIN} />
|
|
64
75
|
<NavigationItem onOpenRoute={handleOpenRoute} active={currentPage === AdminDashboardRoutes.PUBLISHER_ADMIN} label='Publishers' icon={<PersonIcon />} route={AdminDashboardRoutes.PUBLISHER_ADMIN} />
|
|
65
76
|
<NavigationItem onOpenRoute={handleOpenRoute} active={currentPage === AdminDashboardRoutes.SCANS_ADMIN} label='Scans' icon={<SecurityIcon />} route={AdminDashboardRoutes.SCANS_ADMIN} />
|
|
77
|
+
<NavigationItem onOpenRoute={handleOpenRoute} active={currentPage === AdminDashboardRoutes.TIERS} label='Tiers' icon={<StarIcon />} route={AdminDashboardRoutes.TIERS} />
|
|
78
|
+
<NavigationItem onOpenRoute={handleOpenRoute} active={currentPage === AdminDashboardRoutes.CUSTOMERS} label='Customers' icon={<PeopleIcon />} route={AdminDashboardRoutes.CUSTOMERS} />
|
|
79
|
+
<NavigationItem onOpenRoute={handleOpenRoute} active={currentPage?.startsWith(AdminDashboardRoutes.USAGE_STATS)} label='Usage Stats' icon={<BarChartIcon />} route={AdminDashboardRoutes.USAGE_STATS} />
|
|
66
80
|
</Sidepanel>
|
|
67
81
|
<Box
|
|
68
82
|
overflow='auto'
|
|
@@ -93,6 +107,10 @@ export const AdminDashboard: FunctionComponent<AdminDashboardProps> = props => {
|
|
|
93
107
|
<Route path='/extensions' element={<ExtensionAdmin/>} />
|
|
94
108
|
<Route path='/publisher' element={<PublisherAdmin/>} />
|
|
95
109
|
<Route path='/scans' element={<ScanAdmin/>} />
|
|
110
|
+
<Route path='/tiers' element={<Tiers/>} />
|
|
111
|
+
<Route path='/customers' element={<Customers/>} />
|
|
112
|
+
<Route path='/usage' element={<UsageStatsView/>} />
|
|
113
|
+
<Route path='/usage/:customer' element={<UsageStatsView/>} />
|
|
96
114
|
<Route path='*' element={<Welcome/>} />
|
|
97
115
|
</Routes>
|
|
98
116
|
</Container>
|
|
@@ -101,12 +119,37 @@ export const AdminDashboard: FunctionComponent<AdminDashboardProps> = props => {
|
|
|
101
119
|
} else if (user) {
|
|
102
120
|
content = <Message message='You are not authorized as administrator.'/>;
|
|
103
121
|
} else if (!props.userLoading && loginProviders) {
|
|
104
|
-
|
|
122
|
+
|
|
123
|
+
content = <Box display='flex' alignItems='center'>
|
|
124
|
+
<Message message='You are not logged in.'/>
|
|
125
|
+
<Box height='fit-content' alignItems='center' display='flex'>
|
|
126
|
+
<LoginComponent
|
|
127
|
+
loginProviders={loginProviders}
|
|
128
|
+
renderButton={(href, onClick) => {
|
|
129
|
+
if (href) {
|
|
130
|
+
return (<IconButton
|
|
131
|
+
href={href}
|
|
132
|
+
title='Log In'
|
|
133
|
+
aria-label='Log In' >
|
|
134
|
+
<AccountBoxIcon />
|
|
135
|
+
</IconButton>);
|
|
136
|
+
} else {
|
|
137
|
+
return (<IconButton
|
|
138
|
+
onClick={onClick}
|
|
139
|
+
title='Log In'
|
|
140
|
+
aria-label='Log In' >
|
|
141
|
+
<AccountBoxIcon />
|
|
142
|
+
</IconButton>);
|
|
143
|
+
}
|
|
144
|
+
}}
|
|
145
|
+
/>
|
|
146
|
+
</Box>
|
|
147
|
+
</Box>;
|
|
105
148
|
}
|
|
106
149
|
|
|
107
150
|
return <>
|
|
108
151
|
<CssBaseline />
|
|
109
|
-
<Box display='flex' height='100vh'>{content}</Box>
|
|
152
|
+
<Box display='flex' height='100vh' justifyContent='center'>{content}</Box>
|
|
110
153
|
</>;
|
|
111
154
|
};
|
|
112
155
|
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026 Contributors to the Eclipse Foundation.
|
|
3
|
+
*
|
|
4
|
+
* See the NOTICE file(s) distributed with this work for additional
|
|
5
|
+
* information regarding copyright ownership.
|
|
6
|
+
*
|
|
7
|
+
* This program and the accompanying materials are made available under the
|
|
8
|
+
* terms of the Eclipse Public License 2.0 which is available at
|
|
9
|
+
* https://www.eclipse.org/legal/epl-2.0.
|
|
10
|
+
*
|
|
11
|
+
* SPDX-License-Identifier: EPL-2.0
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import React, { FC } from 'react';
|
|
15
|
+
import { Autocomplete, TextField } from '@mui/material';
|
|
16
|
+
import { GridFilterOperator, GridFilterInputValueProps } from '@mui/x-data-grid';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Custom multi-select filter input component for DataGrid columns.
|
|
20
|
+
* Renders an Autocomplete with multiple selection support.
|
|
21
|
+
*/
|
|
22
|
+
export const MultiSelectFilterInput: FC<GridFilterInputValueProps & { options: string[] }> = ({
|
|
23
|
+
item,
|
|
24
|
+
applyValue,
|
|
25
|
+
options
|
|
26
|
+
}) => {
|
|
27
|
+
const handleChange = (_event: React.SyntheticEvent, newValue: string[]) => {
|
|
28
|
+
applyValue({ ...item, value: newValue });
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<Autocomplete
|
|
33
|
+
multiple
|
|
34
|
+
size='small'
|
|
35
|
+
options={options}
|
|
36
|
+
value={(item.value as string[]) || []}
|
|
37
|
+
onChange={handleChange}
|
|
38
|
+
renderInput={(params) => (
|
|
39
|
+
<TextField {...params} variant='standard' placeholder='Filter...' />
|
|
40
|
+
)}
|
|
41
|
+
sx={{ minWidth: 150, mt: 'auto' }}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Creates filter operators for single-value columns with multi-select capability.
|
|
48
|
+
* Includes "is any of" and "is none of" operators.
|
|
49
|
+
*
|
|
50
|
+
* @param options - Array of possible values to select from
|
|
51
|
+
* @returns Array of GridFilterOperator for use in column definition
|
|
52
|
+
*
|
|
53
|
+
*/
|
|
54
|
+
export const createMultiSelectFilterOperators = (options: string[]): GridFilterOperator[] => [
|
|
55
|
+
{
|
|
56
|
+
label: 'is any of',
|
|
57
|
+
value: 'isAnyOf',
|
|
58
|
+
getApplyFilterFn: (filterItem) => {
|
|
59
|
+
if (!filterItem.value || (filterItem.value as string[]).length === 0) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const filterValues = filterItem.value as string[];
|
|
63
|
+
|
|
64
|
+
return (value) => filterValues.indexOf(value as string) !== -1;
|
|
65
|
+
},
|
|
66
|
+
InputComponent: (props: GridFilterInputValueProps) => (
|
|
67
|
+
<MultiSelectFilterInput {...props} options={options} />
|
|
68
|
+
),
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
label: 'is none of',
|
|
72
|
+
value: 'isNoneOf',
|
|
73
|
+
getApplyFilterFn: (filterItem) => {
|
|
74
|
+
if (!filterItem.value || (filterItem.value as string[]).length === 0) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const filterValues = filterItem.value as string[];
|
|
78
|
+
|
|
79
|
+
return (value) => filterValues.indexOf(value as string) === -1;
|
|
80
|
+
},
|
|
81
|
+
InputComponent: (props: GridFilterInputValueProps) => (
|
|
82
|
+
<MultiSelectFilterInput {...props} options={options} />
|
|
83
|
+
),
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Creates filter operators for array-type columns with multi-select capability.
|
|
89
|
+
* Includes "contains any of" and "contains none of" operators.
|
|
90
|
+
*
|
|
91
|
+
* @param options - Array of possible values to select from
|
|
92
|
+
* @returns Array of GridFilterOperator for use in column definition
|
|
93
|
+
*
|
|
94
|
+
*/
|
|
95
|
+
export const createArrayContainsFilterOperators = (options: string[]): GridFilterOperator[] => [
|
|
96
|
+
{
|
|
97
|
+
label: 'contains any of',
|
|
98
|
+
value: 'containsAnyOf',
|
|
99
|
+
getApplyFilterFn: (filterItem) => {
|
|
100
|
+
if (!filterItem.value || (filterItem.value as string[]).length === 0) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
const filterValues = filterItem.value as string[];
|
|
104
|
+
|
|
105
|
+
return (value) => filterValues.some(fv => (value as string[]).indexOf(fv) !== -1);
|
|
106
|
+
},
|
|
107
|
+
InputComponent: (props: GridFilterInputValueProps) => (
|
|
108
|
+
<MultiSelectFilterInput {...props} options={options} />
|
|
109
|
+
),
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
label: 'contains none of',
|
|
113
|
+
value: 'containsNoneOf',
|
|
114
|
+
getApplyFilterFn: (filterItem) => {
|
|
115
|
+
if (!filterItem.value || (filterItem.value as string[]).length === 0) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const filterValues = filterItem.value as string[];
|
|
119
|
+
|
|
120
|
+
return (value) => !filterValues.some(fv => (value as string[]).indexOf(fv) !== -1);
|
|
121
|
+
},
|
|
122
|
+
InputComponent: (props: GridFilterInputValueProps) => (
|
|
123
|
+
<MultiSelectFilterInput {...props} options={options} />
|
|
124
|
+
),
|
|
125
|
+
},
|
|
126
|
+
];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026 Contributors to the Eclipse Foundation.
|
|
3
|
+
*
|
|
4
|
+
* See the NOTICE file(s) distributed with this work for additional
|
|
5
|
+
* information regarding copyright ownership.
|
|
6
|
+
*
|
|
7
|
+
* This program and the accompanying materials are made available under the
|
|
8
|
+
* terms of the Eclipse Public License 2.0 which is available at
|
|
9
|
+
* https://www.eclipse.org/legal/epl-2.0.
|
|
10
|
+
*
|
|
11
|
+
* SPDX-License-Identifier: EPL-2.0
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
MultiSelectFilterInput,
|
|
16
|
+
createMultiSelectFilterOperators,
|
|
17
|
+
createArrayContainsFilterOperators
|
|
18
|
+
} from './data-grid-filter-operators';
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
|
|
2
|
+
/******************************************************************************
|
|
3
|
+
* Copyright (c) 2026 Contributors to the Eclipse Foundation.
|
|
4
|
+
*
|
|
5
|
+
* See the NOTICE file(s) distributed with this work for additional
|
|
6
|
+
* information regarding copyright ownership.
|
|
7
|
+
*
|
|
8
|
+
* This program and the accompanying materials are made available under the
|
|
9
|
+
* terms of the Eclipse Public License 2.0 which is available at
|
|
10
|
+
* https://www.eclipse.org/legal/epl-2.0.
|
|
11
|
+
*
|
|
12
|
+
* SPDX-License-Identifier: EPL-2.0
|
|
13
|
+
*****************************************************************************/
|
|
14
|
+
|
|
15
|
+
import React, { FC, useState, useEffect, useRef } from 'react';
|
|
16
|
+
import {
|
|
17
|
+
Dialog,
|
|
18
|
+
DialogTitle,
|
|
19
|
+
DialogContent,
|
|
20
|
+
DialogActions,
|
|
21
|
+
TextField,
|
|
22
|
+
Button,
|
|
23
|
+
FormControl,
|
|
24
|
+
InputLabel,
|
|
25
|
+
Select,
|
|
26
|
+
MenuItem,
|
|
27
|
+
CircularProgress,
|
|
28
|
+
Alert,
|
|
29
|
+
Box,
|
|
30
|
+
Autocomplete,
|
|
31
|
+
FormHelperText,
|
|
32
|
+
styled
|
|
33
|
+
} from '@mui/material';
|
|
34
|
+
import type { SelectChangeEvent } from '@mui/material';
|
|
35
|
+
import { type Customer, EnforcementState, type Tier } from "../../../extension-registry-types";
|
|
36
|
+
import { MainContext } from "../../../context";
|
|
37
|
+
import { handleError } from "../../../utils";
|
|
38
|
+
|
|
39
|
+
interface CustomerFormDialogProps {
|
|
40
|
+
open: boolean;
|
|
41
|
+
customer?: Customer;
|
|
42
|
+
onClose: () => void;
|
|
43
|
+
onSubmit: (formData: Customer) => Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
const Code = styled('code')(({ theme }) => ({
|
|
48
|
+
fontFamily: 'source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace',
|
|
49
|
+
backgroundColor: theme.palette.action.hover, // Subtle gray background
|
|
50
|
+
padding: '2px 6px',
|
|
51
|
+
borderRadius: '4px',
|
|
52
|
+
fontSize: '0.9em',
|
|
53
|
+
color: theme.palette.text.primary,
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
export const CustomerFormDialog: FC<CustomerFormDialogProps> = ({ open, customer, onClose, onSubmit }) => {
|
|
57
|
+
const abortController = useRef<AbortController>(new AbortController());
|
|
58
|
+
const { service } = React.useContext(MainContext);
|
|
59
|
+
const [formData, setFormData] = useState<Customer>({
|
|
60
|
+
name: '',
|
|
61
|
+
tier: undefined,
|
|
62
|
+
state: EnforcementState.ENFORCEMENT,
|
|
63
|
+
cidrBlocks: []
|
|
64
|
+
});
|
|
65
|
+
const [tiers, setTiers] = useState<Tier[]>([]);
|
|
66
|
+
const [loading, setLoading] = useState(false);
|
|
67
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
68
|
+
const [touched, setTouched] = useState<Record<string, boolean>>({});
|
|
69
|
+
|
|
70
|
+
const loadTiers = async () => {
|
|
71
|
+
try {
|
|
72
|
+
const data = await service.admin.getTiers(abortController.current);
|
|
73
|
+
setTiers(data.tiers);
|
|
74
|
+
} catch (err: any) {
|
|
75
|
+
console.error('Failed to load tiers:', err);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
loadTiers();
|
|
81
|
+
return () => abortController.current.abort();
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (customer) {
|
|
86
|
+
setFormData({
|
|
87
|
+
name: customer.name,
|
|
88
|
+
tier: customer.tier,
|
|
89
|
+
state: customer.state,
|
|
90
|
+
cidrBlocks: customer.cidrBlocks
|
|
91
|
+
});
|
|
92
|
+
} else {
|
|
93
|
+
setFormData({
|
|
94
|
+
name: '',
|
|
95
|
+
tier: tiers.length > 0 ? tiers[0] : undefined,
|
|
96
|
+
state: EnforcementState.ENFORCEMENT,
|
|
97
|
+
cidrBlocks: []
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
setErrors({});
|
|
101
|
+
setTouched({});
|
|
102
|
+
}, [open, customer, tiers]);
|
|
103
|
+
|
|
104
|
+
const clearFieldError = (fieldName: string) => {
|
|
105
|
+
if (errors[fieldName]) {
|
|
106
|
+
setErrors(prev => {
|
|
107
|
+
const newErrors = { ...prev };
|
|
108
|
+
delete newErrors[fieldName];
|
|
109
|
+
return newErrors;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | SelectChangeEvent) => {
|
|
115
|
+
const { name, value } = e.target;
|
|
116
|
+
clearFieldError(name);
|
|
117
|
+
|
|
118
|
+
if (name === 'tierName') {
|
|
119
|
+
const tier = tiers.find((tier) => tier.name === value);
|
|
120
|
+
setFormData(prev => ({
|
|
121
|
+
...prev,
|
|
122
|
+
tier: tier,
|
|
123
|
+
}));
|
|
124
|
+
} else {
|
|
125
|
+
setFormData(prev => ({ ...prev, [name]: value }));
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
130
|
+
const { name } = e.target;
|
|
131
|
+
setTouched(prev => ({ ...prev, [name]: true }));
|
|
132
|
+
|
|
133
|
+
// Validate the specific field on blur
|
|
134
|
+
validateField(name);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const fieldValidators: Record<string, () => string | undefined> = {
|
|
138
|
+
name: () => {
|
|
139
|
+
if (formData.name === undefined) {
|
|
140
|
+
return "Customer name is required";
|
|
141
|
+
} else if (formData.name.trim() !== formData.name) {
|
|
142
|
+
return "Customer name must not contain trailing whitespace";
|
|
143
|
+
} else {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
tierName: () => formData.tier?.name ? undefined : 'Tier selection is required',
|
|
148
|
+
state: () => formData.state ? undefined : 'State is required',
|
|
149
|
+
cidrBlocks: () => {
|
|
150
|
+
if (formData.cidrBlocks && formData.cidrBlocks.length > 0) {
|
|
151
|
+
const invalidEntries = formData.cidrBlocks.filter(cidr => !isValidCIDR(cidr.trim()));
|
|
152
|
+
if (invalidEntries.length > 0) {
|
|
153
|
+
return `Invalid CIDR block(s): ${invalidEntries.join(', ')}`;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return undefined;
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const validateField = (fieldName: string): string | undefined => {
|
|
161
|
+
const validator = fieldValidators[fieldName];
|
|
162
|
+
const error = validator?.();
|
|
163
|
+
|
|
164
|
+
if (error) {
|
|
165
|
+
setErrors(prev => ({ ...prev, [fieldName]: error }));
|
|
166
|
+
}
|
|
167
|
+
return error;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const isValidCIDR = (cidr: string): boolean => {
|
|
171
|
+
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/;
|
|
172
|
+
const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}(\/\d{1,3})?$/;
|
|
173
|
+
return ipv4Regex.test(cidr) || ipv6Regex.test(cidr);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const handleCidrBlocksChange = (event: any, value: string[]) => {
|
|
177
|
+
clearFieldError('cidrBlocks');
|
|
178
|
+
|
|
179
|
+
// Validate all entries
|
|
180
|
+
const invalidEntries = value.filter(cidr => !isValidCIDR(cidr.trim()));
|
|
181
|
+
|
|
182
|
+
if (invalidEntries.length > 0) {
|
|
183
|
+
setTouched(prev => ({ ...prev, cidrBlocks: true }));
|
|
184
|
+
setErrors(prev => ({
|
|
185
|
+
...prev,
|
|
186
|
+
cidrBlocks: `Invalid CIDR block(s): ${invalidEntries.join(', ')}. Please enter valid IPv4 or IPv6 CIDR notation.`
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Always update the value so the user can see and correct invalid entries
|
|
191
|
+
setFormData(prev => ({
|
|
192
|
+
...prev,
|
|
193
|
+
cidrBlocks: value.map(cidr => cidr.trim()),
|
|
194
|
+
}));
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const validateForm = (): boolean => {
|
|
198
|
+
// Mark all fields as touched on submit
|
|
199
|
+
setTouched({
|
|
200
|
+
name: true,
|
|
201
|
+
tierName: true,
|
|
202
|
+
state: true,
|
|
203
|
+
cidrBlocks: true,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const newErrors: Record<string, string> = {};
|
|
207
|
+
for (const key of Object.keys(formData)) {
|
|
208
|
+
const error = validateField(key);
|
|
209
|
+
if (error !== undefined) {
|
|
210
|
+
newErrors[key] = error;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
setErrors(newErrors);
|
|
215
|
+
return Object.keys(newErrors).length === 0;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const handleSubmit = async () => {
|
|
219
|
+
if (!validateForm()) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
setLoading(true);
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
await onSubmit(formData);
|
|
227
|
+
onClose();
|
|
228
|
+
} catch (err: any) {
|
|
229
|
+
setErrors({ submit: handleError(err) });
|
|
230
|
+
} finally {
|
|
231
|
+
setLoading(false);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const isEditMode = !!customer;
|
|
236
|
+
const title = isEditMode ? 'Edit Customer' : 'Create New Customer';
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<Dialog open={open} onClose={onClose} maxWidth='sm' fullWidth>
|
|
240
|
+
<DialogTitle>{title}</DialogTitle>
|
|
241
|
+
<DialogContent>
|
|
242
|
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
|
|
243
|
+
{errors.submit && (
|
|
244
|
+
<Alert severity='error'>{errors.submit}</Alert>
|
|
245
|
+
)}
|
|
246
|
+
|
|
247
|
+
<TextField
|
|
248
|
+
label='Customer Name'
|
|
249
|
+
name='name'
|
|
250
|
+
value={formData.name}
|
|
251
|
+
onChange={handleChange}
|
|
252
|
+
onBlur={handleBlur}
|
|
253
|
+
fullWidth
|
|
254
|
+
placeholder='e.g., Acme Corp, TechStart Inc'
|
|
255
|
+
required={true}
|
|
256
|
+
disabled={loading}
|
|
257
|
+
error={touched.name && !!errors.name}
|
|
258
|
+
helperText={touched.name && errors.name}
|
|
259
|
+
/>
|
|
260
|
+
|
|
261
|
+
<FormControl fullWidth disabled={loading} required={true} error={touched.tierName && !!errors.tierName}>
|
|
262
|
+
<InputLabel>Tier</InputLabel>
|
|
263
|
+
<Select
|
|
264
|
+
name='tierName'
|
|
265
|
+
value={formData.tier?.name ?? ''}
|
|
266
|
+
onChange={handleChange}
|
|
267
|
+
onBlur={(e) => {
|
|
268
|
+
setTouched(prev => ({ ...prev, tierName: true }));
|
|
269
|
+
validateField('tierName');
|
|
270
|
+
}}
|
|
271
|
+
label='Tier'
|
|
272
|
+
>
|
|
273
|
+
{tiers
|
|
274
|
+
.filter(tier => tier.tierType === 'NON_FREE')
|
|
275
|
+
.map(tier => (
|
|
276
|
+
<MenuItem key={tier.name} value={tier.name}>
|
|
277
|
+
{tier.name}
|
|
278
|
+
</MenuItem>
|
|
279
|
+
))}
|
|
280
|
+
</Select>
|
|
281
|
+
{touched.tierName && errors.tierName && <FormHelperText>{errors.tierName}</FormHelperText>}
|
|
282
|
+
</FormControl>
|
|
283
|
+
|
|
284
|
+
<FormControl fullWidth disabled={loading} required={true} error={touched.state && !!errors.state}>
|
|
285
|
+
<InputLabel>State</InputLabel>
|
|
286
|
+
<Select
|
|
287
|
+
name='state'
|
|
288
|
+
value={formData.state}
|
|
289
|
+
onChange={handleChange}
|
|
290
|
+
onBlur={(e) => {
|
|
291
|
+
setTouched(prev => ({ ...prev, state: true }));
|
|
292
|
+
validateField('state');
|
|
293
|
+
}}
|
|
294
|
+
label='State'
|
|
295
|
+
>
|
|
296
|
+
{Object.keys(EnforcementState).map(key => (
|
|
297
|
+
<MenuItem key={key} value={EnforcementState[key as keyof typeof EnforcementState]}>
|
|
298
|
+
{key}
|
|
299
|
+
</MenuItem>
|
|
300
|
+
))}
|
|
301
|
+
</Select>
|
|
302
|
+
{touched.state && errors.state && <FormHelperText>{errors.state}</FormHelperText>}
|
|
303
|
+
</FormControl>
|
|
304
|
+
|
|
305
|
+
<Autocomplete
|
|
306
|
+
multiple
|
|
307
|
+
freeSolo
|
|
308
|
+
limitTags={5}
|
|
309
|
+
disabled={loading}
|
|
310
|
+
options={[]}
|
|
311
|
+
value={formData.cidrBlocks || []}
|
|
312
|
+
onChange={handleCidrBlocksChange}
|
|
313
|
+
onBlur={() => {
|
|
314
|
+
setTouched(prev => ({ ...prev, cidrBlocks: true }));
|
|
315
|
+
validateField('cidrBlocks');
|
|
316
|
+
}}
|
|
317
|
+
renderInput={(params) => (
|
|
318
|
+
<TextField
|
|
319
|
+
{...params}
|
|
320
|
+
label='CIDR Blocks'
|
|
321
|
+
placeholder='e.g., 192.168.1.0/24'
|
|
322
|
+
error={touched.cidrBlocks && !!errors.cidrBlocks}
|
|
323
|
+
helperText={(touched.cidrBlocks && errors.cidrBlocks) || (
|
|
324
|
+
<>Enter CIDR blocks and press <Code>Enter</Code> to add each one</>
|
|
325
|
+
)}
|
|
326
|
+
/>
|
|
327
|
+
)}
|
|
328
|
+
/>
|
|
329
|
+
|
|
330
|
+
</Box>
|
|
331
|
+
</DialogContent>
|
|
332
|
+
|
|
333
|
+
<DialogActions sx={{ p: 2 }}>
|
|
334
|
+
<Button onClick={onClose} disabled={loading}>
|
|
335
|
+
Cancel
|
|
336
|
+
</Button>
|
|
337
|
+
<Button
|
|
338
|
+
onClick={handleSubmit}
|
|
339
|
+
variant='contained'
|
|
340
|
+
disabled={loading || Object.keys(errors).length > 0}
|
|
341
|
+
startIcon={loading ? <CircularProgress size={20} /> : undefined}
|
|
342
|
+
>
|
|
343
|
+
{isEditMode ? 'Update' : 'Create'}
|
|
344
|
+
</Button>
|
|
345
|
+
</DialogActions>
|
|
346
|
+
</Dialog>
|
|
347
|
+
);
|
|
348
|
+
};
|