go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/analysis/frontend/ui/src/views/new_rule/new_rule.tsx (about) 1 // Copyright 2022 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 import { 16 ChangeEvent, 17 useContext, 18 useEffect, 19 useState, 20 } from 'react'; 21 import { useMutation } from 'react-query'; 22 import { 23 useNavigate, 24 useParams, 25 useSearchParams, 26 } from 'react-router-dom'; 27 28 import { 29 GrpcError, 30 RpcCode, 31 } from '@chopsui/prpc-client'; 32 import LoadingButton from '@mui/lab/LoadingButton'; 33 import Backdrop from '@mui/material/Backdrop'; 34 import CircularProgress from '@mui/material/CircularProgress'; 35 import Container from '@mui/material/Container'; 36 import Grid from '@mui/material/Grid'; 37 import Paper from '@mui/material/Paper'; 38 import Typography from '@mui/material/Typography'; 39 import PanelHeading from '@/components/headings/panel_heading/panel_heading'; 40 41 import BugPicker from '@/components/bug_picker/bug_picker'; 42 import ErrorAlert from '@/components/error_alert/error_alert'; 43 import RuleEditInput from '@/components/rule_edit_input/rule_edit_input'; 44 import { SnackbarContext } from '@/context/snackbar_context'; 45 import { ClusterId } from '@/proto/go.chromium.org/luci/analysis/proto/v1/common.pb'; 46 import { CreateRuleRequest, Rule } from '@/proto/go.chromium.org/luci/analysis/proto/v1/rules.pb'; 47 import { linkToRule } from '@/tools/urlHandling/links'; 48 import { getRulesService } from '@/services/services'; 49 50 const NewRulePage = () => { 51 const { project } = useParams(); 52 const navigate = useNavigate(); 53 const [searchParams] = useSearchParams(); 54 55 const [bugSystem, setBugSystem] = useState<string>(''); 56 const [bugId, setBugId] = useState<string>(''); 57 const [definition, setDefinition] = useState<string>(''); 58 const [sourceCluster, setSourceCluster] = useState<ClusterId>({ algorithm: '', id: '' }); 59 60 const { setSnack } = useContext(SnackbarContext); 61 const createRule = useMutation((request: CreateRuleRequest) => service.create(request)); 62 const [validationError, setValidationError] = useState<GrpcError | null>(null); 63 64 useEffect(() => { 65 const rule = searchParams.get('rule'); 66 if (rule) { 67 setDefinition(rule); 68 } 69 const sourceClusterAlg = searchParams.get('sourceAlg'); 70 const sourceClusterID = searchParams.get('sourceId'); 71 if (sourceClusterAlg && sourceClusterID) { 72 setSourceCluster({ 73 algorithm: sourceClusterAlg, 74 id: sourceClusterID, 75 }); 76 } 77 }, [searchParams]); 78 79 80 if (!project) { 81 return ( 82 <ErrorAlert 83 errorTitle="Project not found" 84 errorText="A project is required to create a rule, please check the URL and try again." 85 showError 86 /> 87 ); 88 } 89 90 const service = getRulesService(); 91 92 const handleBugSystemChange = (bugSystem: string) => { 93 setBugSystem(bugSystem); 94 }; 95 96 const handleBugIdChange = (bugId: string) => { 97 setBugId(bugId); 98 }; 99 100 const handleRuleDefinitionChange = (e: ChangeEvent<HTMLTextAreaElement>) => { 101 setDefinition(e.target.value); 102 }; 103 104 105 const handleSave = () => { 106 const request: CreateRuleRequest = { 107 parent: `projects/${project}`, 108 rule: Rule.create({ 109 bug: { 110 system: bugSystem, 111 id: bugId, 112 }, 113 ruleDefinition: definition, 114 isActive: true, 115 isManagingBug: false, 116 isManagingBugPriority: true, 117 sourceCluster: sourceCluster, 118 }), 119 }; 120 createRule.mutate(request, { 121 onSuccess(data) { 122 const rule = data; 123 setSnack({ 124 open: true, 125 message: 'Rule updated successfully', 126 severity: 'success', 127 }); 128 navigate(linkToRule(rule.project, rule.ruleId)); 129 }, 130 onError(error) { 131 if ( error instanceof GrpcError && 132 error.code === RpcCode.INVALID_ARGUMENT) { 133 setValidationError(error); 134 } else { 135 setSnack({ 136 open: true, 137 message: `Failed to create rule due to: ${error}`, 138 severity: 'error', 139 }); 140 } 141 }, 142 }); 143 }; 144 145 return ( 146 <Container> 147 <Paper elevation={3} sx={{ 148 pt: 1, 149 pb: 4, 150 px: 2, 151 mt: 1, 152 mx: 2, 153 }}> 154 <PanelHeading> 155 New Rule 156 </PanelHeading> 157 <Grid container direction='column' spacing={1}> 158 <Grid item xs> 159 {validationError && ( 160 <ErrorAlert 161 errorTitle="Validation error" 162 errorText={`Rule data is invalid: ${validationError.description.trim()}`} 163 showError 164 onErrorClose={() => setValidationError(null)} 165 /> 166 )} 167 </Grid> 168 <Grid item container xs> 169 <Grid item xs={6}> 170 <Typography> 171 Associated bug 172 </Typography> 173 <BugPicker 174 bugSystem={bugSystem} 175 bugId={bugId} 176 handleBugSystemChanged={handleBugSystemChange} 177 handleBugIdChanged={handleBugIdChange} /> 178 </Grid> 179 </Grid> 180 <Grid item xs marginTop='1rem'> 181 <Typography> 182 Rule definition 183 </Typography> 184 <RuleEditInput 185 definition={definition} 186 onDefinitionChange={handleRuleDefinitionChange} 187 /> 188 </Grid> 189 <Grid item xs> 190 <LoadingButton 191 variant="contained" 192 data-testid="create-rule-save" 193 onClick={handleSave} 194 loading={createRule.isLoading}> 195 Save 196 </LoadingButton> 197 </Grid> 198 </Grid> 199 </Paper> 200 <Backdrop 201 sx={{ color: '#fff' }} 202 open={createRule.isSuccess} 203 > 204 <CircularProgress color="inherit" /> 205 </Backdrop> 206 </Container> 207 ); 208 }; 209 210 export default NewRulePage;