Skip to content

Commit 4e12041

Browse files
Feat/426 node config UI (#454)
* add new node config UI * update node config UI from node details page * fix modal scrolling * fix mui scrollock * add duration input * update duration inputs * update type * rename type * update resource config UI * add readonly badge * display all available resources for all envs * remove env * configure node/envs access * add config for storage expiry, enable network, payment claim, persistent storage * update type * select chain when adding network * update style * add error duplicate network * Update src/components/node-config/configure-indexer.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * remove cast --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
1 parent 66c40e4 commit 4e12041

19 files changed

Lines changed: 1975 additions & 146 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
.controls {
2+
align-items: center;
3+
display: flex;
4+
gap: 8px;
5+
}
6+
7+
.unitSelect {
8+
background: transparent;
9+
border: none;
10+
color: var(--text-primary);
11+
cursor: pointer;
12+
font-family: var(--font-inter), sans-serif;
13+
font-size: 16px;
14+
font-weight: 500;
15+
padding: 4px 4px 4px 0;
16+
}
17+
18+
.unitSelect:focus {
19+
outline: none;
20+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import Button from '@/components/button/button';
2+
import Input from '@/components/input/input';
3+
import { type DurationUnit, fromSeconds, toSeconds } from '@/utils/duration';
4+
import React, { useEffect, useRef, useState } from 'react';
5+
import styles from './duration-input.module.css';
6+
7+
type DurationUnitOption = {
8+
label: string;
9+
value: DurationUnit;
10+
};
11+
12+
type DurationInputProps = {
13+
availableUnits: DurationUnitOption[];
14+
defaultUnit?: DurationUnit;
15+
disabled?: boolean;
16+
errorText?: string;
17+
hint?: React.ReactNode;
18+
label?: React.ReactNode;
19+
max?: number; // seconds
20+
min?: number; // seconds
21+
name?: string;
22+
onBlur?: (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
23+
onChange: (seconds: number) => void;
24+
onSetMax?: () => void;
25+
size?: 'sm' | 'md';
26+
topRight?: React.ReactNode;
27+
value: number; // seconds
28+
};
29+
30+
const DurationInput: React.FC<DurationInputProps> = ({
31+
availableUnits,
32+
defaultUnit = 'seconds',
33+
disabled,
34+
errorText,
35+
hint,
36+
label,
37+
max,
38+
min,
39+
name,
40+
onBlur,
41+
onChange,
42+
onSetMax,
43+
size,
44+
topRight,
45+
value,
46+
}) => {
47+
const [unit, setUnit] = useState<DurationUnit>(defaultUnit);
48+
const [displayValue, setDisplayValue] = useState<number | ''>(fromSeconds(value, defaultUnit));
49+
const sentSecondsRef = useRef<number>(value);
50+
51+
useEffect(() => {
52+
if (value !== sentSecondsRef.current) {
53+
sentSecondsRef.current = value;
54+
setDisplayValue(fromSeconds(value, unit));
55+
}
56+
}, [value, unit]);
57+
58+
const handleValueChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
59+
if (e.target.value === '') {
60+
setDisplayValue('');
61+
sentSecondsRef.current = 0;
62+
onChange(0);
63+
return;
64+
}
65+
const num = Math.max(0, Number(e.target.value));
66+
setDisplayValue(num);
67+
const seconds = toSeconds(num, unit);
68+
sentSecondsRef.current = seconds;
69+
onChange(seconds);
70+
};
71+
72+
const handleUnitChange = (newUnit: DurationUnit) => {
73+
const currentSeconds = toSeconds(Number(displayValue) || 0, unit);
74+
setUnit(newUnit);
75+
setDisplayValue(fromSeconds(currentSeconds, newUnit));
76+
};
77+
78+
return (
79+
<Input
80+
disabled={disabled}
81+
endAdornment={
82+
<div className={styles.controls}>
83+
<select
84+
aria-label="Duration unit"
85+
className={styles.unitSelect}
86+
disabled={disabled}
87+
onChange={(e) => handleUnitChange(e.target.value as DurationUnit)}
88+
value={unit}
89+
>
90+
{availableUnits.map((opt) => (
91+
<option key={opt.value} value={opt.value}>
92+
{opt.label}
93+
</option>
94+
))}
95+
</select>
96+
{onSetMax ? (
97+
<Button color="accent2" onClick={onSetMax} size="sm" type="button" variant="filled">
98+
Set max
99+
</Button>
100+
) : null}
101+
</div>
102+
}
103+
errorText={errorText}
104+
hint={hint}
105+
label={label}
106+
max={max !== undefined ? fromSeconds(max, unit) : undefined}
107+
min={min !== undefined ? fromSeconds(min, unit) : undefined}
108+
name={name}
109+
onBlur={onBlur}
110+
onChange={handleValueChange}
111+
size={size}
112+
topRight={topRight}
113+
type="number"
114+
value={displayValue}
115+
/>
116+
);
117+
};
118+
119+
export default DurationInput;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
.accessHeader {
2+
align-items: center;
3+
cursor: pointer;
4+
display: flex;
5+
flex-wrap: wrap;
6+
gap: 8px;
7+
user-select: none;
8+
}
9+
10+
.accessTitle {
11+
align-items: center;
12+
display: flex;
13+
font-size: 16px;
14+
font-weight: 700;
15+
gap: 6px;
16+
17+
.icon {
18+
color: var(--text-secondary);
19+
transition: transform 0.25s ease;
20+
21+
&.iconOpen {
22+
transform: rotate(180deg);
23+
}
24+
}
25+
}
26+
27+
.accessChips {
28+
align-items: center;
29+
display: flex;
30+
gap: 6px;
31+
}
32+
33+
.accessEditor {
34+
display: flex;
35+
flex-direction: column;
36+
gap: 12px;
37+
padding-top: 12px;
38+
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import Button from '@/components/button/button';
2+
import Input from '@/components/input/input';
3+
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
4+
import { Collapse } from '@mui/material';
5+
import classNames from 'classnames';
6+
import { isAddress } from 'ethers';
7+
import { useState } from 'react';
8+
import styles from './access-editor.module.css';
9+
import commonStyles from './node-config.module.css';
10+
11+
type AccessEditorProps = {
12+
accessListAddresses: string[];
13+
disabled?: boolean;
14+
onAccessListAddressesChange: (addresses: string[]) => void;
15+
onWalletAddressesChange: (addresses: string[]) => void;
16+
title: string;
17+
walletAddresses: string[];
18+
};
19+
20+
const AccessEditor: React.FC<AccessEditorProps> = ({
21+
accessListAddresses,
22+
disabled,
23+
onAccessListAddressesChange,
24+
onWalletAddressesChange,
25+
title,
26+
walletAddresses,
27+
}) => {
28+
const [isOpen, setIsOpen] = useState(false);
29+
const [newWallet, setNewWallet] = useState('');
30+
const [walletError, setWalletError] = useState<string | null>(null);
31+
const [newContract, setNewContract] = useState('');
32+
const [contractError, setContractError] = useState<string | null>(null);
33+
34+
const handleAddWallet = () => {
35+
const trimmed = newWallet.trim();
36+
if (!trimmed) return;
37+
if (!isAddress(trimmed)) {
38+
setWalletError('Invalid Ethereum address');
39+
return;
40+
}
41+
if (walletAddresses.some((a) => a.toLowerCase() === trimmed.toLowerCase())) {
42+
setWalletError('Address already in list');
43+
return;
44+
}
45+
setWalletError(null);
46+
onWalletAddressesChange([...walletAddresses, trimmed]);
47+
setNewWallet('');
48+
};
49+
50+
const handleRemoveWallet = (address: string) => {
51+
onWalletAddressesChange(walletAddresses.filter((a) => a.toLowerCase() !== address.toLowerCase()));
52+
};
53+
54+
const handleAddContract = () => {
55+
const trimmed = newContract.trim();
56+
if (!trimmed) return;
57+
if (!isAddress(trimmed)) {
58+
setContractError('Invalid Ethereum address');
59+
return;
60+
}
61+
if (accessListAddresses.some((a) => a.toLowerCase() === trimmed.toLowerCase())) {
62+
setContractError('Contract already in list');
63+
return;
64+
}
65+
setContractError(null);
66+
onAccessListAddressesChange([...accessListAddresses, trimmed]);
67+
setNewContract('');
68+
};
69+
70+
const handleRemoveContract = (address: string) => {
71+
onAccessListAddressesChange(accessListAddresses.filter((a) => a.toLowerCase() !== address.toLowerCase()));
72+
};
73+
74+
return (
75+
<div>
76+
<div className={styles.accessHeader} onClick={() => setIsOpen((o) => !o)}>
77+
<div className={styles.accessTitle}>
78+
<span>{title}</span>
79+
<ExpandMoreIcon className={classNames(styles.icon, { [styles.iconOpen]: isOpen })} />
80+
</div>
81+
<div className={styles.accessChips}>
82+
<span className="chip chipGlass">{walletAddresses.length} wallets</span>
83+
<span className="chip chipGlass">{accessListAddresses.length} access lists</span>
84+
</div>
85+
</div>
86+
<Collapse in={isOpen}>
87+
<div className={styles.accessEditor}>
88+
<h4 className={commonStyles.subsectionTitle}>Wallet addresses</h4>
89+
{walletAddresses.length === 0 ? (
90+
<span className="textSecondary">No wallet addresses. Open to all.</span>
91+
) : (
92+
<div className={commonStyles.addressList}>
93+
{walletAddresses.map((addr) => (
94+
<div className={commonStyles.addressRow} key={addr}>
95+
<span className={commonStyles.addressText}>{addr}</span>
96+
{!disabled ? (
97+
<Button color="error" onClick={() => handleRemoveWallet(addr)} size="sm" type="button" variant="transparent">
98+
Remove
99+
</Button>
100+
) : null}
101+
</div>
102+
))}
103+
</div>
104+
)}
105+
{!disabled ? (
106+
<div className={commonStyles.addressAddRow}>
107+
<Input
108+
errorText={walletError ?? undefined}
109+
label="Add wallet address"
110+
onChange={(e) => {
111+
setNewWallet(e.target.value);
112+
setWalletError(null);
113+
}}
114+
onKeyDown={(e) => e.key === 'Enter' && handleAddWallet()}
115+
placeholder="0x..."
116+
size="sm"
117+
type="text"
118+
value={newWallet}
119+
/>
120+
<Button color="accent1" onClick={handleAddWallet} size="md" type="button" variant="filled">
121+
Add
122+
</Button>
123+
</div>
124+
) : null}
125+
126+
<h4 className={commonStyles.subsectionTitle}>Access list contracts</h4>
127+
{accessListAddresses.length === 0 ? (
128+
<span className="textSecondary">No access list contracts. Open to all.</span>
129+
) : (
130+
<div className={commonStyles.addressList}>
131+
{accessListAddresses.map((addr) => (
132+
<div className={commonStyles.addressRow} key={addr}>
133+
<span className={commonStyles.addressText}>{addr}</span>
134+
{!disabled ? (
135+
<Button color="error" onClick={() => handleRemoveContract(addr)} size="sm" type="button" variant="transparent">
136+
Remove
137+
</Button>
138+
) : null}
139+
</div>
140+
))}
141+
</div>
142+
)}
143+
{!disabled ? (
144+
<div className={commonStyles.addressAddRow}>
145+
<Input
146+
errorText={contractError ?? undefined}
147+
label="Add access list contract"
148+
onChange={(e) => {
149+
setNewContract(e.target.value);
150+
setContractError(null);
151+
}}
152+
onKeyDown={(e) => e.key === 'Enter' && handleAddContract()}
153+
placeholder="0x..."
154+
size="sm"
155+
type="text"
156+
value={newContract}
157+
/>
158+
<Button color="accent1" onClick={handleAddContract} size="md" type="button" variant="filled">
159+
Add
160+
</Button>
161+
</div>
162+
) : null}
163+
</div>
164+
</Collapse>
165+
</div>
166+
);
167+
};
168+
169+
export default AccessEditor;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.toggleRow {
2+
align-items: center;
3+
align-self: flex-start;
4+
display: flex;
5+
flex-wrap: wrap;
6+
gap: 16px;
7+
}

0 commit comments

Comments
 (0)