2121
2222/* TODOs:
2323
24+ - find a way to have optional values in DialogState...
2425 - progress reporting
2526 - action cancelling
26- - async, debounced validation
27- - async init
27+ - debounced validation
2828 - different validation rules for different actions
2929 */
3030
442442
443443 */
444444
445- import React from "react" ;
446- import { useObject , useOn } from 'hooks' ;
445+ import React , { useState } from "react" ;
446+ import { useObject , useInit , useOn } from 'hooks' ;
447447import { EventEmitter } from 'cockpit/event' ;
448448
449449import cockpit from "cockpit" ;
@@ -452,9 +452,13 @@ import { Button, type ButtonProps } from "@patternfly/react-core/dist/esm/compon
452452import { FormGroup , type FormGroupProps , FormHelperText } from "@patternfly/react-core/dist/esm/components/Form" ;
453453import { TextInput , type TextInputProps } from "@patternfly/react-core/dist/esm/components/TextInput" ;
454454import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js" ;
455- import { HelperText , HelperTextItem } from "@patternfly/react-core/dist/esm/components/HelperText" ;
455+ import {
456+ HelperText , HelperTextItem , type HelperTextItemProps
457+ } from "@patternfly/react-core/dist/esm/components/HelperText" ;
456458import { Checkbox } from "@patternfly/react-core/dist/esm/components/Checkbox" ;
457- import { FormSelect , type FormSelectProps } from "@patternfly/react-core/dist/esm/components/FormSelect" ;
459+ import {
460+ FormSelect , type FormSelectProps , FormSelectOption
461+ } from "@patternfly/react-core/dist/esm/components/FormSelect" ;
458462import { Radio } from "@patternfly/react-core/dist/esm/components/Radio" ;
459463
460464const _ = cockpit . gettext ;
@@ -465,7 +469,9 @@ type ArrayElement<ArrayType> =
465469interface DialogValidationState {
466470 cached_value : unknown ;
467471 cached_result : string | undefined ;
468- // TODO - all the stuff for async
472+ validation_promise : Promise < void > | undefined ;
473+ validation_object : Record < string , string | undefined > ;
474+ // TODO - all the stuff for debounce
469475}
470476
471477function toSpliced < T > ( arr : T [ ] , start : number , deleteCount : number , ...rest : T [ ] ) : T [ ] {
@@ -559,17 +565,22 @@ export class DialogValue<T> {
559565 Object . is ( this . dialog . validation_state [ this . path ] . cached_value , val ) ) {
560566 v = this . dialog . validation_state [ this . path ] . cached_result ;
561567 console . log ( "CACHE HIT" , this . path , v ) ;
568+ this . dialog . validation_state [ this . path ] . validation_object = this . dialog . validation ;
562569 } else {
563570 v = func ( val ) ;
564571 if ( ! ( this . path in this . dialog . validation_state ) ) {
565572 this . dialog . validation_state [ this . path ] = {
566573 cached_value : val ,
567- cached_result : v
574+ cached_result : v ,
575+ validation_promise : undefined ,
576+ validation_object : this . dialog . validation ,
568577 } ;
569578 } else {
570579 const state = this . dialog . validation_state [ this . path ] ;
571580 state . cached_value = val ;
572581 state . cached_result = v ;
582+ state . validation_promise = undefined ;
583+ state . validation_object = this . dialog . validation ;
573584 }
574585 console . log ( "VALIDATE" , this . path , v ) ;
575586 }
@@ -580,6 +591,70 @@ export class DialogValue<T> {
580591 this . dialog . update ( ) ;
581592 }
582593 }
594+
595+ validate_async ( _debounce : number , func : ( val : T ) => Promise < string | undefined > ) : void {
596+ // TODO - implement debouncing
597+ const val = this . get ( ) ;
598+ if ( this . path in this . dialog . validation_state &&
599+ Object . is ( this . dialog . validation_state [ this . path ] . cached_value , val ) ) {
600+ const v = this . dialog . validation_state [ this . path ] . cached_result ;
601+ console . log ( "CACHE HIT" , this . path , v ) ;
602+ this . dialog . validation_state [ this . path ] . validation_object = this . dialog . validation ;
603+ if ( v ) {
604+ this . dialog . validation [ this . path ] = v ;
605+ this . dialog . validation_failed = true ;
606+ this . dialog . online_validation = true ;
607+ this . dialog . update ( ) ;
608+ }
609+ } else {
610+ console . log ( "START VALIDATE ASYNC" , this . path , val ) ;
611+ const prom =
612+ func ( val )
613+ . catch (
614+ ex => {
615+ console . error ( ex ) ;
616+ return undefined ;
617+ }
618+ )
619+ . then (
620+ v => {
621+ const state = this . dialog . validation_state [ this . path ] ;
622+ if ( Object . is ( state . validation_promise , prom ) ) {
623+ state . validation_promise = undefined ;
624+ state . cached_result = v ;
625+ if ( Object . is ( state . validation_object , this . dialog . validation ) ) {
626+ console . log ( "DONE VALIDATE ASYNC" , this . path , v ) ;
627+ if ( v ) {
628+ this . dialog . validation [ this . path ] = v ;
629+ this . dialog . validation_failed = true ;
630+ this . dialog . online_validation = true ;
631+ this . dialog . update ( ) ;
632+ }
633+ } else {
634+ console . log ( "OUTDATED" , this . path ) ;
635+ }
636+ } else {
637+ console . log ( "OVERTAKEN" , this . path ) ;
638+ }
639+ }
640+ ) ;
641+
642+ if ( ! ( this . path in this . dialog . validation_state ) ) {
643+ this . dialog . validation_state [ this . path ] = {
644+ cached_value : val ,
645+ cached_result : undefined ,
646+ validation_promise : prom ,
647+ validation_object : this . dialog . validation ,
648+ } ;
649+ } else {
650+ const state = this . dialog . validation_state [ this . path ] ;
651+ state . cached_value = val ;
652+ state . cached_result = undefined ;
653+ state . validation_promise = prom ;
654+ state . validation_object = this . dialog . validation ;
655+ }
656+ }
657+ }
583658}
584659
585660interface DialogStateEvents {
@@ -621,6 +696,17 @@ export class DialogState<V> extends EventEmitter<DialogStateEvents> {
621696 this . update ( ) ;
622697 }
623698
699+ async wait_for_validation_promises ( ) : Promise < void > {
700+ for ( const path in this . validation_state ) {
701+ const state = this . validation_state [ path ] ;
702+ if ( state . validation_promise ) {
703+ console . log ( "WAITING FOR" , path ) ;
704+ await state . validation_promise ;
705+ console . log ( "WAITING DONE FOR" , path ) ;
706+ }
707+ }
708+ }
709+
624710 rename_validation_state ( path : string , from : number , to : number ) {
625711 const from_path = path + "." + String ( from ) ;
626712 const to_path = path + "." + String ( to ) ;
@@ -689,7 +775,7 @@ export class DialogState<V> extends EventEmitter<DialogStateEvents> {
689775
690776 async validate ( ) : Promise < boolean > {
691777 this . trigger_validation ( ) ;
692- // TODO - wait for the asyncs to be done
778+ await this . wait_for_validation_promises ( ) ;
693779 return Object . keys ( this . validation ) . length == 0 ;
694780 }
695781}
@@ -708,7 +794,27 @@ export function useDialogState<V extends object>(
708794 return dlg ;
709795}
710796
711- // export function useDialogState_async<V>(...)
797+ export function useDialogState_async < V extends object > (
798+ init : ( ) => Promise < V > ,
799+ validate : ( dlg : DialogState < V > ) => void
800+ ) : null | DialogError | DialogState < V > {
801+ const [ dlg , setDlg ] = useState < null | DialogError | DialogState < V > > ( null ) ;
802+ // HACK - Remove the "!" annotation once pkg/lib/hooks is fixed
803+ // See https://github.com/cockpit-project/cockpit/pull/22677
804+ useOn ( ( dlg instanceof DialogError ? null : dlg ) ! , "changed" ) ;
805+ useInit ( async ( ) => {
806+ try {
807+ const init_vals : V = await init ( ) ;
808+ setDlg ( new DialogState ( init_vals , validate ) ) ;
809+ } catch ( ex ) {
810+ if ( ex instanceof DialogError )
811+ setDlg ( ex ) ;
812+ else
813+ setDlg ( DialogError . fromError ( _ ( "Error during initialization" ) , ex ) ) ;
814+ }
815+ } ) ;
816+ return dlg ;
817+ }
712818
713819// Common elements
714820
@@ -736,15 +842,15 @@ export class DialogError {
736842export function DialogErrorMessage < V > ( {
737843 dialog,
738844} : {
739- dialog : DialogState < V >
845+ dialog : DialogState < V > | DialogError | null ,
740846} ) {
741- if ( ! dialog . error )
847+ const err = ( ! dialog || dialog instanceof DialogError ) ? dialog : dialog . error ;
848+ if ( ! err )
742849 return null ;
743850
744851 let title : string ;
745852 let details : React . ReactNode ;
746853
747- const err = dialog . error ;
748854 if ( err instanceof DialogError ) {
749855 title = err . title ;
750856 details = err . details ;
@@ -775,19 +881,19 @@ export function DialogActionButton<V>({
775881 onClose = undefined ,
776882 ...props
777883} : {
778- dialog : DialogState < V > | null ,
884+ dialog : DialogState < V > | DialogError | null ,
779885 children : React . ReactNode ,
780886 action : ( values : V ) => Promise < void > ,
781887 onClose ?: undefined | ( ( ) => void )
782888} & Omit < ButtonProps , "id" | "action" | "isLoading" | "isDisabled" | "variant" | "onClick" > ) {
783889 return (
784890 < Button
785891 id = "dialog-apply"
786- isLoading = { ! ! dialog && dialog . busy }
787- isDisabled = { ! dialog || dialog . actions_disabled }
892+ isLoading = { ! ! dialog && ! ( dialog instanceof DialogError ) && dialog . busy }
893+ isDisabled = { ! dialog || dialog instanceof DialogError || dialog . actions_disabled }
788894 variant = "primary"
789895 onClick = { ( ) => {
790- cockpit . assert ( dialog ) ;
896+ cockpit . assert ( dialog && ! ( dialog instanceof DialogError ) ) ;
791897 dialog . run_action ( vals => action ( vals ) . then ( onClose || ( ( ) => { } ) ) ) ;
792898 } }
793899 { ...props }
@@ -802,13 +908,13 @@ export function DialogCancelButton<V>({
802908 onClose,
803909 ...props
804910} : {
805- dialog : DialogState < V > ,
911+ dialog : DialogState < V > | DialogError | null ,
806912 onClose : ( ) => void
807913} & Omit < ButtonProps , "id" | "isDisabled" | "variant" | "onClick" > ) {
808914 return (
809915 < Button
810916 id = "dialog-cancel"
811- isDisabled = { dialog . cancel_disabled }
917+ isDisabled = { ! dialog || dialog instanceof DialogError || dialog . cancel_disabled }
812918 variant = "link"
813919 onClick = { onClose }
814920 { ...props }
@@ -823,13 +929,19 @@ export function DialogCancelButton<V>({
823929
824930export function DialogHelperText < V > ( {
825931 value,
932+ warning = null ,
826933 explanation = null ,
827934} : {
828935 value : DialogValue < V > ,
936+ warning ?: React . ReactNode ;
829937 explanation ?: React . ReactNode ;
830938} ) {
831939 let text : React . ReactNode = value . validation_text ( ) ;
832- let variant : "error" | "default" = "error" ;
940+ let variant : HelperTextItemProps [ "variant" ] = "error" ;
941+ if ( ! text && warning ) {
942+ text = warning ;
943+ variant = "warning" ;
944+ }
833945 if ( ! text ) {
834946 text = explanation ;
835947 variant = "default" ;
@@ -875,12 +987,14 @@ export const DialogTextInput = ({
875987 label = null ,
876988 value,
877989 excuse = null ,
990+ warning = null ,
878991 isDisabled = false ,
879992 ...props
880993} : {
881994 label ?: React . ReactNode ,
882995 value : DialogValue < string > ,
883996 excuse ?: null | string ,
997+ warning ?: React . ReactNode ,
884998 isDisabled ?: boolean ,
885999} & Omit < TextInputProps , "id" | "label" | "value" | "onChange" > ) => {
8861000 return (
@@ -892,7 +1006,7 @@ export const DialogTextInput = ({
8921006 isDisabled = { ! ! excuse || isDisabled }
8931007 { ...props }
8941008 />
895- < DialogHelperText explanation = { excuse } value = { value } />
1009+ < DialogHelperText explanation = { excuse } warning = { warning } value = { value } />
8961010 </ OptionalFormGroup >
8971011 ) ;
8981012} ;
@@ -996,3 +1110,43 @@ export function DialogRadioSelect<T extends string>({
9961110 </ OptionalFormGroup >
9971111 ) ;
9981112}
1113+
1114+ export function DialogSelect < T > ( {
1115+ label,
1116+ value,
1117+ warning = null ,
1118+ explanation = null ,
1119+ options,
1120+ option_label = ( o : T ) : string => { cockpit . assert ( typeof o == "string" ) ; return o } ,
1121+ } : {
1122+ label : React . ReactNode ,
1123+ value : DialogValue < T > ,
1124+ warning ?: React . ReactNode ,
1125+ explanation ?: React . ReactNode ,
1126+ options : T [ ] ,
1127+ option_label ?: ( o : T ) => string ,
1128+ } ) {
1129+ return (
1130+ < OptionalFormGroup label = { label } >
1131+ < FormSelect
1132+ id = { value . id ( ) }
1133+ onChange = { ( _event , val ) => {
1134+ const opt = options . find ( o => option_label ( o ) == val ) ;
1135+ value . set ( opt ! ) ;
1136+ } }
1137+ validated = { warning ? "warning" : undefined }
1138+ value = { option_label ( value . get ( ) ) }
1139+ >
1140+ {
1141+ options . map (
1142+ o => {
1143+ const l = option_label ( o ) ;
1144+ return < FormSelectOption key = { l } value = { l } label = { l } /> ;
1145+ }
1146+ )
1147+ }
1148+ </ FormSelect >
1149+ < DialogHelperText value = { value } warning = { warning } explanation = { explanation } />
1150+ </ OptionalFormGroup >
1151+ ) ;
1152+ }
0 commit comments