652 lines
27 KiB
JavaScript
652 lines
27 KiB
JavaScript
import React, { Component } from 'react';
|
|
import {
|
|
Container, Typography, Paper, List, ListItem, ListItemText, ListItemIcon,
|
|
Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions,
|
|
FormControl, InputLabel, Select, MenuItem, Box, IconButton, Switch,
|
|
FormControlLabel, Chip, Divider, Tooltip
|
|
} from '@mui/material';
|
|
import RuleIcon from '@mui/icons-material/Rule';
|
|
import AddIcon from '@mui/icons-material/Add';
|
|
import DeleteIcon from '@mui/icons-material/Delete';
|
|
import EditIcon from '@mui/icons-material/Edit';
|
|
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
|
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
|
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
|
import PauseIcon from '@mui/icons-material/Pause';
|
|
|
|
// Condition operators by type
|
|
const CONDITION_OPERATORS = {
|
|
time: [
|
|
{ value: 'between', label: 'Between' },
|
|
{ value: '=', label: '=' },
|
|
{ value: '<', label: '<' },
|
|
{ value: '>', label: '>' }
|
|
],
|
|
date: [
|
|
{ value: 'before', label: 'Before' },
|
|
{ value: 'after', label: 'After' },
|
|
{ value: 'between', label: 'Between' }
|
|
],
|
|
sensor: [
|
|
{ value: '=', label: '=' },
|
|
{ value: '!=', label: '!=' },
|
|
{ value: '<', label: '<' },
|
|
{ value: '>', label: '>' },
|
|
{ value: '<=', label: '<=' },
|
|
{ value: '>=', label: '>=' }
|
|
],
|
|
output: [
|
|
{ value: '=', label: '=' },
|
|
{ value: '!=', label: '!=' },
|
|
{ value: '<', label: '<' },
|
|
{ value: '>', label: '>' },
|
|
{ value: '<=', label: '<=' },
|
|
{ value: '>=', label: '>=' }
|
|
]
|
|
};
|
|
|
|
class RuleEditor extends Component {
|
|
constructor(props) {
|
|
super(props);
|
|
this.state = {
|
|
rules: [],
|
|
outputChannels: [],
|
|
devices: [],
|
|
outputValues: {},
|
|
|
|
// Dialog state
|
|
open: false,
|
|
editingId: null,
|
|
ruleName: '',
|
|
ruleEnabled: true,
|
|
conditions: { operator: 'AND', conditions: [] },
|
|
action: { channel: '', value: 0 }
|
|
};
|
|
}
|
|
|
|
componentDidMount() {
|
|
this.refreshRules();
|
|
this.loadOutputChannels();
|
|
this.loadDevices();
|
|
this.loadOutputValues();
|
|
// Refresh output values every 10s
|
|
this.refreshInterval = setInterval(() => this.loadOutputValues(), 10000);
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
if (this.refreshInterval) clearInterval(this.refreshInterval);
|
|
}
|
|
|
|
isAdmin() {
|
|
const { user } = this.props;
|
|
return user && user.role === 'admin';
|
|
}
|
|
|
|
refreshRules = () => {
|
|
fetch('/api/rules')
|
|
.then(res => res.json())
|
|
.then(rules => this.setState({ rules }))
|
|
.catch(console.error);
|
|
};
|
|
|
|
loadOutputChannels = () => {
|
|
fetch('/api/outputs')
|
|
.then(res => res.json())
|
|
.then(outputChannels => this.setState({ outputChannels }))
|
|
.catch(console.error);
|
|
};
|
|
|
|
loadDevices = () => {
|
|
fetch('/api/devices')
|
|
.then(res => res.json())
|
|
.then(devices => this.setState({ devices }))
|
|
.catch(console.error);
|
|
};
|
|
|
|
loadOutputValues = () => {
|
|
fetch('/api/outputs/values')
|
|
.then(res => res.json())
|
|
.then(outputValues => this.setState({ outputValues }))
|
|
.catch(console.error);
|
|
};
|
|
|
|
// Dialog handlers
|
|
handleOpenCreate = () => {
|
|
this.setState({
|
|
editingId: null,
|
|
ruleName: '',
|
|
ruleEnabled: true,
|
|
conditions: { operator: 'AND', conditions: [] },
|
|
action: { channel: this.state.outputChannels[0]?.channel || '', value: 0 },
|
|
open: true
|
|
});
|
|
};
|
|
|
|
handleOpenEdit = (rule, e) => {
|
|
e.stopPropagation();
|
|
this.setState({
|
|
editingId: rule.id,
|
|
ruleName: rule.name,
|
|
ruleEnabled: !!rule.enabled,
|
|
conditions: rule.conditions || { operator: 'AND', conditions: [] },
|
|
action: rule.action || { channel: '', value: 0 },
|
|
open: true
|
|
});
|
|
};
|
|
|
|
handleDelete = async (id, e) => {
|
|
e.stopPropagation();
|
|
if (!window.confirm("Delete this rule?")) return;
|
|
const { user } = this.props;
|
|
await fetch(`/api/rules/${id}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Authorization': `Bearer ${user.token}` }
|
|
});
|
|
this.refreshRules();
|
|
};
|
|
|
|
moveRule = async (idx, dir) => {
|
|
const newRules = [...this.state.rules];
|
|
const target = idx + dir;
|
|
if (target < 0 || target >= newRules.length) return;
|
|
|
|
[newRules[idx], newRules[target]] = [newRules[target], newRules[idx]];
|
|
this.setState({ rules: newRules });
|
|
|
|
const order = newRules.map((r, i) => ({ id: r.id, position: i }));
|
|
const { user } = this.props;
|
|
|
|
try {
|
|
await fetch('/api/rules/reorder', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${user.token}`
|
|
},
|
|
body: JSON.stringify({ order })
|
|
});
|
|
} catch (err) {
|
|
console.error("Failed to save order", err);
|
|
}
|
|
};
|
|
|
|
handleSave = async () => {
|
|
const { ruleName, ruleEnabled, conditions, action, editingId } = this.state;
|
|
const { user } = this.props;
|
|
|
|
if (!ruleName || !action.channel) {
|
|
alert('Please fill in all required fields');
|
|
return;
|
|
}
|
|
|
|
const url = editingId ? `/api/rules/${editingId}` : '/api/rules';
|
|
const method = editingId ? 'PUT' : 'POST';
|
|
|
|
try {
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${user.token}`
|
|
},
|
|
body: JSON.stringify({
|
|
name: ruleName,
|
|
enabled: ruleEnabled,
|
|
conditions,
|
|
action
|
|
})
|
|
});
|
|
|
|
if (res.ok) {
|
|
this.setState({ open: false });
|
|
this.refreshRules();
|
|
} else {
|
|
const err = await res.json();
|
|
alert('Failed to save rule: ' + err.error);
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
toggleRuleEnabled = async (rule) => {
|
|
const { user } = this.props;
|
|
try {
|
|
await fetch(`/api/rules/${rule.id}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${user.token}`
|
|
},
|
|
body: JSON.stringify({
|
|
...rule,
|
|
enabled: !rule.enabled
|
|
})
|
|
});
|
|
this.refreshRules();
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
// Condition editing
|
|
addCondition = (parentPath = []) => {
|
|
this.setState(prev => {
|
|
const newConditions = JSON.parse(JSON.stringify(prev.conditions));
|
|
let target = newConditions;
|
|
for (const idx of parentPath) {
|
|
target = target.conditions[idx];
|
|
}
|
|
target.conditions.push({
|
|
type: 'sensor',
|
|
operator: '>',
|
|
channel: '',
|
|
value: 0
|
|
});
|
|
return { conditions: newConditions };
|
|
});
|
|
};
|
|
|
|
addConditionGroup = (parentPath = [], groupType = 'AND') => {
|
|
this.setState(prev => {
|
|
const newConditions = JSON.parse(JSON.stringify(prev.conditions));
|
|
let target = newConditions;
|
|
for (const idx of parentPath) {
|
|
target = target.conditions[idx];
|
|
}
|
|
target.conditions.push({
|
|
operator: groupType,
|
|
conditions: []
|
|
});
|
|
return { conditions: newConditions };
|
|
});
|
|
};
|
|
|
|
updateCondition = (path, updates) => {
|
|
this.setState(prev => {
|
|
const newConditions = JSON.parse(JSON.stringify(prev.conditions));
|
|
let target = newConditions;
|
|
for (let i = 0; i < path.length - 1; i++) {
|
|
target = target.conditions[path[i]];
|
|
}
|
|
const idx = path[path.length - 1];
|
|
target.conditions[idx] = { ...target.conditions[idx], ...updates };
|
|
return { conditions: newConditions };
|
|
});
|
|
};
|
|
|
|
removeCondition = (path) => {
|
|
this.setState(prev => {
|
|
const newConditions = JSON.parse(JSON.stringify(prev.conditions));
|
|
let target = newConditions;
|
|
for (let i = 0; i < path.length - 1; i++) {
|
|
target = target.conditions[path[i]];
|
|
}
|
|
const idx = path[path.length - 1];
|
|
target.conditions.splice(idx, 1);
|
|
return { conditions: newConditions };
|
|
});
|
|
};
|
|
|
|
toggleGroupOperator = (path) => {
|
|
this.setState(prev => {
|
|
const newConditions = JSON.parse(JSON.stringify(prev.conditions));
|
|
let target = newConditions;
|
|
for (const idx of path) {
|
|
target = target.conditions[idx];
|
|
}
|
|
if (path.length === 0) {
|
|
// Root level
|
|
newConditions.operator = newConditions.operator === 'AND' ? 'OR' : 'AND';
|
|
} else {
|
|
target.operator = target.operator === 'AND' ? 'OR' : 'AND';
|
|
}
|
|
return { conditions: newConditions };
|
|
});
|
|
};
|
|
|
|
// Render a condition group recursively
|
|
renderConditionGroup = (group, path = []) => {
|
|
const { devices, outputChannels } = this.state;
|
|
const isRoot = path.length === 0;
|
|
|
|
// Build sensor channels list
|
|
const sensorChannels = devices.map(d => `${d.device}:${d.channel}`);
|
|
|
|
return (
|
|
<Box sx={{
|
|
pl: isRoot ? 0 : 2,
|
|
borderLeft: isRoot ? 'none' : '2px solid',
|
|
borderColor: group.operator === 'AND' ? '#83a598' : '#fabd2f',
|
|
ml: isRoot ? 0 : 1,
|
|
mb: 1
|
|
}}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
|
<Chip
|
|
label={group.operator}
|
|
size="small"
|
|
color={group.operator === 'AND' ? 'primary' : 'warning'}
|
|
onClick={this.isAdmin() ? () => this.toggleGroupOperator(path) : undefined}
|
|
sx={{ cursor: this.isAdmin() ? 'pointer' : 'default' }}
|
|
/>
|
|
{this.isAdmin() && (
|
|
<>
|
|
<Button size="small" onClick={() => this.addCondition(path)}>+ Condition</Button>
|
|
<Button size="small" onClick={() => this.addConditionGroup(path, 'AND')}>+ AND Group</Button>
|
|
<Button size="small" onClick={() => this.addConditionGroup(path, 'OR')}>+ OR Group</Button>
|
|
</>
|
|
)}
|
|
</Box>
|
|
|
|
{group.conditions?.map((cond, idx) => {
|
|
const condPath = [...path, idx];
|
|
|
|
// Nested group
|
|
if (cond.operator === 'AND' || cond.operator === 'OR') {
|
|
return (
|
|
<Box key={idx} sx={{ mb: 1 }}>
|
|
{this.renderConditionGroup(cond, condPath)}
|
|
{this.isAdmin() && (
|
|
<IconButton size="small" color="error" onClick={() => this.removeCondition(condPath)}>
|
|
<DeleteIcon fontSize="small" />
|
|
</IconButton>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// Single condition
|
|
return (
|
|
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1, flexWrap: 'wrap' }}>
|
|
<Select
|
|
size="small"
|
|
value={cond.type || 'sensor'}
|
|
onChange={e => this.updateCondition(condPath, { type: e.target.value, operator: CONDITION_OPERATORS[e.target.value][0].value })}
|
|
disabled={!this.isAdmin()}
|
|
sx={{ minWidth: 100 }}
|
|
>
|
|
<MenuItem value="time">Time</MenuItem>
|
|
<MenuItem value="date">Date</MenuItem>
|
|
<MenuItem value="sensor">Sensor</MenuItem>
|
|
<MenuItem value="output">Output</MenuItem>
|
|
</Select>
|
|
|
|
{(cond.type === 'sensor' || cond.type === 'output') && (
|
|
<Select
|
|
size="small"
|
|
value={cond.channel || ''}
|
|
onChange={e => this.updateCondition(condPath, { channel: e.target.value })}
|
|
disabled={!this.isAdmin()}
|
|
displayEmpty
|
|
sx={{ minWidth: 180 }}
|
|
>
|
|
<MenuItem value=""><em>Select Channel</em></MenuItem>
|
|
{(cond.type === 'sensor' ? sensorChannels : outputChannels.map(c => c.channel))
|
|
.map(ch => <MenuItem key={ch} value={ch}>{ch}</MenuItem>)}
|
|
</Select>
|
|
)}
|
|
|
|
<Select
|
|
size="small"
|
|
value={cond.operator || '='}
|
|
onChange={e => this.updateCondition(condPath, { operator: e.target.value })}
|
|
disabled={!this.isAdmin()}
|
|
sx={{ minWidth: 80 }}
|
|
>
|
|
{(CONDITION_OPERATORS[cond.type] || CONDITION_OPERATORS.sensor).map(op => (
|
|
<MenuItem key={op.value} value={op.value}>{op.label}</MenuItem>
|
|
))}
|
|
</Select>
|
|
|
|
{cond.operator === 'between' ? (
|
|
<>
|
|
<TextField
|
|
size="small"
|
|
type={cond.type === 'time' ? 'time' : 'date'}
|
|
value={Array.isArray(cond.value) ? cond.value[0] : ''}
|
|
onChange={e => this.updateCondition(condPath, { value: [e.target.value, (cond.value?.[1] || '')] })}
|
|
disabled={!this.isAdmin()}
|
|
sx={{ width: 140 }}
|
|
/>
|
|
<Typography>to</Typography>
|
|
<TextField
|
|
size="small"
|
|
type={cond.type === 'time' ? 'time' : 'date'}
|
|
value={Array.isArray(cond.value) ? cond.value[1] : ''}
|
|
onChange={e => this.updateCondition(condPath, { value: [(cond.value?.[0] || ''), e.target.value] })}
|
|
disabled={!this.isAdmin()}
|
|
sx={{ width: 140 }}
|
|
/>
|
|
</>
|
|
) : (
|
|
<TextField
|
|
size="small"
|
|
type={cond.type === 'time' ? 'time' : (cond.type === 'date' ? 'date' : 'number')}
|
|
value={cond.value ?? ''}
|
|
onChange={e => this.updateCondition(condPath, {
|
|
value: cond.type === 'sensor' || cond.type === 'output'
|
|
? parseFloat(e.target.value) || 0
|
|
: e.target.value
|
|
})}
|
|
disabled={!this.isAdmin()}
|
|
sx={{ width: 140 }}
|
|
/>
|
|
)}
|
|
|
|
{this.isAdmin() && (
|
|
<IconButton size="small" color="error" onClick={() => this.removeCondition(condPath)}>
|
|
<DeleteIcon fontSize="small" />
|
|
</IconButton>
|
|
)}
|
|
</Box>
|
|
);
|
|
})}
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
formatConditionSummary = (condition) => {
|
|
if (!condition) return '';
|
|
|
|
if (condition.operator === 'AND' || condition.operator === 'OR') {
|
|
const parts = (condition.conditions || []).map(c => this.formatConditionSummary(c)).filter(Boolean);
|
|
return parts.length > 0 ? `(${parts.join(` ${condition.operator} `)})` : '';
|
|
}
|
|
|
|
const { type, channel, operator, value } = condition;
|
|
let formatted = '';
|
|
|
|
switch (type) {
|
|
case 'time':
|
|
formatted = operator === 'between'
|
|
? `${value?.[0] || '?'} - ${value?.[1] || '?'}`
|
|
: `time ${operator} ${value}`;
|
|
break;
|
|
case 'date':
|
|
formatted = operator === 'between'
|
|
? `date ${value?.[0] || '?'} to ${value?.[1] || '?'}`
|
|
: `date ${operator} ${value}`;
|
|
break;
|
|
case 'sensor':
|
|
case 'output':
|
|
formatted = `${channel || '?'} ${operator} ${value}`;
|
|
break;
|
|
default:
|
|
formatted = JSON.stringify(condition);
|
|
}
|
|
|
|
return formatted;
|
|
};
|
|
|
|
render() {
|
|
const { rules, outputChannels, outputValues, open, editingId, ruleName, ruleEnabled, conditions, action } = this.state;
|
|
const isAdmin = this.isAdmin();
|
|
|
|
return (
|
|
<Container maxWidth="xl" sx={{ mt: 4 }}>
|
|
<Paper sx={{ p: 2, mb: 4, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<Typography variant="h5">
|
|
<RuleIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
|
|
Rule Editor
|
|
</Typography>
|
|
{isAdmin && (
|
|
<Button variant="contained" startIcon={<AddIcon />} onClick={this.handleOpenCreate}>
|
|
Create Rule
|
|
</Button>
|
|
)}
|
|
</Paper>
|
|
|
|
{/* Current Output Values */}
|
|
<Paper sx={{ p: 2, mb: 4 }}>
|
|
<Typography variant="h6" gutterBottom>Current Output Values</Typography>
|
|
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
|
{outputChannels.map(ch => (
|
|
<Chip
|
|
key={ch.channel}
|
|
label={`${ch.description}: ${outputValues[ch.channel] ?? 0}`}
|
|
color={outputValues[ch.channel] > 0 ? 'success' : 'default'}
|
|
variant="outlined"
|
|
/>
|
|
))}
|
|
</Box>
|
|
</Paper>
|
|
|
|
{/* Rules List */}
|
|
<Paper sx={{ p: 2 }}>
|
|
<Typography variant="h6" gutterBottom>Rules (Priority Order)</Typography>
|
|
<List>
|
|
{rules.map((rule, idx) => (
|
|
<ListItem
|
|
key={rule.id}
|
|
sx={{
|
|
bgcolor: rule.enabled ? 'transparent' : 'rgba(0,0,0,0.2)',
|
|
borderRadius: 1,
|
|
mb: 1,
|
|
border: '1px solid #504945'
|
|
}}
|
|
>
|
|
<ListItemIcon>
|
|
{isAdmin ? (
|
|
<IconButton onClick={() => this.toggleRuleEnabled(rule)}>
|
|
{rule.enabled ? <PlayArrowIcon color="success" /> : <PauseIcon color="disabled" />}
|
|
</IconButton>
|
|
) : (
|
|
rule.enabled ? <PlayArrowIcon color="success" /> : <PauseIcon color="disabled" />
|
|
)}
|
|
</ListItemIcon>
|
|
<ListItemText
|
|
primary={
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Typography variant="subtitle1">{rule.name}</Typography>
|
|
<Chip size="small" label={rule.type || 'static'} />
|
|
</Box>
|
|
}
|
|
secondary={
|
|
<Box>
|
|
<Typography variant="body2" color="text.secondary">
|
|
When: {this.formatConditionSummary(rule.conditions)}
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
Then: Set {rule.action?.channel} = {rule.action?.value}
|
|
</Typography>
|
|
</Box>
|
|
}
|
|
/>
|
|
{isAdmin && (
|
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
|
<IconButton size="small" onClick={() => this.moveRule(idx, -1)} disabled={idx === 0}>
|
|
<ArrowUpwardIcon />
|
|
</IconButton>
|
|
<IconButton size="small" onClick={() => this.moveRule(idx, 1)} disabled={idx === rules.length - 1}>
|
|
<ArrowDownwardIcon />
|
|
</IconButton>
|
|
<IconButton onClick={(e) => this.handleOpenEdit(rule, e)}>
|
|
<EditIcon />
|
|
</IconButton>
|
|
<IconButton color="error" onClick={(e) => this.handleDelete(rule.id, e)}>
|
|
<DeleteIcon />
|
|
</IconButton>
|
|
</Box>
|
|
)}
|
|
</ListItem>
|
|
))}
|
|
{rules.length === 0 && (
|
|
<Typography color="text.secondary" sx={{ p: 2 }}>
|
|
No rules defined. {isAdmin ? 'Click "Create Rule" to add one.' : ''}
|
|
</Typography>
|
|
)}
|
|
</List>
|
|
</Paper>
|
|
|
|
{/* Edit/Create Dialog */}
|
|
<Dialog open={open} onClose={() => this.setState({ open: false })} maxWidth="md" fullWidth>
|
|
<DialogTitle>{editingId ? 'Edit Rule' : 'Create New Rule'}</DialogTitle>
|
|
<DialogContent>
|
|
<Box sx={{ display: 'flex', gap: 2, mb: 2, mt: 1 }}>
|
|
<TextField
|
|
label="Rule Name"
|
|
value={ruleName}
|
|
onChange={e => this.setState({ ruleName: e.target.value })}
|
|
fullWidth
|
|
/>
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={ruleEnabled}
|
|
onChange={e => this.setState({ ruleEnabled: e.target.checked })}
|
|
/>
|
|
}
|
|
label="Enabled"
|
|
/>
|
|
</Box>
|
|
|
|
<Divider sx={{ my: 2 }} />
|
|
|
|
<Typography variant="subtitle1" gutterBottom>Conditions (When)</Typography>
|
|
<Box sx={{ p: 2, border: '1px solid #444', borderRadius: 1, mb: 2 }}>
|
|
{this.renderConditionGroup(conditions)}
|
|
</Box>
|
|
|
|
<Divider sx={{ my: 2 }} />
|
|
|
|
<Typography variant="subtitle1" gutterBottom>Action (Then)</Typography>
|
|
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
|
<Typography>Set</Typography>
|
|
<Select
|
|
size="small"
|
|
value={action.channel}
|
|
onChange={e => this.setState({ action: { ...action, channel: e.target.value } })}
|
|
sx={{ minWidth: 200 }}
|
|
>
|
|
{outputChannels.map(ch => (
|
|
<MenuItem key={ch.channel} value={ch.channel}>
|
|
{ch.description} ({ch.channel})
|
|
</MenuItem>
|
|
))}
|
|
</Select>
|
|
<Typography>to</Typography>
|
|
<TextField
|
|
size="small"
|
|
type="number"
|
|
value={action.value}
|
|
onChange={e => this.setState({ action: { ...action, value: parseFloat(e.target.value) || 0 } })}
|
|
inputProps={{
|
|
min: outputChannels.find(c => c.channel === action.channel)?.min || 0,
|
|
max: outputChannels.find(c => c.channel === action.channel)?.max || 10
|
|
}}
|
|
sx={{ width: 100 }}
|
|
/>
|
|
</Box>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={() => this.setState({ open: false })}>Cancel</Button>
|
|
<Button onClick={this.handleSave} variant="contained">Save</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
</Container>
|
|
);
|
|
}
|
|
}
|
|
|
|
export default RuleEditor;
|