2
0
mirror of https://github.com/frappe/books.git synced 2024-09-20 03:29:00 +00:00

refactor: use composable for doc shortcuts in Forms

- type Toast.vue, use constants for duration
This commit is contained in:
18alantom 2023-03-23 12:25:25 +05:30 committed by Alan
parent e1b502b138
commit 08343ae18e
10 changed files with 355 additions and 96 deletions

View File

@ -34,11 +34,16 @@
/>
</div>
</template>
<script>
<script lang="ts">
import { getColorClass } from 'src/utils/colors';
import { ToastDuration, ToastType } from 'src/utils/types';
import { toastDurationMap } from 'src/utils/ui';
import { defineComponent, PropType } from 'vue';
import FeatherIcon from './FeatherIcon.vue';
export default {
type TimeoutId = ReturnType<typeof setTimeout>;
export default defineComponent({
components: {
FeatherIcon,
},
@ -46,16 +51,21 @@ export default {
return {
opacity: 0,
show: true,
opacityTimeoutId: -1,
cleanupTimeoutId: -1,
opacityTimeoutId: null,
cleanupTimeoutId: null,
} as {
opacity: number;
show: boolean;
opacityTimeoutId: null | TimeoutId;
cleanupTimeoutId: null | TimeoutId;
};
},
props: {
message: { type: String, required: true },
action: { type: Function, default: () => {} },
actionText: { type: String, default: '' },
type: { type: String, default: 'info' },
duration: { type: Number, default: 5000 },
type: { type: String as PropType<ToastType>, default: 'info' },
duration: { type: String as PropType<ToastDuration>, default: 'long' },
},
computed: {
iconName() {
@ -77,22 +87,23 @@ export default {
}[this.type];
},
iconColor() {
return getColorClass(this.color, 'text', 400);
return getColorClass(this.color ?? 'gray', 'text', 400);
},
},
mounted() {
const duration = toastDurationMap[this.duration];
setTimeout(() => {
this.opacity = 1;
}, 50);
this.opacityTimeoutId = setTimeout(() => {
this.opacity = 0;
}, this.duration);
}, duration);
this.cleanupTimeoutId = setTimeout(() => {
this.show = false;
this.cleanup();
}, this.duration + 300);
}, duration + 300);
},
methods: {
actionClicked() {
@ -100,8 +111,12 @@ export default {
this.closeToast();
},
closeToast() {
clearTimeout(this.opacityTimeoutId);
clearTimeout(this.cleanupTimeoutId);
if (this.opacityTimeoutId != null) {
clearTimeout(this.opacityTimeoutId);
}
if (this.cleanupTimeoutId != null) {
clearTimeout(this.cleanupTimeoutId);
}
this.opacity = 0;
setTimeout(() => {
@ -110,11 +125,16 @@ export default {
}, 300);
},
cleanup() {
Array.from(this.$el.parentElement?.children ?? [])
const element = this.$el;
if (!(element instanceof Element)) {
return;
}
Array.from(element.parentElement?.children ?? [])
.filter((el) => !el.innerHTML)
.splice(1)
.forEach((el) => el.remove());
},
},
};
});
</script>

View File

@ -5,7 +5,8 @@
</template>
<template #header v-if="hasDoc">
<Button
v-if="!doc.isCancelled && !doc.dirty && isPrintable"
v-if="canPrint"
ref="printButton"
:icon="true"
@click="routeTo(`/print/${doc.schemaName}/${doc.name}`)"
>
@ -122,7 +123,7 @@ import StatusBadge from 'src/components/StatusBadge.vue';
import { getErrorMessage } from 'src/utils';
import { docsPathMap } from 'src/utils/misc';
import { docsPathRef } from 'src/utils/refs';
import { ActionGroup, UIGroupedFields } from 'src/utils/types';
import { ActionGroup, DocRef, UIGroupedFields } from 'src/utils/types';
import {
commonDocSubmit,
commonDocSync,
@ -135,12 +136,31 @@ import {
import { computed, defineComponent, nextTick } from 'vue';
import QuickEditForm from '../QuickEditForm.vue';
import CommonFormSection from './CommonFormSection.vue';
import { inject } from 'vue';
import { shortcutsKey } from 'src/utils/injectionKeys';
import { ref } from 'vue';
import { useDocShortcuts } from 'src/utils/vueUtils';
export default defineComponent({
props: {
name: { type: String, default: '' },
schemaName: { type: String, default: ModelNameEnum.SalesInvoice },
},
setup() {
const shortcuts = inject(shortcutsKey);
const docOrNull = ref(null) as DocRef;
let context = 'CommonForm';
if (shortcuts) {
context = useDocShortcuts(shortcuts, docOrNull, 'CommonForm', true);
}
return {
docOrNull,
shortcuts,
context,
printButton: ref<InstanceType<typeof Button> | null>(null),
};
},
provide() {
return {
schemaName: computed(() => this.docOrNull?.schemaName),
@ -151,14 +171,12 @@ export default defineComponent({
data() {
return {
errors: {},
docOrNull: null,
activeTab: this.t`Default`,
groupedFields: null,
quickEditDoc: null,
isPrintable: false,
} as {
errors: Record<string, string>;
docOrNull: null | Doc;
activeTab: string;
groupedFields: null | UIGroupedFields;
quickEditDoc: null | Doc;
@ -180,16 +198,30 @@ export default defineComponent({
},
activated(): void {
docsPathRef.value = docsPathMap[this.schemaName] ?? '';
this.shortcuts?.pmod.set(this.context, ['KeyP'], () => {
if (!this.canPrint) {
return;
}
this.printButton?.$el.click();
});
},
deactivated(): void {
docsPathRef.value = '';
},
computed: {
canPrint(): boolean {
if (!this.hasDoc) {
return false;
}
return !this.doc.isCancelled && !this.doc.dirty && this.isPrintable;
},
hasDoc(): boolean {
return !!this.docOrNull;
return this.docOrNull instanceof Doc;
},
hasQeDoc(): boolean {
return !!this.quickEditDoc;
return this.quickEditDoc instanceof Doc;
},
status(): string {
if (!this.hasDoc) {
@ -265,8 +297,8 @@ export default defineComponent({
this.doc
);
},
async sync() {
if (await commonDocSync(this.doc)) {
async sync(useDialog?: boolean) {
if (await commonDocSync(this.doc, useDialog)) {
this.updateGroupedFields();
}
},

View File

@ -21,7 +21,8 @@
"
/>
<Button
v-if="!doc.isCancelled && !doc.dirty"
v-if="canPrint"
ref="printButton"
:icon="true"
@click="routeTo(`/print/${doc.schemaName}/${doc.name}`)"
>
@ -313,6 +314,7 @@ import FormHeader from 'src/components/FormHeader.vue';
import StatusBadge from 'src/components/StatusBadge.vue';
import LinkedEntryWidget from 'src/components/Widgets/LinkedEntryWidget.vue';
import { fyo } from 'src/initFyo';
import { shortcutsKey } from 'src/utils/injectionKeys';
import { docsPathMap } from 'src/utils/misc';
import { docsPathRef } from 'src/utils/refs';
import {
@ -321,7 +323,8 @@ import {
getGroupedActionsForDoc,
routeTo,
} from 'src/utils/ui';
import { nextTick } from 'vue';
import { useDocShortcuts } from 'src/utils/vueUtils';
import { inject, nextTick, ref } from 'vue';
import { handleErrorWithDialog } from '../errorHandling';
import QuickEditForm from './QuickEditForm.vue';
@ -341,6 +344,22 @@ export default {
LinkedEntryWidget,
Barcode,
},
setup() {
const doc = ref(null);
const shortcuts = inject(shortcutsKey);
let context = 'InvoiceForm';
if (shortcuts) {
context = useDocShortcuts(shortcuts, doc, context, true);
}
return {
doc,
context,
shortcuts,
printButton: null,
};
},
provide() {
return {
schemaName: this.schemaName,
@ -351,7 +370,6 @@ export default {
data() {
return {
chstatus: false,
doc: null,
quickEditDoc: null,
quickEditFields: [],
color: null,
@ -364,6 +382,9 @@ export default {
this.chstatus = !this.chstatus;
},
computed: {
canPrint() {
return !this.doc.isCancelled && !this.doc.dirty;
},
stockTransferText() {
if (!this.fyo.singles.AccountingSettings.enableInventory) {
return '';
@ -458,6 +479,13 @@ export default {
},
activated() {
docsPathRef.value = docsPathMap[this.schemaName];
this.shortcuts?.pmod.set(this.context, ['KeyP'], () => {
if (!this.canPrint) {
return;
}
this.printButton?.$el.click();
});
},
deactivated() {
docsPathRef.value = '';

View File

@ -116,7 +116,7 @@ export default defineComponent({
},
deactivated() {
docsPathRef.value = '';
this.shortcuts?.delete(this);
this.shortcuts?.delete(this.context);
},
methods: {
setShortcuts() {
@ -124,10 +124,10 @@ export default defineComponent({
return;
}
this.shortcuts.pmod.set(this, ['KeyN'], () =>
this.shortcuts.pmod.set(this.context, ['KeyN'], () =>
this.makeNewDocButton?.$el.click()
);
this.shortcuts.pmod.set(this, ['KeyE'], () =>
this.shortcuts.pmod.set(this.context, ['KeyE'], () =>
this.exportButton?.$el.click()
);
},
@ -194,6 +194,9 @@ export default defineComponent({
},
},
computed: {
context(): string {
return 'ListView-' + this.schemaName;
},
title(): string {
if (this.pageTitle) {
return this.pageTitle;

View File

@ -113,14 +113,17 @@ import DropdownWithActions from 'src/components/DropdownWithActions.vue';
import StatusBadge from 'src/components/StatusBadge.vue';
import TwoColumnForm from 'src/components/TwoColumnForm.vue';
import { fyo } from 'src/initFyo';
import { shortcutsKey } from 'src/utils/injectionKeys';
import { getQuickEditWidget } from 'src/utils/quickEditWidgets';
import {
commonDocSubmit,
commonDocSync,
focusOrSelectFormControl,
getActionsForDoc,
openQuickEdit
commonDocSubmit,
commonDocSync,
focusOrSelectFormControl,
getActionsForDoc,
openQuickEdit,
} from 'src/utils/ui';
import { useDocShortcuts } from 'src/utils/vueUtils';
import { ref, inject } from 'vue';
export default {
name: 'QuickEditForm',
@ -146,6 +149,21 @@ export default {
DropdownWithActions,
},
emits: ['close'],
setup() {
const doc = ref(null);
const shortcuts = inject(shortcutsKey);
let context = 'QuickEditForm';
if (shortcuts) {
context = useDocShortcuts(shortcuts, doc, context, true);
}
return {
doc,
context,
shortcuts,
};
},
provide() {
return {
schemaName: this.schemaName,
@ -155,13 +173,17 @@ export default {
},
data() {
return {
doc: null,
values: null,
titleField: null,
imageField: null,
statusText: null,
};
},
activated() {
this.shortcuts.set(this.context, ['Escape'], () => {
this.routeToPrevious();
});
},
async mounted() {
if (this.defaults) {
this.values = JSON.parse(this.defaults);

View File

@ -228,7 +228,7 @@ import {
getPrintTemplatePropValues,
} from 'src/utils/printTemplates';
import { docsPathRef, showSidebar } from 'src/utils/refs';
import { PrintValues } from 'src/utils/types';
import { DocRef, PrintValues } from 'src/utils/types';
import {
focusOrSelectFormControl,
getActionsForDoc,
@ -239,14 +239,12 @@ import {
showMessageDialog,
showToast,
} from 'src/utils/ui';
import { useDocShortcuts } from 'src/utils/vueUtils';
import { getMapFromList } from 'utils/index';
import { computed, defineComponent } from 'vue';
import { computed, defineComponent, inject, ref } from 'vue';
import PrintContainer from './PrintContainer.vue';
import TemplateBuilderHint from './TemplateBuilderHint.vue';
import TemplateEditor from './TemplateEditor.vue';
import { inject } from 'vue';
const COMPONENT_NAME = 'TemplateBuilder';
export default defineComponent({
props: { name: String },
@ -262,8 +260,18 @@ export default defineComponent({
ShortcutKeys,
},
setup() {
const doc = ref(null) as DocRef<PrintTemplate>;
const shortcuts = inject(shortcutsKey);
let context = 'TemplateBuilder';
if (shortcuts) {
context = useDocShortcuts(shortcuts, doc, context, false);
}
return {
shortcuts: inject(shortcutsKey),
doc,
context,
shortcuts,
};
},
provide() {
@ -271,7 +279,6 @@ export default defineComponent({
},
data() {
return {
doc: null,
editMode: false,
showHints: false,
hints: undefined,
@ -290,7 +297,6 @@ export default defineComponent({
showHints: boolean;
hints?: Record<string, unknown>;
values: null | PrintValues;
doc: PrintTemplate | null;
displayDoc: PrintTemplate | null;
scale: number;
panelWidth: number;
@ -311,25 +317,27 @@ export default defineComponent({
},
async activated(): Promise<void> {
docsPathRef.value = docsPathMap.PrintTemplate ?? '';
this.setShortcuts;
this.setShortcuts();
},
deactivated(): void {
docsPathRef.value = '';
this.shortcuts?.delete(COMPONENT_NAME);
},
methods: {
setShortcuts() {
/**
* Node: Doc Save and Delete shortcuts are in the setup.
*/
if (!this.shortcuts) {
return;
}
this.shortcuts.ctrl.set(COMPONENT_NAME, ['Enter'], this.setTemplate);
this.shortcuts.ctrl.set(COMPONENT_NAME, ['KeyE'], this.toggleEditMode);
this.shortcuts.ctrl.set(COMPONENT_NAME, ['KeyH'], this.toggleShowHints);
this.shortcuts.ctrl.set(COMPONENT_NAME, ['Equal'], () =>
this.shortcuts.ctrl.set(this.context, ['Enter'], this.setTemplate);
this.shortcuts.ctrl.set(this.context, ['KeyE'], this.toggleEditMode);
this.shortcuts.ctrl.set(this.context, ['KeyH'], this.toggleShowHints);
this.shortcuts.ctrl.set(this.context, ['Equal'], () =>
this.setScale(this.scale + 0.1)
);
this.shortcuts.ctrl.set(COMPONENT_NAME, ['Minus'], () =>
this.shortcuts.ctrl.set(this.context, ['Minus'], () =>
this.setScale(this.scale - 0.1)
);
},
@ -385,7 +393,7 @@ export default defineComponent({
let message = this.t`Please set a Display Doc`;
if (!this.displayDoc) {
return showToast({ type: 'warning', message, duration: 1000 });
return showToast({ type: 'warning', message, duration: 'short' });
}
this.editMode = !this.editMode;

View File

@ -23,6 +23,20 @@ type ShortcutMap = Map<Context, Map<string, ShortcutConfig>>;
const mods: Readonly<Mod[]> = ['alt', 'ctrl', 'meta', 'repeat', 'shift'];
/**
* Used to add shortcuts based on **context**.
*
* **Context** is a identifier for where the shortcut belongs. For instance
* a _Form_ component having shortcuts for _Submit Form_.
*
* In the above example an app can have multiple instances of the _Form_
* component active at the same time, so the passed context should be a
* unique identifier such as the component object.
*
* If only one instance of a component is meant to be active at a time
* (for example a _Sidebar_ component) then do not use objects, use some
* primitive datatype (`string`).
*/
export class Shortcuts {
keys: Keys;
isMac: boolean;
@ -64,9 +78,9 @@ export class Shortcuts {
*
* @param context context in which the shortcut is to be checked
* @param shortcut shortcut that is to be checked
* @returns
* @returns boolean indicating presence
*/
has(context: Context, shortcut?: string[]) {
has(context: Context, shortcut?: string[]): boolean {
if (!shortcut) {
return this.shortcuts.has(context);
}
@ -86,7 +100,7 @@ export class Shortcuts {
* @param context context object to which the shortcut belongs
* @param shortcut keyboard event codes used as shortcut chord
* @param callback function to be called when the shortcut is pressed
* @param propagate whether to check and executs shortcuts in parent contexts
* @param propagate whether to check and execute shortcuts in earlier contexts
* @param removeIfSet whether to delete the set shortcut
*/
set(
@ -95,18 +109,14 @@ export class Shortcuts {
callback: ShortcutFunction,
propagate: boolean = false,
removeIfSet: boolean = true
) {
): void {
if (!this.shortcuts.has(context)) {
this.shortcuts.set(context, new Map());
}
const contextualShortcuts = this.shortcuts.get(context)!;
const key = this.getKey(shortcut);
if (removeIfSet) {
contextualShortcuts.delete(key);
}
if (contextualShortcuts.has(key)) {
const contextualShortcuts = this.shortcuts.get(context)!;
if (contextualShortcuts.has(key) && !removeIfSet) {
throw new Error(`Shortcut ${key} already exists.`);
}

View File

@ -1,8 +1,15 @@
import type { Doc } from 'fyo/model/doc';
import type { Action } from 'fyo/model/types';
import { ModelNameEnum } from 'models/types';
import type { ModelNameEnum } from 'models/types';
import type { Field, FieldType } from 'schemas/types';
import type { QueryFilter } from 'utils/db/types';
import type { Ref } from 'vue';
import type { toastDurationMap } from './ui';
export type DocRef<D extends Doc = Doc> = Ref<D | null>;
export type ToastType = 'info' | 'warning' | 'error' | 'success';
export type ToastDuration = keyof typeof toastDurationMap;
export interface MessageDialogButton {
label: string;
@ -17,8 +24,8 @@ export interface MessageDialogOptions {
export interface ToastOptions {
message: string;
type?: 'info' | 'warning' | 'error' | 'success';
duration?: number;
type?: ToastType;
duration?: ToastDuration;
action?: () => void;
actionText?: string;
}

View File

@ -33,6 +33,8 @@ import {
UIGroupedFields,
} from './types';
export const toastDurationMap = { short: 2_500, long: 5_000 } as const;
export async function openQuickEdit({
doc,
schemaName,
@ -318,12 +320,7 @@ function getCancelAction(doc: Doc): Action {
},
condition: (doc: Doc) => doc.canCancel,
async action() {
const res = await cancelDocWithPrompt(doc);
if (!res) {
return;
}
showActionToast(doc, 'cancel');
await commonDocCancel(doc);
},
};
}
@ -336,13 +333,7 @@ function getDeleteAction(doc: Doc): Action {
},
condition: (doc: Doc) => doc.canDelete,
async action() {
const res = await deleteDocWithPrompt(doc);
if (!res) {
return;
}
showActionToast(doc, 'delete');
router.back();
await commongDocDelete(doc);
},
};
}
@ -569,11 +560,39 @@ export function getShortcutKeyMap(
};
}
export async function commonDocSync(doc: Doc): Promise<boolean> {
try {
await doc.sync();
} catch (error) {
handleErrorWithDialog(error, doc);
export async function commongDocDelete(doc: Doc): Promise<boolean> {
const res = await deleteDocWithPrompt(doc);
if (!res) {
return false;
}
showActionToast(doc, 'delete');
router.back();
return true;
}
export async function commonDocCancel(doc: Doc): Promise<boolean> {
const res = await cancelDocWithPrompt(doc);
if (!res) {
return false;
}
showActionToast(doc, 'cancel');
return true;
}
export async function commonDocSync(
doc: Doc,
useDialog: boolean = false
): Promise<boolean> {
let success: boolean;
if (useDialog) {
success = !!(await showSubmitOrSyncDialog(doc, 'sync'));
} else {
success = await syncWithoutDialog(doc);
}
if (!success) {
return false;
}
@ -581,8 +600,19 @@ export async function commonDocSync(doc: Doc): Promise<boolean> {
return true;
}
async function syncWithoutDialog(doc: Doc): Promise<boolean> {
try {
await doc.sync();
} catch (error) {
handleErrorWithDialog(error, doc);
return false;
}
return true;
}
export async function commonDocSubmit(doc: Doc): Promise<boolean> {
const success = await showSubmitDialog(doc);
const success = await showSubmitOrSyncDialog(doc, 'submit');
if (!success) {
return false;
}
@ -591,12 +621,16 @@ export async function commonDocSubmit(doc: Doc): Promise<boolean> {
return true;
}
async function showSubmitDialog(doc: Doc) {
async function showSubmitOrSyncDialog(doc: Doc, type: 'submit' | 'sync') {
const label = doc.schema.label ?? doc.schemaName;
const message = t`Submit ${label}?`;
let message = t`Submit ${label}?`;
if (type === 'sync') {
message = t`Save ${label}?`;
}
const yesAction = async () => {
try {
await doc.submit();
await doc[type]();
} catch (error) {
handleErrorWithDialog(error, doc);
return false;
@ -630,7 +664,7 @@ function showActionToast(doc: Doc, type: 'sync' | 'cancel' | 'delete') {
delete: t`${label} deleted`,
}[type];
showToast({ type: 'success', message, duration: 2500 });
showToast({ type: 'success', message, duration: 'short' });
}
function showSubmitToast(doc: Doc) {
@ -639,21 +673,12 @@ function showSubmitToast(doc: Doc) {
const toastOption: ToastOptions = {
type: 'success',
message,
duration: 5000,
duration: 'long',
...getSubmitSuccessToastAction(doc),
};
showToast(toastOption);
}
function getToastLabel(doc: Doc) {
const label = doc.schema.label ?? doc.schemaName;
if (doc.schema.naming === 'random') {
return label;
}
return doc.name ?? label;
}
function getSubmitSuccessToastAction(doc: Doc) {
const isStockTransfer = doc instanceof Transfer;
const isTransactional = doc instanceof Transactional;
@ -680,3 +705,33 @@ function getSubmitSuccessToastAction(doc: Doc) {
return {};
}
export function showCannotSaveOrSubmitToast(doc: Doc) {
const label = getToastLabel(doc);
let message = t`${label} already saved`;
if (doc.schema.isSubmittable && doc.isSubmitted) {
message = t`${label} already submitted`;
}
showToast({ type: 'warning', message, duration: 'short' });
}
export function showCannotCancelOrDeleteToast(doc: Doc) {
const label = getToastLabel(doc);
let message = t`${label} cannot be deleted`;
if (doc.schema.isSubmittable && !doc.isCancelled) {
message = t`${label} cannot be cancelled`;
}
showToast({ type: 'warning', message, duration: 'short' });
}
function getToastLabel(doc: Doc) {
const label = doc.schema.label || doc.schemaName;
if (doc.schema.naming === 'random') {
return label;
}
return doc.name || label;
}

View File

@ -1,6 +1,23 @@
import { Keys } from 'utils/types';
import { onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import {
onActivated,
onDeactivated,
onMounted,
onUnmounted,
reactive,
ref,
} from 'vue';
import { getIsMac } from './misc';
import { Shortcuts } from './shortcuts';
import { DocRef } from './types';
import {
commonDocCancel,
commonDocSubmit,
commonDocSync,
commongDocDelete,
showCannotCancelOrDeleteToast,
showCannotSaveOrSubmitToast,
} from './ui';
export function useKeys() {
const isMac = getIsMac();
@ -72,3 +89,60 @@ export function useMouseLocation() {
return loc;
}
export function useDocShortcuts(
shortcuts: Shortcuts,
docRef: DocRef,
name: string,
isMultiple: boolean = true
) {
let context = name;
if (isMultiple) {
context = name + '-' + Math.random().toString(36).slice(2, 6);
}
const syncOrSubmitCallback = () => {
const doc = docRef.value;
if (!doc) {
return;
}
if (doc.canSave) {
return commonDocSync(doc, true);
}
if (doc.canSubmit) {
return commonDocSubmit(doc);
}
showCannotSaveOrSubmitToast(doc);
};
const cancelOrDeleteCallback = () => {
const doc = docRef.value;
if (!doc) {
return;
}
if (doc.canCancel) {
return commonDocCancel(doc);
}
if (doc.canDelete) {
return commongDocDelete(doc);
}
showCannotCancelOrDeleteToast(doc);
};
onActivated(() => {
shortcuts.pmod.set(context, ['KeyS'], syncOrSubmitCallback, false);
shortcuts.pmod.set(context, ['Backspace'], cancelOrDeleteCallback, false);
});
onDeactivated(() => {
shortcuts.delete(context);
});
return context;
}