2
0
mirror of https://github.com/frappe/books.git synced 2025-01-22 22:58:28 +00:00

refactor: Dropdown.vue (add types, simplify)

- fix dropdown keyboard item selection
- add new entry shortcut to ListView
- rename doc to entry in a few places
This commit is contained in:
18alantom 2023-03-20 11:10:07 +05:30 committed by Alan
parent 74f4513a7e
commit 8cef2ae60f
5 changed files with 134 additions and 125 deletions

View File

@ -72,7 +72,6 @@ export type SelectOption = { value: string; label: string };
export interface OptionField extends Omit<BaseField, 'fieldtype'> { export interface OptionField extends Omit<BaseField, 'fieldtype'> {
fieldtype: OptionFieldType; fieldtype: OptionFieldType;
options: SelectOption[]; options: SelectOption[];
emptyMessage?: string;
allowCustom?: boolean; allowCustom?: boolean;
} }
@ -85,7 +84,6 @@ export interface TargetField extends Omit<BaseField, 'fieldtype'> {
export interface DynamicLinkField extends Omit<BaseField, 'fieldtype'> { export interface DynamicLinkField extends Omit<BaseField, 'fieldtype'> {
fieldtype: DynamicLinkFieldType; fieldtype: DynamicLinkFieldType;
emptyMessage?: string;
references: string; // Reference to an option field that links to schema references: string; // Reference to an option field that links to schema
} }

View File

@ -21,13 +21,17 @@
{{ t`Loading...` }} {{ t`Loading...` }}
</div> </div>
<div <div
v-if="!isLoading && dropdownItems.length === 0" v-else-if="dropdownItems.length === 0"
class="p-2 text-gray-600 italic" class="p-2 text-gray-600 italic"
> >
{{ getEmptyMessage() }} {{ getEmptyMessage() }}
</div> </div>
<template v-else> <template v-else>
<div v-for="d in dropdownItems" :key="d.label"> <div
v-for="(d, index) in dropdownItems"
:key="index + d.label"
ref="items"
>
<div <div
v-if="d.isGroup" v-if="d.isGroup"
class=" class="
@ -45,7 +49,6 @@
</div> </div>
<a <a
v-else v-else
ref="items"
class=" class="
block block
p-2 p-2
@ -55,13 +58,12 @@
cursor-pointer cursor-pointer
truncate truncate
" "
:class="d.index === highlightedIndex ? 'bg-gray-100' : ''" :class="index === highlightedIndex ? 'bg-gray-100' : ''"
@mouseenter="highlightedIndex = d.index" @mouseenter="highlightedIndex = index"
@mouseleave="highlightedIndex = -1"
@mousedown.prevent @mousedown.prevent
@click="selectItem(d)" @click="selectItem(d)"
> >
<component :is="d.component" v-if="d.component" /> <component v-if="d.component" :is="d.component" />
<template v-else>{{ d.label }}</template> <template v-else>{{ d.label }}</template>
</a> </a>
</div> </div>
@ -71,24 +73,29 @@
</template> </template>
</Popover> </Popover>
</template> </template>
<script lang="ts">
<script> import { Doc } from 'fyo/model/doc';
import uniq from 'lodash/uniq'; import { Field } from 'schemas/types';
import { fyo } from 'src/initFyo'; import { fyo } from 'src/initFyo';
import { nextTick } from 'vue'; import { defineComponent, PropType } from 'vue';
import Popover from './Popover.vue'; import Popover from './Popover.vue';
export default { type DropdownItem = {
label: string;
value?: string;
action?: Function;
group?: string;
component?: { template: string };
isGroup?: boolean;
};
export default defineComponent({
name: 'Dropdown', name: 'Dropdown',
props: { props: {
items: { items: {
type: Array, type: Array as PropType<DropdownItem[]>,
default: () => [], default: () => [],
}, },
groups: {
type: Array,
default: null,
},
right: { right: {
type: Boolean, type: Boolean,
default: false, default: false,
@ -98,9 +105,11 @@ export default {
default: false, default: false,
}, },
df: { df: {
type: Object as PropType<Field | null>,
default: null, default: null,
}, },
doc: { doc: {
type: Object as PropType<Doc | null>,
default: null, default: null,
}, },
}, },
@ -113,92 +122,53 @@ export default {
highlightedIndex: -1, highlightedIndex: -1,
}; };
}, },
computed: { watch: {
sortedGroups() { highlightedIndex() {
if (Array.isArray(this.groups)) { this.scrollToHighlighted();
return this.groups;
}
let groupNames = uniq(
this.items
.map((d) => d.group)
.filter(Boolean)
.sort()
);
if (groupNames.length > 0) {
return groupNames;
}
return null;
}, },
dropdownItems() { dropdownItems() {
if (this.sortedGroups) { const maxed = Math.max(this.highlightedIndex, -1);
let itemsByGroup = {}; this.highlightedIndex = Math.min(maxed, this.dropdownItems.length - 1);
},
},
computed: {
dropdownItems(): DropdownItem[] {
const groupedItems = getGroupedItems(this.items ?? []);
const groupNames = Object.keys(groupedItems).filter(Boolean).sort();
for (let item of this.items) { const items: DropdownItem[] = groupedItems[''] ?? [];
let group = item.group || ''; for (let group of groupNames) {
itemsByGroup[group] = itemsByGroup[group] || []; items.push({
itemsByGroup[group].push(item); label: group,
} isGroup: true,
});
let items = []; const grouped = groupedItems[group] ?? [];
let i = 0; items.push(...grouped);
const noGroupItems = itemsByGroup[''];
if (noGroupItems?.length) {
items = items.concat(
noGroupItems.map((d) => {
d.index = i++;
return d;
})
);
}
for (let group of this.sortedGroups) {
let groupItems = itemsByGroup[group];
groupItems = groupItems.map((d) => {
d.index = i++;
return d;
});
items = items.concat(
{
label: group,
isGroup: true,
},
groupItems
);
}
return items;
} }
return this.items.map((d, i) => { return items;
d.index = i;
return d;
});
}, },
}, },
methods: { methods: {
getEmptyMessage() { getEmptyMessage(): string {
if (this.df === null) { const { schemaName, fieldname } = this.df ?? {};
if (!schemaName || !fieldname || !this.doc) {
return this.t`Empty`; return this.t`Empty`;
} }
if (this.df.emptyMessage) {
return this.df.emptyMessage;
}
const { schemaName, fieldname } = this.df;
const emptyMessage = fyo.models[schemaName]?.emptyMessages[fieldname]?.( const emptyMessage = fyo.models[schemaName]?.emptyMessages[fieldname]?.(
this.doc this.doc
); );
if (emptyMessage === undefined || emptyMessage.length === 0) { if (!emptyMessage) {
return this.t`Empty`; return this.t`Empty`;
} }
return emptyMessage; return emptyMessage;
}, },
async selectItem(d) { async selectItem(d?: DropdownItem): Promise<void> {
if (!d?.action) { if (!d || !d?.action) {
return; return;
} }
@ -208,51 +178,61 @@ export default {
await d.action(); await d.action();
}, },
toggleDropdown(flag) { toggleDropdown(flag?: boolean): void {
if (flag == null) { if (typeof flag !== 'boolean') {
this.isShown = !this.isShown; flag = !this.isShown;
} else {
this.isShown = Boolean(flag);
} }
this.isShown = flag;
}, },
async selectHighlightedItem() { async selectHighlightedItem(): Promise<void> {
if (![-1, this.items.length].includes(this.highlightedIndex)) { let item = this.items[this.highlightedIndex];
// valid selection if (!item && this.dropdownItems.length === 1) {
let item = this.items[this.highlightedIndex]; item = this.dropdownItems[0];
await this.selectItem(item);
} else if (this.items.length === 1) {
await this.selectItem(this.items[0]);
} }
return await this.selectItem(item);
}, },
highlightItemUp(e) { highlightItemUp(e?: Event): void {
e?.preventDefault(); e?.preventDefault();
this.highlightedIndex -= 1; this.highlightedIndex = Math.max(0, this.highlightedIndex - 1);
if (this.highlightedIndex < 0) {
this.highlightedIndex = 0;
}
nextTick(() => {
this.scrollToHighlighted();
});
}, },
highlightItemDown(e) { highlightItemDown(e?: Event): void {
e?.preventDefault(); e?.preventDefault();
this.highlightedIndex += 1; this.highlightedIndex = Math.min(
if (this.highlightedIndex >= this.items.length) { this.dropdownItems.length - 1,
this.highlightedIndex = this.items.length - 1; this.highlightedIndex + 1
);
},
scrollToHighlighted(): void {
const elems = this.$refs.items;
if (!Array.isArray(elems)) {
return;
} }
nextTick(() => { const highlightedElement = elems[this.highlightedIndex];
this.scrollToHighlighted(); if (!(highlightedElement instanceof Element)) {
}); return;
}, }
scrollToHighlighted() {
let highlightedElement = this.$refs.items[this.highlightedIndex]; highlightedElement.scrollIntoView({ block: 'nearest' });
highlightedElement &&
highlightedElement.scrollIntoView({ block: 'nearest' });
}, },
}, },
}; });
function getGroupedItems(
items: DropdownItem[]
): Record<string, DropdownItem[]> {
const groupedItems: Record<string, DropdownItem[]> = {};
for (let item of items) {
const group = item.group ?? '';
groupedItems[group] ??= [];
groupedItems[group].push(item);
}
return groupedItems;
}
</script> </script>

View File

@ -27,6 +27,7 @@ export default {
emits: ['open', 'close'], emits: ['open', 'close'],
props: { props: {
showPopup: { showPopup: {
type: [Boolean, null],
default: null, default: null,
}, },
right: Boolean, right: Boolean,

View File

@ -83,27 +83,38 @@ export default defineComponent({
], ],
}, },
{ {
label: t`Doc`, label: t`Entry`,
description: t`Applicable when a Doc is open in the Form view or Quick Edit view`, description: t`Applicable when a entry is open in the Form view or Quick Edit view`,
collapsed: false, collapsed: false,
shortcuts: [ shortcuts: [
{ {
shortcut: [ShortcutKey.pmod, 'S'], shortcut: [ShortcutKey.pmod, 'S'],
description: [ description: [
t`Save or Submit a doc.`, t`Save or Submit an entry.`,
t`A doc is submitted only if it is submittable and is in the saved state.`, t`An entry is submitted only if it is submittable and is in the saved state.`,
].join(' '), ].join(' '),
}, },
{ {
shortcut: [ShortcutKey.pmod, ShortcutKey.delete], shortcut: [ShortcutKey.pmod, ShortcutKey.delete],
description: [ description: [
t`Cancel or Delete a doc.`, t`Cancel or Delete an entry.`,
t`A doc is cancelled only if it is in the submitted state.`, t`An entry is cancelled only if it is in the submitted state.`,
t`A submittable doc is deleted only if it is in the cancelled state.`, t`A submittable entry is deleted only if it is in the cancelled state.`,
].join(' '), ].join(' '),
}, },
], ],
}, },
{
label: t`List View`,
description: t`Applicable when the List View of an entry type is open`,
collapsed: false,
shortcuts: [
{
shortcut: [ShortcutKey.pmod, 'N'],
description: t`Create a new entry of the same type as the List View`,
},
],
},
{ {
label: t`Quick Search`, label: t`Quick Search`,
description: t`Applicable when Quick Search is open`, description: t`Applicable when Quick Search is open`,

View File

@ -55,6 +55,7 @@ import {
} from 'src/utils/misc'; } from 'src/utils/misc';
import { docsPathRef } from 'src/utils/refs'; import { docsPathRef } from 'src/utils/refs';
import { openQuickEdit, routeTo } from 'src/utils/ui'; import { openQuickEdit, routeTo } from 'src/utils/ui';
import { Shortcuts } from 'src/utils/vueUtils';
import List from './List.vue'; import List from './List.vue';
export default { export default {
@ -79,6 +80,7 @@ export default {
listFilters: {}, listFilters: {},
}; };
}, },
inject: { shortcutManager: { from: 'shortcuts' } },
async activated() { async activated() {
if (typeof this.filters === 'object') { if (typeof this.filters === 'object') {
this.$refs.filterDropdown.setFilter(this.filters, true); this.$refs.filterDropdown.setFilter(this.filters, true);
@ -90,9 +92,12 @@ export default {
if (this.fyo.store.isDevelopment) { if (this.fyo.store.isDevelopment) {
window.lv = this; window.lv = this;
} }
this.shortcuts.pmod.set(['KeyN'], this.makeNewDoc);
}, },
deactivated() { deactivated() {
docsPathRef.value = ''; docsPathRef.value = '';
this.shortcuts.pmod.delete(['KeyN']);
}, },
methods: { methods: {
updatedData(listFilters) { updatedData(listFilters) {
@ -113,6 +118,10 @@ export default {
}); });
}, },
async makeNewDoc() { async makeNewDoc() {
if (!this.canCreate) {
return;
}
const filters = getCreateFiltersFromListViewFilters(this.filters ?? {}); const filters = getCreateFiltersFromListViewFilters(this.filters ?? {});
const doc = fyo.doc.getNewDoc(this.schemaName, filters); const doc = fyo.doc.getNewDoc(this.schemaName, filters);
const path = this.getFormPath(doc); const path = this.getFormPath(doc);
@ -165,6 +174,16 @@ export default {
canCreate() { canCreate() {
return fyo.schemaMap[this.schemaName].create !== false; return fyo.schemaMap[this.schemaName].create !== false;
}, },
shortcuts() {
// @ts-ignore
const shortcutManager = this.shortcutManager;
if (shortcutManager instanceof Shortcuts) {
return shortcutManager;
}
// no-op (hopefully)
throw Error('Shortcuts Not Found');
},
}, },
}; };