Skip to content

Commit a14a96d

Browse files
committed
WIP
1 parent 474d21e commit a14a96d

File tree

5 files changed

+848
-823
lines changed

5 files changed

+848
-823
lines changed

src/components/common/dialog.tsx

Lines changed: 174 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,7 @@
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

@@ -442,8 +441,8 @@
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';
447446
import { EventEmitter } from 'cockpit/event';
448447

449448
import cockpit from "cockpit";
@@ -452,9 +451,13 @@ import { Button, type ButtonProps } from "@patternfly/react-core/dist/esm/compon
452451
import { FormGroup, type FormGroupProps, FormHelperText } from "@patternfly/react-core/dist/esm/components/Form";
453452
import { TextInput, type TextInputProps } from "@patternfly/react-core/dist/esm/components/TextInput";
454453
import { 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";
456457
import { 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";
458461
import { Radio } from "@patternfly/react-core/dist/esm/components/Radio";
459462

460463
const _ = cockpit.gettext;
@@ -465,7 +468,9 @@ type ArrayElement<ArrayType> =
465468
interface 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

471476
function 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

585659
interface 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 {
736841
export 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

824929
export 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

Comments
 (0)