Skip to content

Commit

Permalink
add asssertConfirm and move global modal confirm code out of HoustonA…
Browse files Browse the repository at this point in the history
…ppContainer.vue
  • Loading branch information
joshuaboud committed May 31, 2024
1 parent e1fdf18 commit 638e070
Show file tree
Hide file tree
Showing 4 changed files with 173 additions and 110 deletions.
36 changes: 4 additions & 32 deletions houston-common-ui/lib/components/HoustonAppContainer.vue
Original file line number Diff line number Diff line change
@@ -1,39 +1,11 @@
<script lang="ts">
import { provide, inject, type InjectionKey, ref, type Ref } from "vue";
import { ModalConfirm, type ConfirmOptions } from '@/components/modals';
import { okAsync, errAsync, ResultAsync } from 'neverthrow';
import { type Action } from "@/composables/wrapActions";
export type GlobalModalConfirmFunctions = {
confirm: (options: ConfirmOptions) => ResultAsync<boolean, Error>;
confirmBeforeAction: (
options: ConfirmOptions,
action: Action<any, any, any>
) => typeof action;
};
const globalModalConfirmFuncs = ref<GlobalModalConfirmFunctions>();
const getGlobalModalConfirmFuncs = () => {
if (globalModalConfirmFuncs.value === undefined) {
throw new Error("Global ModalConfirm methods not provided!");
}
return globalModalConfirmFuncs.value;
};
export const confirm = (...args: Parameters<GlobalModalConfirmFunctions["confirm"]>): ReturnType<GlobalModalConfirmFunctions["confirm"]> =>
getGlobalModalConfirmFuncs().confirm(...args);
export const confirmBeforeAction = (...args: Parameters<GlobalModalConfirmFunctions["confirmBeforeAction"]>): ReturnType<GlobalModalConfirmFunctions["confirmBeforeAction"]> =>
getGlobalModalConfirmFuncs().confirmBeforeAction(...args);
</script>

<script setup lang="ts">
import { defineComponent, defineProps, onMounted, watchEffect, type Component, type PropType } from "vue";
import { ref, defineProps, watchEffect } from "vue";
import HoustonHeader from "@/components/HoustonHeader.vue";
import { defineHoustonAppTabState, type HoustonAppTabEntrySpec, TabSelector, TabView } from '@/components/tabs';
import NotificationView from "@/components/NotificationView.vue";
import { useGlobalProcessingState } from '@/composables/useGlobalProcessingState';
import { ModalConfirm } from '@/components/modals';
import { _internal } from '@/composables/globalModalConfirm';
const props = defineProps<{
moduleName: string,
Expand All @@ -48,7 +20,7 @@ const globalModalConfirm = ref<InstanceType<typeof ModalConfirm> | null>(null);
watchEffect(() => {
if (globalModalConfirm.value !== null) {
globalModalConfirmFuncs.value = globalModalConfirm.value;
_internal.provideGlobalModalFuncs(globalModalConfirm.value);
}
});
Expand Down
196 changes: 118 additions & 78 deletions houston-common-ui/lib/components/modals/ModalConfirm.vue
Original file line number Diff line number Diff line change
@@ -1,130 +1,170 @@
<script lang="ts">
export type ConfirmOptions = {
header: string;
body: string;
dangerous?: boolean;
header: string;
body: string;
dangerous?: boolean;
};
</script>

<script setup lang="ts">
import { defineProps, defineEmits, computed, ref, watchEffect, defineExpose } from "vue";
import { ResultAsync, okAsync } from 'neverthrow';
import {
defineProps,
defineEmits,
computed,
ref,
watchEffect,
defineExpose,
} from "vue";
import { ResultAsync, okAsync, errAsync } from "neverthrow";
import { type Action } from "@/composables/wrapActions";
import CardContainer from '@/components/CardContainer.vue';
import Modal from './Modal.vue';
import CardContainer from "@/components/CardContainer.vue";
import Modal from "./Modal.vue";
import { ExclamationCircleIcon } from "@heroicons/vue/20/solid";
const _ = cockpit.gettext;
defineProps<{
clickOutsideCancels?: boolean;
clickOutsideCancels?: boolean;
}>();
const emit = defineEmits<{
(e: "confirm"): void;
(e: "cancel"): void;
(e: "confirm"): void;
(e: "cancel"): void;
}>();
class Confirmation {
id: symbol;
promise: Promise<boolean>
resolve!: (value: boolean | PromiseLike<boolean>) => void;
constructor(public header: string, public body: string, public dangerous: boolean) {
this.promise = new Promise((r) => {
this.resolve = r;
});
this.id = Symbol();
}
id: symbol;
promise: Promise<boolean>;
resolve!: (value: boolean | PromiseLike<boolean>) => void;
constructor(
public header: string,
public body: string,
public dangerous: boolean
) {
this.promise = new Promise((r) => {
this.resolve = r;
});
this.id = Symbol();
}
}
const confirmationStack = ref<Confirmation[]>([]);
const pushConfirmation = (c: Confirmation) => {
confirmationStack.value = [c, ...confirmationStack.value];
confirmationStack.value = [c, ...confirmationStack.value];
};
const popConfirmation = () => {
confirmationStack.value = confirmationStack.value.slice(1);
confirmationStack.value = confirmationStack.value.slice(1);
};
const currentConfirmation = computed(() => confirmationStack.value.length > 0 ? confirmationStack.value[0] : undefined);
const currentConfirmation = computed(() =>
confirmationStack.value.length > 0 ? confirmationStack.value[0] : undefined
);
const resolveCurrent = (value: boolean) => {
if (currentConfirmation.value === undefined) {
return;
}
currentConfirmation.value.resolve(value);
popConfirmation();
if (currentConfirmation.value === undefined) {
return;
}
currentConfirmation.value.resolve(value);
popConfirmation();
};
// stage the modal contents to avoid it going blank on leave
const headerText = ref<string>("");
const bodyText = ref<string>("");
const isDangerous = ref<boolean>(false);
watchEffect(() => {
if (currentConfirmation.value === undefined) {
return;
}
headerText.value = currentConfirmation.value.header;
bodyText.value = currentConfirmation.value.body;
isDangerous.value = currentConfirmation.value.dangerous;
if (currentConfirmation.value === undefined) {
return;
}
headerText.value = currentConfirmation.value.header;
bodyText.value = currentConfirmation.value.body;
isDangerous.value = currentConfirmation.value.dangerous;
});
const confirm = (options: ConfirmOptions): ResultAsync<boolean, never> => {
const confirmation = new Confirmation(options.header, options.body, options.dangerous ?? false);
pushConfirmation(confirmation);
return ResultAsync.fromSafePromise(confirmation.promise);
const confirmation = new Confirmation(
options.header,
options.body,
options.dangerous ?? false
);
pushConfirmation(confirmation);
return ResultAsync.fromSafePromise(confirmation.promise);
};
const confirmBeforeAction = (
options: ConfirmOptions,
action: Action<any, any, any>
options: ConfirmOptions,
action: Action<any, any, any>
): typeof action => {
return (...args: Parameters<typeof action>): ReturnType<typeof action> | ResultAsync<null, never> => {
return confirm(options).andThen((confirmed) => {
if (!confirmed) {
return okAsync(null);
}
return action(...args);
});
};
return (
...args: Parameters<typeof action>
): ReturnType<typeof action> | ResultAsync<null, never> => {
return confirm(options).andThen((confirmed) => {
if (!confirmed) {
return okAsync(null);
}
return action(...args);
});
};
};
type ValueElseUndefiend<T> = T extends
| string
| number
| boolean
| symbol
| object
? T
: undefined;
const assertConfirm = <T,>(
options: ConfirmOptions,
resultIfConfirmed?: T
): ResultAsync<ValueElseUndefiend<T>, Error> => {
return confirm(options).andThen((confirmed) =>
confirmed
? okAsync(resultIfConfirmed as any)
: errAsync(new Error("Cancelled by user."))
);
};
defineExpose({
confirm,
confirmBeforeAction,
confirm,
confirmBeforeAction,
assertConfirm,
});
</script>

<template>
<Modal :show="currentConfirmation !== undefined">
<CardContainer class="sm:min-w-96">
<template #header>
{{ headerText }}
</template>
<div class="flex flex-row items-center gap-2">
<ExclamationCircleIcon
v-if="isDangerous"
class="size-icon-xl icon-danger shrink-0"
/>
<div class="grow overflow-x-auto whitespace-pre">
{{ bodyText }}
</div>
</div>
<template #footer>
<div class="button-group-row justify-end grow">
<button
class="btn btn-secondary"
@click="resolveCurrent(false)"
>{{ _("Cancel") }}</button>
<button
class="btn"
:class="isDangerous ? 'btn-danger' : 'btn-primary'"
@click="resolveCurrent(true)"
>{{ _("Confirm") }}</button>
</div>
</template>
</CardContainer>
</Modal>
<Modal :show="currentConfirmation !== undefined">
<CardContainer class="sm:min-w-96">
<template #header>
{{ headerText }}
</template>
<div class="flex flex-row items-center gap-2">
<ExclamationCircleIcon
v-if="isDangerous"
class="size-icon-xl icon-danger shrink-0"
/>
<div class="grow overflow-x-auto whitespace-pre">
{{ bodyText }}
</div>
</div>
<template #footer>
<div class="button-group-row justify-end grow">
<button class="btn btn-secondary" @click="resolveCurrent(false)">
{{ _("Cancel") }}
</button>
<button
class="btn"
:class="isDangerous ? 'btn-danger' : 'btn-primary'"
@click="resolveCurrent(true)"
>
{{ _("Confirm") }}
</button>
</div>
</template>
</CardContainer>
</Modal>
</template>
46 changes: 46 additions & 0 deletions houston-common-ui/lib/composables/globalModalConfirm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { type ConfirmOptions } from "@/components/modals";
import { ResultAsync } from "neverthrow";
import { type Action } from "@/composables/wrapActions";
import { ref } from "vue";

export type GlobalModalConfirmFunctions = {
confirm: (options: ConfirmOptions) => ResultAsync<boolean, never>;
confirmBeforeAction: (
options: ConfirmOptions,
action: Action<any, any, any>
) => typeof action;
assertConfirm: (
options: ConfirmOptions,
resultIfConfirmed?: unknown
) => ResultAsync<typeof resultIfConfirmed, Error>;
};

const globalModalConfirmFuncs = ref<GlobalModalConfirmFunctions>();

export namespace _internal {
export const provideGlobalModalFuncs = (
funcs: GlobalModalConfirmFunctions
) => {
globalModalConfirmFuncs.value = funcs;
};
}

const getGlobalModalConfirmFuncs = () => {
if (globalModalConfirmFuncs.value === undefined) {
throw new Error("Global ModalConfirm methods not provided!");
}
return globalModalConfirmFuncs.value;
};

export const confirm = (
...args: Parameters<GlobalModalConfirmFunctions["confirm"]>
): ReturnType<GlobalModalConfirmFunctions["confirm"]> =>
getGlobalModalConfirmFuncs().confirm(...args);
export const confirmBeforeAction = (
...args: Parameters<GlobalModalConfirmFunctions["confirmBeforeAction"]>
): ReturnType<GlobalModalConfirmFunctions["confirmBeforeAction"]> =>
getGlobalModalConfirmFuncs().confirmBeforeAction(...args);
export const assertConfirm = (
...args: Parameters<GlobalModalConfirmFunctions["assertConfirm"]>
): ReturnType<GlobalModalConfirmFunctions["assertConfirm"]> =>
getGlobalModalConfirmFuncs().assertConfirm(...args);
5 changes: 5 additions & 0 deletions houston-common-ui/lib/composables/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@ export * from "./useDarkModeState";
export * from "./useGlobalProcessingState";
export * from "./useTempObjectStaging";
export * from "./wrapActions";
export {
confirm,
confirmBeforeAction,
assertConfirm,
} from "./globalModalConfirm";

0 comments on commit 638e070

Please sign in to comment.