mirror of
https://github.com/frappe/books.git
synced 2024-11-09 23:30:56 +00:00
feat: add search modal
This commit is contained in:
parent
758727b296
commit
89983f24e2
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<button
|
||||
class="focus:outline-none rounded-md shadow-button flex-center"
|
||||
class="focus:outline-none rounded-md shadow-button flex-center h-8"
|
||||
:style="style"
|
||||
:class="_class"
|
||||
v-bind="$attrs"
|
||||
|
@ -1,14 +1,20 @@
|
||||
<template>
|
||||
<div
|
||||
class="absolute w-screen h-screen z-20 flex justify-center items-center"
|
||||
style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(6px)"
|
||||
class="
|
||||
fixed
|
||||
top-0
|
||||
left-0
|
||||
w-screen
|
||||
h-screen
|
||||
z-20
|
||||
flex
|
||||
justify-center
|
||||
items-center
|
||||
"
|
||||
style="background: rgba(0, 0, 0, 0.2); backdrop-filter: blur(4px)"
|
||||
v-if="openModal"
|
||||
>
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-2xl"
|
||||
v-bind="$attrs"
|
||||
style="width: 600px"
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-2xl w-600" v-bind="$attrs">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -6,7 +6,7 @@
|
||||
<BackLink v-if="backLink" />
|
||||
<div class="flex items-stretch window-no-drag gap-2">
|
||||
<slot />
|
||||
<SearchBar v-show="!hideSearch" />
|
||||
<SearchBar v-if="!hideSearch"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -2,82 +2,145 @@
|
||||
const keys = useKeys();
|
||||
</script>
|
||||
<template>
|
||||
<div v-on-outside-click="clearInput" class="relative">
|
||||
<Dropdown :items="suggestions" class="text-sm h-full">
|
||||
<template
|
||||
v-slot="{
|
||||
toggleDropdown,
|
||||
highlightItemUp,
|
||||
highlightItemDown,
|
||||
selectHighlightedItem,
|
||||
}"
|
||||
<div>
|
||||
<!-- Search Bar Button -->
|
||||
<button
|
||||
@click="open"
|
||||
class="
|
||||
focus:outline-none
|
||||
shadow-button
|
||||
flex flex-row
|
||||
gap-1
|
||||
text-base text-gray-700
|
||||
bg-gray-100
|
||||
rounded-md
|
||||
h-8
|
||||
w-48
|
||||
px-3
|
||||
items-center
|
||||
"
|
||||
>
|
||||
<feather-icon name="search" class="w-4 h-4" />
|
||||
<p>{{ t`Search` }}</p>
|
||||
<div v-if="!inputValue" class="text-gray-500 ml-auto">
|
||||
{{ platform === 'Mac' ? '⌘ K' : 'Ctrl K' }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search Modal -->
|
||||
<Modal :open-modal="openModal">
|
||||
<!-- Search Input -->
|
||||
<div class="p-1">
|
||||
<input
|
||||
ref="input"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
:placeholder="t`Type to search...`"
|
||||
v-model="inputValue"
|
||||
@focus="search"
|
||||
@input="search"
|
||||
@keydown.up="up"
|
||||
@keydown.down="down"
|
||||
@keydown.enter="() => select()"
|
||||
@keydown.esc="close"
|
||||
class="
|
||||
bg-gray-100
|
||||
text-2xl
|
||||
focus:outline-none
|
||||
w-full
|
||||
placeholder-gray-700
|
||||
text-gray-900
|
||||
rounded-md
|
||||
p-3
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<hr v-if="suggestions.length" />
|
||||
|
||||
<!-- Search List -->
|
||||
<div :style="`max-height: ${49 * 6 - 1}px`" class="overflow-scroll">
|
||||
<div
|
||||
v-for="(si, i) in suggestions"
|
||||
:key="`${i}-${si.key}`"
|
||||
ref="suggestions"
|
||||
class="hover:bg-blue-100 cursor-pointer"
|
||||
:class="idx === i ? 'bg-blue-100' : ''"
|
||||
@click="select(i)"
|
||||
>
|
||||
<div
|
||||
class="rounded-md relative flex items-center overflow-hidden h-full"
|
||||
class="flex flex-row w-full justify-between px-3 items-center"
|
||||
style="height: 48px"
|
||||
>
|
||||
<div class="absolute flex justify-center w-8">
|
||||
<feather-icon name="search" class="w-3 h-3 text-gray-800" />
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
class="
|
||||
bg-gray-100
|
||||
text-sm
|
||||
pl-7
|
||||
focus:outline-none
|
||||
h-full
|
||||
w-56
|
||||
placeholder-gray-800
|
||||
"
|
||||
:placeholder="t`Search...`"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
v-model="inputValue"
|
||||
@focus="
|
||||
() => {
|
||||
search();
|
||||
toggleDropdown(true);
|
||||
}
|
||||
"
|
||||
@input="search"
|
||||
ref="input"
|
||||
@keydown.up="highlightItemUp"
|
||||
@keydown.down="highlightItemDown"
|
||||
@keydown.enter="selectHighlightedItem"
|
||||
@keydown.tab="toggleDropdown(false)"
|
||||
@keydown.esc="toggleDropdown(false)"
|
||||
/>
|
||||
<p class="">
|
||||
{{ si.label }}
|
||||
</p>
|
||||
<div
|
||||
v-if="!inputValue"
|
||||
class="absolute justify-center right-1.5 text-gray-500 px-1.5"
|
||||
class="text-base px-2 py-1 rounded-lg flex items-center"
|
||||
:class="groupColorClassMap[si.group]"
|
||||
>
|
||||
{{ platform === 'Mac' ? '⌘ K' : 'Ctrl K' }}
|
||||
{{ groupLabelMap[si.group] }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<hr v-if="i !== suggestions.length - 1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<hr />
|
||||
<div class="m-2 flex justify-between items-center">
|
||||
<!-- Group Filters -->
|
||||
<div class="flex flex-row gap-2">
|
||||
<button
|
||||
v-for="g in searchGroups"
|
||||
:key="g"
|
||||
class="text-base border px-2 py-0.5 rounded-lg"
|
||||
:class="getGroupFilterButtonClass(g)"
|
||||
@click="groupFilters[g] = !groupFilters[g]"
|
||||
>
|
||||
{{ groupLabelMap[g] }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Keybindings Help -->
|
||||
<div class="flex text-base gap-3 justify-center text-gray-700">
|
||||
<p>↑↓ {{ t`Navigate` }}</p>
|
||||
<p>↩ {{ t`Select` }}</p>
|
||||
<p><span class="text-sm tracking-tighter">esc</span> {{ t`Close` }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
<script>
|
||||
import { t } from 'fyo';
|
||||
import Dropdown from 'src/components/Dropdown';
|
||||
import { getSearchList } from 'src/utils/search';
|
||||
import { fuzzyMatch } from 'src/utils';
|
||||
import { getBgTextColorClass } from 'src/utils/colors';
|
||||
import { getSearchList, searchGroups } from 'src/utils/search';
|
||||
import { routeTo } from 'src/utils/ui';
|
||||
import { useKeys } from 'src/utils/vueUtils';
|
||||
import { watch } from 'vue';
|
||||
import { getIsNullOrUndef } from 'utils/';
|
||||
import { nextTick, watch } from 'vue';
|
||||
import Modal from './Modal.vue';
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
idx: 0,
|
||||
searchGroups,
|
||||
openModal: false,
|
||||
inputValue: '',
|
||||
searchList: [],
|
||||
suggestions: [],
|
||||
groupFilters: {
|
||||
List: true,
|
||||
Report: true,
|
||||
Create: true,
|
||||
Page: true,
|
||||
Docs: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
components: {
|
||||
Dropdown,
|
||||
},
|
||||
emits: ['change'],
|
||||
components: { Modal },
|
||||
mounted() {
|
||||
this.makeSearchList();
|
||||
watch(this.keys, (keys) => {
|
||||
@ -86,28 +149,63 @@ export default {
|
||||
keys.has('KeyK') &&
|
||||
(keys.has('MetaLeft') || keys.has('ControlLeft'))
|
||||
) {
|
||||
this.$refs.input.focus();
|
||||
this.open();
|
||||
}
|
||||
|
||||
if (!this.openModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (keys.size === 1 && keys.has('Escape')) {
|
||||
this.$refs.input.blur();
|
||||
this.close();
|
||||
}
|
||||
|
||||
const input = this.$refs.input;
|
||||
if (!getIsNullOrUndef(input) && document.activeElement !== input) {
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
this.openModal = false;
|
||||
},
|
||||
activated() {
|
||||
this.openModal = false;
|
||||
},
|
||||
methods: {
|
||||
async search() {
|
||||
this.suggestions = this.searchList.filter((d) => {
|
||||
let key = this.inputValue.toLowerCase();
|
||||
return d.label.toLowerCase().includes(key);
|
||||
open() {
|
||||
this.openModal = true;
|
||||
nextTick(() => {
|
||||
this.$refs.input.focus();
|
||||
});
|
||||
|
||||
if (this.suggestions.length === 0) {
|
||||
this.suggestions = [{ label: t`No results found.` }];
|
||||
}
|
||||
},
|
||||
clearInput() {
|
||||
close() {
|
||||
this.openModal = false;
|
||||
this.reset();
|
||||
},
|
||||
reset() {
|
||||
this.searchGroups.forEach((g) => {
|
||||
this.groupFilters[g] = true;
|
||||
});
|
||||
this.inputValue = '';
|
||||
this.$emit('change', null);
|
||||
},
|
||||
up() {
|
||||
this.idx = Math.max(this.idx - 1, 0);
|
||||
this.scrollToHighlighted();
|
||||
},
|
||||
down() {
|
||||
this.idx = Math.max(
|
||||
Math.min(this.idx + 1, this.suggestions.length - 1),
|
||||
0
|
||||
);
|
||||
this.scrollToHighlighted();
|
||||
},
|
||||
select(idx) {
|
||||
this.idx = idx ?? this.idx;
|
||||
this.suggestions[this.idx]?.action();
|
||||
this.close();
|
||||
},
|
||||
scrollToHighlighted() {
|
||||
const ref = this.$refs.suggestions[this.idx];
|
||||
ref.scrollIntoView({ block: 'nearest' });
|
||||
},
|
||||
async makeSearchList() {
|
||||
const searchList = getSearchList();
|
||||
@ -115,12 +213,61 @@ export default {
|
||||
if (d.route && !d.action) {
|
||||
d.action = () => {
|
||||
routeTo(d.route);
|
||||
this.inputValue = '';
|
||||
};
|
||||
}
|
||||
return d;
|
||||
});
|
||||
},
|
||||
getGroupFilterButtonClass(g) {
|
||||
const isOn = this.groupFilters[g];
|
||||
const color = this.groupColorMap[g];
|
||||
if (isOn) {
|
||||
return `${getBgTextColorClass(color)} border-${color}-100`;
|
||||
}
|
||||
|
||||
return `text-${color}-600 border-${color}-100`;
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
groupLabelMap() {
|
||||
return {
|
||||
Create: t`Create`,
|
||||
List: t`List`,
|
||||
Report: t`Report`,
|
||||
Docs: t`Docs`,
|
||||
Page: t`Page`,
|
||||
};
|
||||
},
|
||||
groupColorMap() {
|
||||
return {
|
||||
Docs: 'blue',
|
||||
Create: 'green',
|
||||
List: 'teal',
|
||||
Report: 'yellow',
|
||||
Page: 'orange',
|
||||
};
|
||||
},
|
||||
groupColorClassMap() {
|
||||
return searchGroups.reduce((map, g) => {
|
||||
map[g] = getBgTextColorClass(this.groupColorMap[g]);
|
||||
return map;
|
||||
}, {});
|
||||
},
|
||||
suggestions() {
|
||||
const filters = new Set(
|
||||
this.searchGroups.filter((g) => this.groupFilters[g])
|
||||
);
|
||||
|
||||
return this.searchList
|
||||
.filter((si) => filters.has(si.group))
|
||||
.map((si) => ({
|
||||
...fuzzyMatch(this.inputValue, `${si.label} ${si.group}`),
|
||||
si,
|
||||
}))
|
||||
.filter(({ isMatch }) => isMatch)
|
||||
.sort((a, b) => a.distance - b.distance)
|
||||
.map(({ si }) => si);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -6,7 +6,13 @@
|
||||
@change="applyFilter"
|
||||
:fields="fields"
|
||||
/>
|
||||
<Button :icon="true" type="primary" @click="makeNewDoc">
|
||||
<Button
|
||||
:icon="true"
|
||||
type="primary"
|
||||
@click="makeNewDoc"
|
||||
:padding="false"
|
||||
class="px-3"
|
||||
>
|
||||
<feather-icon name="plus" class="w-4 h-4 text-white" />
|
||||
</Button>
|
||||
</PageHeader>
|
||||
@ -21,9 +27,9 @@
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import Button from 'src/components/Button';
|
||||
import FilterDropdown from 'src/components/FilterDropdown';
|
||||
import PageHeader from 'src/components/PageHeader';
|
||||
import Button from 'src/components/Button.vue';
|
||||
import FilterDropdown from 'src/components/FilterDropdown.vue';
|
||||
import PageHeader from 'src/components/PageHeader.vue';
|
||||
import { fyo } from 'src/initFyo';
|
||||
import { routeTo } from 'src/utils/ui';
|
||||
import List from './List';
|
||||
|
@ -36,7 +36,6 @@ import { setLanguageMap } from './utils/language';
|
||||
app.component('App', App);
|
||||
app.component('FeatherIcon', FeatherIcon);
|
||||
app.component('Badge', Badge);
|
||||
|
||||
app.directive('on-outside-click', outsideClickDirective);
|
||||
app.mixin({
|
||||
computed: {
|
||||
|
@ -108,7 +108,7 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
/*
|
||||
{
|
||||
path: '/data_import',
|
||||
path: '/data-import',
|
||||
name: 'Data Import',
|
||||
component: DataImport,
|
||||
},
|
||||
|
@ -18,9 +18,18 @@ export const statusColor = {
|
||||
};
|
||||
|
||||
const getValidColor = (color: string) => {
|
||||
const isValid = ['gray', 'orange', 'green', 'red', 'yellow', 'blue'].includes(
|
||||
color
|
||||
);
|
||||
const isValid = [
|
||||
'gray',
|
||||
'orange',
|
||||
'green',
|
||||
'red',
|
||||
'yellow',
|
||||
'blue',
|
||||
'indigo',
|
||||
'pink',
|
||||
'purple',
|
||||
'teal',
|
||||
].includes(color);
|
||||
return isValid ? color : 'gray';
|
||||
};
|
||||
|
||||
|
@ -4,11 +4,13 @@ import reports from 'reports/view';
|
||||
import { fyo } from 'src/initFyo';
|
||||
import { routeTo } from './ui';
|
||||
|
||||
export const searchGroups = ['Docs', 'List', 'Create', 'Report', 'Page'];
|
||||
enum SearchGroupEnum {
|
||||
'List' = 'List',
|
||||
'Report' = 'Report',
|
||||
'Create' = 'Create',
|
||||
'Setup' = 'Setup',
|
||||
'Page' = 'Page',
|
||||
'Docs' = 'Docs',
|
||||
}
|
||||
|
||||
type SearchGroup = keyof typeof SearchGroupEnum;
|
||||
@ -38,50 +40,37 @@ async function openFormEditDoc(schemaName: string) {
|
||||
}
|
||||
|
||||
function getCreateList(): SearchItem[] {
|
||||
return [
|
||||
{
|
||||
label: t`Create Item`,
|
||||
group: 'Create',
|
||||
action() {
|
||||
openQuickEditDoc(ModelNameEnum.Item);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t`Create Party`,
|
||||
group: 'Create',
|
||||
action() {
|
||||
openQuickEditDoc(ModelNameEnum.Party);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t`Create Payment`,
|
||||
group: 'Create',
|
||||
action() {
|
||||
openQuickEditDoc(ModelNameEnum.Payment);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t`Create Sales Invoice`,
|
||||
group: 'Create',
|
||||
action() {
|
||||
openFormEditDoc(ModelNameEnum.SalesInvoice);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t`Create Purchase Invoice`,
|
||||
group: 'Create',
|
||||
action() {
|
||||
openFormEditDoc(ModelNameEnum.PurchaseInvoice);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t`Create Journal Entry`,
|
||||
group: 'Create',
|
||||
action() {
|
||||
openFormEditDoc(ModelNameEnum.JournalEntry);
|
||||
},
|
||||
},
|
||||
];
|
||||
const quickEditCreateList = [
|
||||
ModelNameEnum.Item,
|
||||
ModelNameEnum.Party,
|
||||
ModelNameEnum.Payment,
|
||||
].map(
|
||||
(schemaName) =>
|
||||
({
|
||||
label: fyo.schemaMap[schemaName]?.label,
|
||||
group: 'Create',
|
||||
action() {
|
||||
openQuickEditDoc(schemaName);
|
||||
},
|
||||
} as SearchItem)
|
||||
);
|
||||
|
||||
const formEditCreateList = [
|
||||
ModelNameEnum.SalesInvoice,
|
||||
ModelNameEnum.PurchaseInvoice,
|
||||
ModelNameEnum.JournalEntry,
|
||||
].map(
|
||||
(schemaName) =>
|
||||
({
|
||||
label: fyo.schemaMap[schemaName]?.label,
|
||||
group: 'Create',
|
||||
action() {
|
||||
openFormEditDoc(schemaName);
|
||||
},
|
||||
} as SearchItem)
|
||||
);
|
||||
|
||||
return [quickEditCreateList, formEditCreateList].flat();
|
||||
}
|
||||
|
||||
function getReportList(): SearchItem[] {
|
||||
@ -116,36 +105,34 @@ function getListViewList(): SearchItem[] {
|
||||
|
||||
function getSetupList(): SearchItem[] {
|
||||
return [
|
||||
{
|
||||
label: t`Dashboard`,
|
||||
route: '/',
|
||||
group: 'Page',
|
||||
},
|
||||
{
|
||||
label: t`Chart of Accounts`,
|
||||
route: '/chartOfAccounts',
|
||||
group: 'Setup',
|
||||
route: '/chart-of-accounts',
|
||||
group: 'Page',
|
||||
},
|
||||
{
|
||||
label: t`Data Import`,
|
||||
route: '/data_import',
|
||||
group: 'Setup',
|
||||
route: '/data-import',
|
||||
group: 'Page',
|
||||
},
|
||||
{
|
||||
label: t`Settings`,
|
||||
route: '/settings',
|
||||
group: 'Setup',
|
||||
group: 'Page',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getSearchList() {
|
||||
const group: Record<SearchGroup, string> = {
|
||||
Create: t`Create`,
|
||||
List: t`List`,
|
||||
Report: t`Report`,
|
||||
Setup: t`Setup`,
|
||||
};
|
||||
|
||||
return [getListViewList(), getCreateList(), getReportList(), getSetupList()]
|
||||
.flat()
|
||||
.map((si) => ({
|
||||
...si,
|
||||
group: group[si.group],
|
||||
}));
|
||||
return [
|
||||
getListViewList(),
|
||||
getCreateList(),
|
||||
getReportList(),
|
||||
getSetupList(),
|
||||
].flat();
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user