Skip to content

Commit

Permalink
Feature(frontend): Add onBefore signal for actions
Browse files Browse the repository at this point in the history
### Changelog:
* Feature(frontend): Add onBefore signal for actions.
* Feature(frontend): Add showConfirmationModal utility.
* Fix(frontend): Fix buttons type in confirmation modal.

Closes: vst/vst-utils#648+

See merge request vst/vst-utils!661
  • Loading branch information
onegreyonewhite committed Aug 7, 2024
2 parents ba00784 + a559639 commit 4bfe734
Show file tree
Hide file tree
Showing 14 changed files with 295 additions and 132 deletions.
137 changes: 76 additions & 61 deletions doc/locale/ru/LC_MESSAGES/quickstart-front.po

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions doc/quickstart-front.rst
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,31 @@ Example of simple frontend entrypoint:
});


Operation views hooks
---------------------
Function `hookViewOperation` can be used to execute some custom code before action execution.
Action may be prevented if `prevent` is set to true on returned object.

.. sourcecode:: typescript

import { hookViewOperation, showConfirmationModal } from '@vstconsulting/vstutils';

hookViewOperation({
path: '/category/{id}/change_parent/',
operation: 'execute',
onBefore: async () => {
const isConfirmed = await showConfirmationModal({
title: 'Are you sure?',
text: 'Changing category parent is irreversible',
confirmButtonText: 'Change',
cancelButtonText: 'Cancel',
});
return {
prevent: !isConfirmed,
};
},
});


.. _field-section:

Expand Down
2 changes: 2 additions & 0 deletions frontend_src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ export {
onSchemaLoaded,
onSchemaModelsCreated,
onSchemaViewsCreated,
hookViewOperation,
} from './vstutils/signals';
export { type AppSchema } from './vstutils/schema';
export { defineFieldComponent } from './vstutils/fields/base/defineFieldComponent';
export { BaseField } from './vstutils/fields/base/BaseField';
export { showConfirmationModal } from './vstutils/confirmation-modal';
25 changes: 25 additions & 0 deletions frontend_src/vstutils/__tests__/operation-hooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { createApp, createSchema, useTestCtx } from '#unittests';
import { hookViewOperation } from '../signals';

test('operations hooks', async () => {
const onBefore = vitest.fn((() => {
return {
prevent: true,
};
}) satisfies Parameters<typeof hookViewOperation>[0]['onBefore']);

hookViewOperation({ path: '/user/new/', operation: 'save', onBefore });

await createApp({ schema: createSchema() });
const { screen, app, user } = useTestCtx();

await app.router.push('/user/new/');

const saveBtn = await screen.findByTitle('Save');
await user.click(saveBtn);

expect(onBefore).toBeCalledTimes(1);
expect(onBefore).toBeCalledWith({
operation: app.views.get('/user/new/')!.actions.get('save'),
});
});
9 changes: 8 additions & 1 deletion frontend_src/vstutils/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export class ActionsManager {
return this.app.router.currentRoute.meta?.view as IView;
}

execute(args: {
async execute(args: {
action: Action;
instance?: Model;
instances?: Model[];
Expand All @@ -44,6 +44,13 @@ export class ActionsManager {
disablePopUp = false,
} = args;

if (action.onBefore) {
const { prevent } = (await action.onBefore({ operation: action })) ?? {};
if (prevent) {
return;
}
}

if (action.confirmationRequired && !skipConfirmation) {
return this.app.initActionConfirmationModal({ title: action.title }).then(() => {
args.skipConfirmation = true;
Expand Down
21 changes: 20 additions & 1 deletion frontend_src/vstutils/components/common/AppModals.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,40 @@
@confirm="reloadPage"
/>

<ConfirmModal
v-if="_currentConfirmationModal"
ref="customConfirmationModal"
:title="_currentConfirmationModal.title"
:message="_currentConfirmationModal.text"
:confirm-title="_currentConfirmationModal.confirmButtonText"
:reject-title="_currentConfirmationModal.cancelButtonText"
@confirm="_currentConfirmationModal.confirm"
@reject="_currentConfirmationModal.reject"
/>

<slot />
</div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { ref, watchEffect } from 'vue';
import { i18n } from '#vstutils/translation';
import { getApp, saveAllSettings } from '#vstutils/utils';
import ConfirmModal from './ConfirmModal.vue';
import { _currentConfirmationModal } from '#vstutils/confirmation-modal';
const app = getApp();
const saveSettingsModal = ref<InstanceType<typeof ConfirmModal>>();
const confirmationModal = ref<InstanceType<typeof ConfirmModal>>();
const reloadPageModal = ref<InstanceType<typeof ConfirmModal>>();
const customConfirmationModal = ref<InstanceType<typeof ConfirmModal>>();
watchEffect(() => {
if (customConfirmationModal.value) {
customConfirmationModal.value.openModal();
}
});
const confirmation = ref<{ callback: null | (() => void); actionName: string }>({
callback: null,
Expand Down
4 changes: 2 additions & 2 deletions frontend_src/vstutils/components/common/ConfirmModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
</div>
</template>
<template #footer>
<button class="btn btn-success" @click="callConfirm">
<button class="btn btn-success" type="button" @click="callConfirm">
{{ $t(confirmTitle) }}
</button>
<button class="btn btn-secondary" style="float: right" @click="callReject">
<button class="btn btn-secondary" style="float: right" type="button" @click="callReject">
{{ $t(rejectTitle) }}
</button>
</template>
Expand Down
35 changes: 35 additions & 0 deletions frontend_src/vstutils/confirmation-modal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { shallowRef } from 'vue';

/**
* @internal
*/
export const _currentConfirmationModal = shallowRef<{
title: string;
text: string;
confirmButtonText?: string;
cancelButtonText?: string;

confirm: () => void;
reject: () => void;
}>();

export function showConfirmationModal(params: {
title: string;
text: string;
confirmButtonText?: string;
cancelButtonText?: string;
}): Promise<boolean> {
return new Promise((resolve) => {
_currentConfirmationModal.value = {
...params,
confirm: () => {
_currentConfirmationModal.value = undefined;
resolve(true);
},
reject: () => {
_currentConfirmationModal.value = undefined;
resolve(false);
},
};
});
}
25 changes: 24 additions & 1 deletion frontend_src/vstutils/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { capitalize, getApp } from '#vstutils/utils';
import type { Model } from '#vstutils/models';
import type { IAppInitialized } from '#vstutils/app';
import type { RepresentData } from '#vstutils/utils';
import type { Action, Sublink } from '#vstutils/views';
import type { Action, OperationOnBeforeHook, Sublink } from './views/operations';
import type { RouteConfig } from 'vue-router';
import type { AppSchema } from './schema';

Expand Down Expand Up @@ -90,6 +90,29 @@ export const onAppBeforeInit = createHook<[{ app: IAppInitialized }]>(APP_BEFORE
export const onSchemaViewsCreated = createHook<[{ views: IAppInitialized['views'] }]>(SCHEMA_VIEWS_CREATED);
export const onRoutesCreated = createHook<[RouteConfig[]]>(ROUTES_CREATED);
export const onSchemaLoaded = createHook<[AppSchema]>(SCHEMA_LOADED);
export const hookViewOperation = (params: {
path: string;
operation: string;
onBefore?: OperationOnBeforeHook;
}) => {
onSchemaViewsCreated(({ views }) => {
const view = views.get(params.path);
if (!view) {
console.error(`View "${params.path}" not found`);
return;
}

const operation = view.actions.get(params.operation) || view.sublinks.get(params.operation);
if (!operation) {
console.error(`Operation "${params.operation}" for view ${params.path} not found`);
return;
}

if (params.onBefore) {
operation.onBefore = params.onBefore;
}
});
};

export const onSchemaModelsCreated =
createHook<[{ app: IAppInitialized; models: Map<string, Model> }]>(SCHEMA_MODELS_CREATED);
Expand Down
2 changes: 2 additions & 0 deletions frontend_src/vstutils/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,3 +515,5 @@ export function escapeHtml(unsafe: string) {
}

export const OBJECT_NOT_FOUND_TEXT = '[Object not found]';

export type MaybePromise<T> = T | Promise<T>;
57 changes: 1 addition & 56 deletions frontend_src/vstutils/views/View.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import {
import { i18n } from '#vstutils/translation';

import type { ComponentOptionsMixin } from 'vue/types/v3-component-options';
import type { IAppInitialized } from '#vstutils/app';
import type { Model, ModelConstructor } from '#vstutils/models';
import type {
BaseViewStore,
Expand All @@ -47,68 +46,14 @@ import type { Operation as SwaggerOperation } from 'swagger-schema-official';
import type { QuerySet } from '../querySet';
import type { BaseField, Field } from '../fields/base';
import type { ViewProps } from './props';
import type { Action, NotEmptyAction, Sublink } from './operations';

export { ViewTypes };

export interface Operation {
name: string;
title: string;
style?: Record<string, string | number> | string;
classes?: string[];
iconClasses?: string[];
appendFragment?: string;
hidden?: boolean;
doNotShowOnList?: boolean;
doNotGroup?: boolean;
isFileResponse?: boolean;
auth?: boolean;
}

export interface Sublink extends Operation {
href?: string | (() => string);
external?: boolean;
}

type ViewMixin = unknown;

const getViewStoreId = createUniqueIdGenerator();

/**
* Object that describes one action.
* For empty action path and method are required.
* For non empty action component or href must me provided.
*/
export interface Action extends Operation {
isEmpty?: boolean;
isMultiAction?: boolean;
component?: any;
path?: string;
href?: string;
method?: HttpMethod;
auth?: boolean;
confirmationRequired?: boolean;
view?: ActionView;
responseModel?: ModelConstructor;
handler?: (args: {
action: Action;
instance?: Model;
fromList?: boolean;
disablePopUp?: boolean;
}) => Promise<any> | any;
handlerMany?: (args: {
action: Action;
instances: Model[];
disablePopUp?: boolean;
}) => Promise<any> | any;
redirectPath?: string | (() => string);
onAfter?: (args: { app: IAppInitialized; action: Action; response: unknown; instance?: Model }) => void;
}

export interface NotEmptyAction extends Action {
isEmpty: false;
requestModel: ModelConstructor;
}

type ViewType = keyof typeof ViewTypes;

export interface ViewParams extends SwaggerOperation {
Expand Down
1 change: 1 addition & 0 deletions frontend_src/vstutils/views/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export { ViewConstructor, ViewsTree };

export * from './View';
export * from './props';
export * from './operations';
12 changes: 2 additions & 10 deletions frontend_src/vstutils/views/openapi.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,8 @@
import { defineAsyncComponent } from 'vue';
import { getApp, ViewTypes } from '#vstutils/utils';

import type {
Action,
ActionView,
IView,
ListView,
PageEditView,
PageNewView,
PageView,
ViewStore,
} from './View';
import type { ActionView, IView, ListView, PageEditView, PageNewView, PageView, ViewStore } from './View';
import type { Action } from './operations';

type Operations = Record<string, Record<string, Action>>;

Expand Down
Loading

0 comments on commit 4bfe734

Please sign in to comment.