Skip to content

Commit 6c116f4

Browse files
committed
WIP
1 parent 474d21e commit 6c116f4

File tree

6 files changed

+950
-926
lines changed

6 files changed

+950
-926
lines changed

src/components/common/dialog.tsx

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

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

449449
import cockpit from "cockpit";
@@ -452,9 +452,13 @@ import { Button, type ButtonProps } from "@patternfly/react-core/dist/esm/compon
452452
import { FormGroup, type FormGroupProps, FormHelperText } from "@patternfly/react-core/dist/esm/components/Form";
453453
import { TextInput, type TextInputProps } from "@patternfly/react-core/dist/esm/components/TextInput";
454454
import { 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";
456458
import { 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";
458462
import { Radio } from "@patternfly/react-core/dist/esm/components/Radio";
459463

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

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

585660
interface 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 {
736842
export 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

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

Comments
 (0)