2
0
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:
18alantom 2022-05-01 17:04:13 +05:30
parent 758727b296
commit 89983f24e2
9 changed files with 304 additions and 150 deletions

View File

@ -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"

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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';

View File

@ -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: {

View File

@ -108,7 +108,7 @@ const routes: RouteRecordRaw[] = [
},
/*
{
path: '/data_import',
path: '/data-import',
name: 'Data Import',
component: DataImport,
},

View File

@ -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';
};

View File

@ -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();
}