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:
parent
74f4513a7e
commit
8cef2ae60f
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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,
|
||||||
|
@ -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`,
|
||||||
|
@ -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');
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user