@@ -14,19 +14,21 @@ import {
1414 Typography ,
1515 message ,
1616} from 'antd' ;
17- import { DeleteOutlined , PlusOutlined , SaveOutlined } from '@ant-design/icons' ;
17+ import { CloudUploadOutlined , DeleteOutlined , PlusOutlined , SaveOutlined } from '@ant-design/icons' ;
1818import { useHistory , useLocation } from 'react-router-dom' ;
1919
2020import {
21+ createRuleDraft ,
22+ deployRuleDraft ,
2123 getRuleDraftSource ,
2224 getRuleDraftVocabulary ,
2325 parseRuleDraftIntoBuilder ,
24- submitRuleDraft ,
2526 validateRuleDraft ,
2627} from '../../actions/RulesActions' ;
2728import usePromiseResult from '../../hooks/usePromiseResult' ;
2829import {
2930 ParseIntoBuilderResponse ,
31+ RuleDraft ,
3032 RuleDraftValidationMessage ,
3133 RuleDraftValidationResponse ,
3234 RuleDraftVocabulary ,
@@ -53,6 +55,16 @@ const { Title, Text, Paragraph } = Typography;
5355
5456type EditorMode = 'builder' | 'code' ;
5557
58+ // Saving stages a draft in the rule_drafts table; deploying writes it into the
59+ // rules directory. The saved draft's id is what a subsequent deploy targets.
60+ type SubmitState =
61+ | { kind : 'idle' }
62+ | { kind : 'saving' }
63+ | { kind : 'saved' ; draft : RuleDraft }
64+ | { kind : 'deploying' ; draft : RuleDraft }
65+ | { kind : 'deployed' ; draft : RuleDraft ; mainSmlUpdated : boolean ; pathOnDisk : string }
66+ | { kind : 'error' ; message : string } ;
67+
5668const VALIDATE_DEBOUNCE_MS = 600 ;
5769
5870interface BootstrapData {
@@ -118,17 +130,12 @@ const RuleEditorView: React.FC<{ data: BootstrapData }> = ({ data }) => {
118130 return EMPTY_BUILDER_MODEL ;
119131 } ) ;
120132 const [ summary , setSummary ] = React . useState < string > ( '' ) ;
121- // Off by default: turning a rule on is a deliberate opt-in, so a submit never
133+ // Off by default: turning a rule on is a deliberate opt-in, so a deploy never
122134 // wires a new rule into the live ruleset unless the author checks the box.
123135 const [ wireIntoMain , setWireIntoMain ] = React . useState < boolean > ( false ) ;
124136 const [ validation , setValidation ] = React . useState < RuleDraftValidationResponse | null > ( null ) ;
125137 const [ isValidating , setIsValidating ] = React . useState < boolean > ( false ) ;
126- const [ submitState , setSubmitState ] = React . useState <
127- | { kind : 'idle' }
128- | { kind : 'submitting' }
129- | { kind : 'done' ; title : string ; prUrl : string | null }
130- | { kind : 'error' ; message : string }
131- > ( { kind : 'idle' } ) ;
138+ const [ submitState , setSubmitState ] = React . useState < SubmitState > ( { kind : 'idle' } ) ;
132139
133140 const effectiveSource = mode === 'builder' ? generateSmlFromBuilder ( builder , data . vocabulary . features ) : codeSource ;
134141
@@ -174,11 +181,14 @@ const RuleEditorView: React.FC<{ data: BootstrapData }> = ({ data }) => {
174181
175182 const ruleNameForSubmit = mode === 'builder' ? builder . ruleName : guessRuleNameFromSource ( codeSource ) ;
176183
177- const canSubmit =
178- ! ! validation ?. ok &&
179- SML_IDENTIFIER_RE . test ( ruleNameForSubmit ) &&
180- submitState . kind !== 'submitting' &&
181- ! ! effectiveSource . trim ( ) ;
184+ const isBusy = submitState . kind === 'saving' || submitState . kind === 'deploying' ;
185+ const canSave = ! ! validation ?. ok && SML_IDENTIFIER_RE . test ( ruleNameForSubmit ) && ! isBusy && ! ! effectiveSource . trim ( ) ;
186+ // A draft must exist (be saved) before it can be deployed; the deploy targets its id.
187+ const savedDraft =
188+ submitState . kind === 'saved' || submitState . kind === 'deployed' || submitState . kind === 'deploying'
189+ ? submitState . draft
190+ : null ;
191+ const canDeploy = savedDraft !== null && ! isBusy ;
182192
183193 // The builder and code editor hold independent state, so a tab switch has to
184194 // carry content across: builder -> code dumps the generated SML into the
@@ -208,21 +218,37 @@ const RuleEditorView: React.FC<{ data: BootstrapData }> = ({ data }) => {
208218 }
209219 } ;
210220
211- const onSubmit = async ( ) => {
212- if ( ! canSubmit ) return ;
213- setSubmitState ( { kind : 'submitting ' } ) ;
221+ const onSave = async ( ) => {
222+ if ( ! canSave ) return ;
223+ setSubmitState ( { kind : 'saving ' } ) ;
214224 try {
215- const res = await submitRuleDraft ( {
225+ const draft = await createRuleDraft ( {
216226 path,
217227 source : effectiveSource ,
218228 rule_name : ruleNameForSubmit ,
219229 summary,
220- is_new_rule : data . isNewRule ,
221- wire_into_main : wireIntoMain ,
222230 } ) ;
223- setSubmitState ( { kind : 'done' , title : res . title , prUrl : res . url } ) ;
231+ setSubmitState ( { kind : 'saved' , draft } ) ;
232+ message . success ( 'Draft saved.' ) ;
233+ } catch ( e ) {
234+ const msg = e instanceof Error ? e . message : String ( e ) ;
235+ setSubmitState ( { kind : 'error' , message : msg } ) ;
236+ }
237+ } ;
238+
239+ const onDeploy = async ( ) => {
240+ if ( savedDraft === null || isBusy ) return ;
241+ setSubmitState ( { kind : 'deploying' , draft : savedDraft } ) ;
242+ try {
243+ const res = await deployRuleDraft ( savedDraft . id , { wire_into_main : wireIntoMain } ) ;
244+ setSubmitState ( {
245+ kind : 'deployed' ,
246+ draft : res ,
247+ mainSmlUpdated : res . main_sml_updated ,
248+ pathOnDisk : res . path_on_disk ,
249+ } ) ;
224250 const wiredMsg = res . main_sml_updated ? ' (main.sml updated)' : '' ;
225- message . success ( `${ res . title } ${ wiredMsg } .` ) ;
251+ message . success ( `Deployed to ${ res . path_on_disk } ${ wiredMsg } .` ) ;
226252 } catch ( e ) {
227253 const msg = e instanceof Error ? e . message : String ( e ) ;
228254 setSubmitState ( { kind : 'error' , message : msg } ) ;
@@ -238,8 +264,8 @@ const RuleEditorView: React.FC<{ data: BootstrapData }> = ({ data }) => {
238264 { data . isNewRule ? 'Add rule' : 'Edit rule' }
239265 </ Title >
240266 < Text type = "secondary" >
241- Drafts open a pull request against the rules repo. Nothing applies until the PR is merged and the engine
242- reloads.
267+ Save stages this rule in the drafts table. Deploy writes it into the rules directory; nothing applies
268+ until the engine reloads.
243269 </ Text >
244270 </ div >
245271 < div className = { styles . headerActions } >
@@ -262,9 +288,25 @@ const RuleEditorView: React.FC<{ data: BootstrapData }> = ({ data }) => {
262288 />
263289 </ Tooltip >
264290 < Button onClick = { ( ) => history . push ( '/rules' ) } > Cancel</ Button >
265- < Button type = "primary" icon = { < SaveOutlined /> } disabled = { ! canSubmit } onClick = { onSubmit } >
266- Submit for review
291+ < Button
292+ icon = { < SaveOutlined /> }
293+ disabled = { ! canSave }
294+ onClick = { onSave }
295+ loading = { submitState . kind === 'saving' }
296+ >
297+ Save draft
267298 </ Button >
299+ < Tooltip title = { canDeploy ? '' : 'Save the draft first, then deploy it.' } >
300+ < Button
301+ type = "primary"
302+ icon = { < CloudUploadOutlined /> }
303+ disabled = { ! canDeploy }
304+ onClick = { onDeploy }
305+ loading = { submitState . kind === 'deploying' }
306+ >
307+ Deploy
308+ </ Button >
309+ </ Tooltip >
268310 </ div >
269311 </ div >
270312
@@ -274,12 +316,12 @@ const RuleEditorView: React.FC<{ data: BootstrapData }> = ({ data }) => {
274316 < div >
275317 < Card size = "small" style = { { marginBottom : 12 } } >
276318 < Form layout = "vertical" size = "small" >
277- < Form . Item label = "File path" tooltip = "Path inside the rules repo where this file will live." >
319+ < Form . Item label = "File path" tooltip = "Path inside the rules directory where this file will live." >
278320 < Input value = { path } onChange = { ( e ) => setPath ( e . target . value ) } disabled = { ! data . isNewRule } />
279321 </ Form . Item >
280322 < Form . Item
281- label = { data . isNewRule ? 'Why this rule? (for reviewers) ' : "What's changing? (for reviewers) " }
282- tooltip = "Becomes the pull request description . Not saved into the rule file itself."
323+ label = { data . isNewRule ? 'Why this rule?' : "What's changing?" }
324+ tooltip = "Saved alongside the draft . Not written into the rule file itself."
283325 >
284326 < Input . TextArea
285327 value = { summary }
@@ -294,11 +336,11 @@ const RuleEditorView: React.FC<{ data: BootstrapData }> = ({ data }) => {
294336 </ Form . Item >
295337 < Form . Item style = { { marginBottom : 0 } } >
296338 < Checkbox checked = { wireIntoMain } onChange = { ( e ) => setWireIntoMain ( e . target . checked ) } >
297- Turn this rule on once the review is approved .
339+ Turn this rule on when I deploy it .
298340 </ Checkbox >
299341 < div className = { styles . footnote } >
300- Adds your rule to the list Osprey runs, as part of the same review . If it's already on the
301- list, nothing changes.
342+ Adds your rule to the list Osprey runs (a Require line in main.sml) as part of the deploy . If
343+ it's already on the list, nothing changes.
302344 </ div >
303345 </ Form . Item >
304346 </ Form >
@@ -339,8 +381,7 @@ const RuleEditorView: React.FC<{ data: BootstrapData }> = ({ data }) => {
339381 < Card size = "small" title = "Generated SML preview" style = { { marginTop : 12 } } >
340382 < pre className = { styles . previewBlock } > { effectiveSource } </ pre >
341383 < div className = { styles . footnote } >
342- This is the code that will be submitted in a pull request on Github. Make further changes in the Code
343- Editor view.
384+ This is the code that will be saved as the draft. Make further changes in the Code Editor view.
344385 </ div >
345386 </ Card >
346387 ) }
@@ -356,42 +397,42 @@ const RuleEditorView: React.FC<{ data: BootstrapData }> = ({ data }) => {
356397 ) ;
357398} ;
358399
359- const SubmitBanner : React . FC < {
360- submitState :
361- | { kind : 'idle' }
362- | { kind : 'submitting' }
363- | { kind : 'done' ; title : string ; prUrl : string | null }
364- | { kind : 'error' ; message : string } ;
365- } > = ( { submitState } ) => {
400+ const SubmitBanner : React . FC < { submitState : SubmitState } > = ( { submitState } ) => {
366401 if ( submitState . kind === 'idle' ) return null ;
367- if ( submitState . kind === 'submitting' ) {
368- return < Alert type = "info" message = "Submitting draft..." showIcon style = { { marginBottom : 12 } } /> ;
402+ if ( submitState . kind === 'saving' ) {
403+ return < Alert type = "info" message = "Saving draft..." showIcon style = { { marginBottom : 12 } } /> ;
404+ }
405+ if ( submitState . kind === 'deploying' ) {
406+ return < Alert type = "info" message = "Deploying..." showIcon style = { { marginBottom : 12 } } /> ;
407+ }
408+ if ( submitState . kind === 'saved' ) {
409+ return (
410+ < Alert
411+ type = "success"
412+ showIcon
413+ style = { { marginBottom : 12 } }
414+ message = "Draft saved"
415+ description = "Staged in the drafts table. Deploy it to write it into the rules directory."
416+ />
417+ ) ;
369418 }
370- if ( submitState . kind === 'done ' ) {
419+ if ( submitState . kind === 'deployed ' ) {
371420 return (
372421 < Alert
373422 type = "success"
374423 showIcon
375424 style = { { marginBottom : 12 } }
376- message = { submitState . title }
425+ message = { `Deployed to ${ submitState . pathOnDisk } ` }
377426 description = {
378- submitState . prUrl ? (
379- < a href = { submitState . prUrl } target = "_blank" rel = "noopener noreferrer" >
380- { submitState . prUrl }
381- </ a >
382- ) : null
427+ submitState . mainSmlUpdated
428+ ? 'Added to main.sml. Takes effect when the engine reloads.'
429+ : 'Takes effect once something requires it and the engine reloads.'
383430 }
384431 />
385432 ) ;
386433 }
387434 return (
388- < Alert
389- type = "error"
390- showIcon
391- style = { { marginBottom : 12 } }
392- message = "Submit failed"
393- description = { submitState . message }
394- />
435+ < Alert type = "error" showIcon style = { { marginBottom : 12 } } message = "Failed" description = { submitState . message } />
395436 ) ;
396437} ;
397438
0 commit comments