2323
2424 - progress reporting
2525 - action cancelling
26- - async, debounced validation
27- - async init
26+ - debounced validation
2827 - different validation rules for different actions
2928 */
3029
442441
443442 */
444443
445- import React from "react" ;
446- import { useObject , useOn } from 'hooks' ;
444+ import React , { useState } from "react" ;
445+ import { useObject , useInit , useOn } from 'hooks' ;
447446import { EventEmitter } from 'cockpit/event' ;
448447
449448import cockpit from "cockpit" ;
@@ -452,9 +451,13 @@ import { Button, type ButtonProps } from "@patternfly/react-core/dist/esm/compon
452451import { FormGroup , type FormGroupProps , FormHelperText } from "@patternfly/react-core/dist/esm/components/Form" ;
453452import { TextInput , type TextInputProps } from "@patternfly/react-core/dist/esm/components/TextInput" ;
454453import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js" ;
455- import { HelperText , HelperTextItem } from "@patternfly/react-core/dist/esm/components/HelperText" ;
454+ import {
455+ HelperText , HelperTextItem , type HelperTextItemProps
456+ } from "@patternfly/react-core/dist/esm/components/HelperText" ;
456457import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox" ;
457- import { FormSelect , type FormSelectProps } from "@patternfly/react-core/dist/esm/components/FormSelect" ;
458+ import {
459+ FormSelect , type FormSelectProps , FormSelectOption
460+ } from "@patternfly/react-core/dist/esm/components/FormSelect" ;
458461import { Radio } from "@patternfly/react-core/dist/esm/components/Radio" ;
459462
460463const _ = cockpit . gettext ;
@@ -465,7 +468,9 @@ type ArrayElement<ArrayType> =
465468interface DialogValidationState {
466469 cached_value : unknown ;
467470 cached_result : string | undefined ;
468- // TODO - all the stuff for async
471+ validation_promise : Promise < void > | undefined ;
472+ validation_object : Record < string , string | undefined > ;
473+ // TODO - all the stuff for debounce
469474}
470475
471476function toSpliced < T > ( arr : T [ ] , start : number , deleteCount : number , ...rest : T [ ] ) : T [ ] {
@@ -559,17 +564,22 @@ export class DialogValue<T> {
559564 Object . is ( this . dialog . validation_state [ this . path ] . cached_value , val ) ) {
560565 v = this . dialog . validation_state [ this . path ] . cached_result ;
561566 console . log ( "CACHE HIT" , this . path , v ) ;
567+ this . dialog . validation_state [ this . path ] . validation_object = this . dialog . validation ;
562568 } else {
563569 v = func ( val ) ;
564570 if ( ! ( this . path in this . dialog . validation_state ) ) {
565571 this . dialog . validation_state [ this . path ] = {
566572 cached_value : val ,
567- cached_result : v
573+ cached_result : v ,
574+ validation_promise : undefined ,
575+ validation_object : this . dialog . validation ,
568576 } ;
569577 } else {
570578 const state = this . dialog . validation_state [ this . path ] ;
571579 state . cached_value = val ;
572580 state . cached_result = v ;
581+ state . validation_promise = undefined ;
582+ state . validation_object = this . dialog . validation ;
573583 }
574584 console . log ( "VALIDATE" , this . path , v ) ;
575585 }
@@ -580,6 +590,70 @@ export class DialogValue<T> {
580590 this . dialog . update ( ) ;
581591 }
582592 }
593+
594+ validate_async ( _debounce : number , func : ( val : T ) => Promise < string | undefined > ) : void {
595+ // TODO - implement debouncing
596+ const val = this . get ( ) ;
597+ if ( this . path in this . dialog . validation_state &&
598+ Object . is ( this . dialog . validation_state [ this . path ] . cached_value , val ) ) {
599+ const v = this . dialog . validation_state [ this . path ] . cached_result ;
600+ console . log ( "CACHE HIT" , this . path , v ) ;
601+ this . dialog . validation_state [ this . path ] . validation_object = this . dialog . validation ;
602+ if ( v ) {
603+ this . dialog . validation [ this . path ] = v ;
604+ this . dialog . validation_failed = true ;
605+ this . dialog . online_validation = true ;
606+ this . dialog . update ( ) ;
607+ }
608+ } else {
609+ console . log ( "START VALIDATE ASYNC" , this . path , val ) ;
610+ const prom =
611+ func ( val )
612+ . catch (
613+ ex => {
614+ console . log ( "ASYNC VALIDATE ERROR" , ex ) ;
615+ return "Failed: " + String ( ex ) ;
616+ }
617+ )
618+ . then (
619+ v => {
620+ const state = this . dialog . validation_state [ this . path ] ;
621+ if ( Object . is ( state . validation_promise , prom ) ) {
622+ state . validation_promise = undefined ;
623+ state . cached_result = v ;
624+ if ( Object . is ( state . validation_object , this . dialog . validation ) ) {
625+ console . log ( "DONE VALIDATE ASYNC" , this . path , v ) ;
626+ if ( v ) {
627+ this . dialog . validation [ this . path ] = v ;
628+ this . dialog . validation_failed = true ;
629+ this . dialog . online_validation = true ;
630+ this . dialog . update ( ) ;
631+ }
632+ } else {
633+ console . log ( "OUTDATED" , this . path ) ;
634+ }
635+ } else {
636+ console . log ( "OVERTAKEN" , this . path ) ;
637+ }
638+ }
639+ ) ;
640+
641+ if ( ! ( this . path in this . dialog . validation_state ) ) {
642+ this . dialog . validation_state [ this . path ] = {
643+ cached_value : val ,
644+ cached_result : undefined ,
645+ validation_promise : prom ,
646+ validation_object : this . dialog . validation ,
647+ } ;
648+ } else {
649+ const state = this . dialog . validation_state [ this . path ] ;
650+ state . cached_value = val ;
651+ state . cached_result = undefined ;
652+ state . validation_promise = prom ;
653+ state . validation_object = this . dialog . validation ;
654+ }
655+ }
656+ }
583657}
584658
585659interface DialogStateEvents {
@@ -621,6 +695,17 @@ export class DialogState<V> extends EventEmitter<DialogStateEvents> {
621695 this . update ( ) ;
622696 }
623697
698+ async wait_for_validation_promises ( ) : Promise < void > {
699+ for ( const path in this . validation_state ) {
700+ const state = this . validation_state [ path ] ;
701+ if ( state . validation_promise ) {
702+ console . log ( "WAITING FOR" , path ) ;
703+ await state . validation_promise ;
704+ console . log ( "WAITING DONE FOR" , path ) ;
705+ }
706+ }
707+ }
708+
624709 rename_validation_state ( path : string , from : number , to : number ) {
625710 const from_path = path + "." + String ( from ) ;
626711 const to_path = path + "." + String ( to ) ;
@@ -689,7 +774,7 @@ export class DialogState<V> extends EventEmitter<DialogStateEvents> {
689774
690775 async validate ( ) : Promise < boolean > {
691776 this . trigger_validation ( ) ;
692- // TODO - wait for the asyncs to be done
777+ await this . wait_for_validation_promises ( ) ;
693778 return Object . keys ( this . validation ) . length == 0 ;
694779 }
695780}
@@ -708,7 +793,27 @@ export function useDialogState<V extends object>(
708793 return dlg ;
709794}
710795
711- // export function useDialogState_async<V>(...)
796+ export function useDialogState_async < V extends object > (
797+ init : ( ) => Promise < V > ,
798+ validate : ( dlg : DialogState < V > ) => void
799+ ) : null | DialogError | DialogState < V > {
800+ const [ dlg , setDlg ] = useState < null | DialogError | DialogState < V > > ( null ) ;
801+ // HACK - Remove the "!" annotation once pkg/lib/hooks is fixed
802+ // See https://github.com/cockpit-project/cockpit/pull/22677
803+ useOn ( ( dlg instanceof DialogError ? null : dlg ) ! , "changed" ) ;
804+ useInit ( async ( ) => {
805+ try {
806+ const init_vals : V = await init ( ) ;
807+ setDlg ( new DialogState ( init_vals , validate ) ) ;
808+ } catch ( ex ) {
809+ if ( ex instanceof DialogError )
810+ setDlg ( ex ) ;
811+ else
812+ setDlg ( DialogError . fromError ( _ ( "Error during initialization" ) , ex ) ) ;
813+ }
814+ } ) ;
815+ return dlg ;
816+ }
712817
713818// Common elements
714819
@@ -736,15 +841,15 @@ export class DialogError {
736841export function DialogErrorMessage < V > ( {
737842 dialog,
738843} : {
739- dialog : DialogState < V >
844+ dialog : DialogState < V > | DialogError | null ,
740845} ) {
741- if ( ! dialog . error )
846+ const err = ( ! dialog || dialog instanceof DialogError ) ? dialog : dialog . error ;
847+ if ( ! err )
742848 return null ;
743849
744850 let title : string ;
745851 let details : React . ReactNode ;
746852
747- const err = dialog . error ;
748853 if ( err instanceof DialogError ) {
749854 title = err . title ;
750855 details = err . details ;
@@ -775,19 +880,19 @@ export function DialogActionButton<V>({
775880 onClose = undefined ,
776881 ...props
777882} : {
778- dialog : DialogState < V > | null ,
883+ dialog : DialogState < V > | DialogError | null ,
779884 children : React . ReactNode ,
780885 action : ( values : V ) => Promise < void > ,
781886 onClose ?: undefined | ( ( ) => void )
782887} & Omit < ButtonProps , "id" | "action" | "isLoading" | "isDisabled" | "variant" | "onClick" > ) {
783888 return (
784889 < Button
785890 id = "dialog-apply"
786- isLoading = { ! ! dialog && dialog . busy }
787- isDisabled = { ! dialog || dialog . actions_disabled }
891+ isLoading = { ! ! dialog && ! ( dialog instanceof DialogError ) && dialog . busy }
892+ isDisabled = { ! dialog || dialog instanceof DialogError || dialog . actions_disabled }
788893 variant = "primary"
789894 onClick = { ( ) => {
790- cockpit . assert ( dialog ) ;
895+ cockpit . assert ( dialog && ! ( dialog instanceof DialogError ) ) ;
791896 dialog . run_action ( vals => action ( vals ) . then ( onClose || ( ( ) => { } ) ) ) ;
792897 } }
793898 { ...props }
@@ -802,13 +907,13 @@ export function DialogCancelButton<V>({
802907 onClose,
803908 ...props
804909} : {
805- dialog : DialogState < V > ,
910+ dialog : DialogState < V > | DialogError | null ,
806911 onClose : ( ) => void
807912} & Omit < ButtonProps , "id" | "isDisabled" | "variant" | "onClick" > ) {
808913 return (
809914 < Button
810915 id = "dialog-cancel"
811- isDisabled = { dialog . cancel_disabled }
916+ isDisabled = { ! dialog || dialog instanceof DialogError || dialog . cancel_disabled }
812917 variant = "link"
813918 onClick = { onClose }
814919 { ...props }
@@ -823,13 +928,19 @@ export function DialogCancelButton<V>({
823928
824929export function DialogHelperText < V > ( {
825930 value,
931+ warning = null ,
826932 explanation = null ,
827933} : {
828934 value : DialogValue < V > ,
935+ warning ?: React . ReactNode ;
829936 explanation ?: React . ReactNode ;
830937} ) {
831938 let text : React . ReactNode = value . validation_text ( ) ;
832- let variant : "error" | "default" = "error" ;
939+ let variant : HelperTextItemProps [ "variant" ] = "error" ;
940+ if ( ! text && warning ) {
941+ text = warning ;
942+ variant = "warning" ;
943+ }
833944 if ( ! text ) {
834945 text = explanation ;
835946 variant = "default" ;
@@ -875,12 +986,14 @@ export const DialogTextInput = ({
875986 label = null ,
876987 value,
877988 excuse = null ,
989+ warning = null ,
878990 isDisabled = false ,
879991 ...props
880992} : {
881993 label ?: React . ReactNode ,
882994 value : DialogValue < string > ,
883995 excuse ?: null | string ,
996+ warning ?: React . ReactNode ,
884997 isDisabled ?: boolean ,
885998} & Omit < TextInputProps , "id" | "label" | "value" | "onChange" > ) => {
886999 return (
@@ -892,7 +1005,7 @@ export const DialogTextInput = ({
8921005 isDisabled = { ! ! excuse || isDisabled }
8931006 { ...props }
8941007 />
895- < DialogHelperText explanation = { excuse } value = { value } />
1008+ < DialogHelperText explanation = { excuse } warning = { warning } value = { value } />
8961009 </ OptionalFormGroup >
8971010 ) ;
8981011} ;
@@ -996,3 +1109,43 @@ export function DialogRadioSelect<T extends string>({
9961109 </ OptionalFormGroup >
9971110 ) ;
9981111}
1112+
1113+ export function DialogSelect < T > ( {
1114+ label,
1115+ value,
1116+ warning = null ,
1117+ explanation = null ,
1118+ options,
1119+ option_label = ( o : T ) : string => { cockpit . assert ( typeof o == "string" ) ; return o } ,
1120+ } : {
1121+ label : React . ReactNode ,
1122+ value : DialogValue < T > ,
1123+ warning ?: React . ReactNode ,
1124+ explanation ?: React . ReactNode ,
1125+ options : T [ ] ,
1126+ option_label ?: ( o : T ) => string ,
1127+ } ) {
1128+ return (
1129+ < OptionalFormGroup label = { label } >
1130+ < FormSelect
1131+ id = { value . id ( ) }
1132+ onChange = { ( _event , val ) => {
1133+ const opt = options . find ( o => option_label ( o ) == val ) ;
1134+ value . set ( opt ! ) ;
1135+ } }
1136+ validated = { warning ? "warning" : undefined }
1137+ value = { option_label ( value . get ( ) ) }
1138+ >
1139+ {
1140+ options . map (
1141+ o => {
1142+ const l = option_label ( o ) ;
1143+ return < FormSelectOption key = { l } value = { l } label = { l } /> ;
1144+ }
1145+ )
1146+ }
1147+ </ FormSelect >
1148+ < DialogHelperText value = { value } warning = { warning } explanation = { explanation } />
1149+ </ OptionalFormGroup >
1150+ ) ;
1151+ }
0 commit comments