mirror of
https://github.com/frappe/books.git
synced 2025-01-05 08:02:15 +00:00
commit
69f8ef11fe
14
src/App.vue
14
src/App.vue
@ -39,6 +39,7 @@
|
|||||||
import { ConfigKeys } from 'fyo/core/types';
|
import { ConfigKeys } from 'fyo/core/types';
|
||||||
import { ModelNameEnum } from 'models/types';
|
import { ModelNameEnum } from 'models/types';
|
||||||
import { incrementOpenCount } from 'src/utils/misc';
|
import { incrementOpenCount } from 'src/utils/misc';
|
||||||
|
import { computed } from 'vue';
|
||||||
import WindowsTitleBar from './components/WindowsTitleBar.vue';
|
import WindowsTitleBar from './components/WindowsTitleBar.vue';
|
||||||
import { handleErrorWithDialog } from './errorHandling';
|
import { handleErrorWithDialog } from './errorHandling';
|
||||||
import { fyo, initializeInstance } from './initFyo';
|
import { fyo, initializeInstance } from './initFyo';
|
||||||
@ -48,6 +49,7 @@ import SetupWizard from './pages/SetupWizard/SetupWizard.vue';
|
|||||||
import setupInstance from './setup/setupInstance';
|
import setupInstance from './setup/setupInstance';
|
||||||
import './styles/index.css';
|
import './styles/index.css';
|
||||||
import { checkForUpdates } from './utils/ipcCalls';
|
import { checkForUpdates } from './utils/ipcCalls';
|
||||||
|
import { Search } from './utils/search';
|
||||||
import { routeTo } from './utils/ui';
|
import { routeTo } from './utils/ui';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@ -57,6 +59,12 @@ export default {
|
|||||||
activeScreen: null,
|
activeScreen: null,
|
||||||
dbPath: '',
|
dbPath: '',
|
||||||
companyName: '',
|
companyName: '',
|
||||||
|
searcher: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
provide() {
|
||||||
|
return {
|
||||||
|
searcher: computed(() => this.searcher),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
@ -94,6 +102,11 @@ export default {
|
|||||||
ModelNameEnum.AccountingSettings,
|
ModelNameEnum.AccountingSettings,
|
||||||
'companyName'
|
'companyName'
|
||||||
);
|
);
|
||||||
|
await this.setSearcher();
|
||||||
|
},
|
||||||
|
async setSearcher() {
|
||||||
|
this.searcher = new Search(fyo);
|
||||||
|
await this.searcher.initializeKeywords();
|
||||||
},
|
},
|
||||||
async fileSelected(filePath, isNew) {
|
async fileSelected(filePath, isNew) {
|
||||||
fyo.config.set(ConfigKeys.LastSelectedFilePath, filePath);
|
fyo.config.set(ConfigKeys.LastSelectedFilePath, filePath);
|
||||||
@ -139,6 +152,7 @@ export default {
|
|||||||
fyo.purgeCache();
|
fyo.purgeCache();
|
||||||
this.activeScreen = 'DatabaseSelector';
|
this.activeScreen = 'DatabaseSelector';
|
||||||
this.dbPath = '';
|
this.dbPath = '';
|
||||||
|
this.searcher = null;
|
||||||
this.companyName = '';
|
this.companyName = '';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -12,9 +12,14 @@
|
|||||||
items-center
|
items-center
|
||||||
"
|
"
|
||||||
style="background: rgba(0, 0, 0, 0.2); backdrop-filter: blur(4px)"
|
style="background: rgba(0, 0, 0, 0.2); backdrop-filter: blur(4px)"
|
||||||
|
@click="$emit('closemodal')"
|
||||||
v-if="openModal"
|
v-if="openModal"
|
||||||
>
|
>
|
||||||
<div class="bg-white rounded-lg shadow-2xl w-form" v-bind="$attrs">
|
<div
|
||||||
|
class="bg-white rounded-lg shadow-2xl w-form"
|
||||||
|
v-bind="$attrs"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -28,5 +33,6 @@ export default {
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
emits: ['closemodal'],
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
@ -72,7 +72,7 @@ export default defineComponent({
|
|||||||
emits: ['index-change'],
|
emits: ['index-change'],
|
||||||
props: {
|
props: {
|
||||||
itemCount: { type: Number, default: 0 },
|
itemCount: { type: Number, default: 0 },
|
||||||
allowedCounts: { type: Array, default: () => [20, 100, 500, -1] },
|
allowedCounts: { type: Array, default: () => [50, 100, 500, -1] },
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
@ -20,14 +20,14 @@
|
|||||||
>
|
>
|
||||||
<feather-icon name="search" class="w-4 h-4" />
|
<feather-icon name="search" class="w-4 h-4" />
|
||||||
<p>{{ t`Search` }}</p>
|
<p>{{ t`Search` }}</p>
|
||||||
<div v-if="!inputValue" class="text-gray-400 ml-auto text-sm">
|
<div class="text-gray-400 ml-auto text-sm">
|
||||||
{{ modKey('k') }}
|
{{ modKey('k') }}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search Modal -->
|
<!-- Search Modal -->
|
||||||
<Modal :open-modal="openModal">
|
<Modal :open-modal="openModal" @closemodal="close">
|
||||||
<!-- Search Input -->
|
<!-- Search Input -->
|
||||||
<div class="p-1">
|
<div class="p-1">
|
||||||
<input
|
<input
|
||||||
@ -58,16 +58,40 @@
|
|||||||
<hr v-if="suggestions.length" />
|
<hr v-if="suggestions.length" />
|
||||||
|
|
||||||
<!-- Search List -->
|
<!-- Search List -->
|
||||||
<div :style="`max-height: ${49 * 6 - 1}px`" class="overflow-auto">
|
<div :style="`max-height: ${49 * 8 - 1}px`" class="overflow-auto">
|
||||||
<div
|
<div
|
||||||
v-for="(si, i) in suggestions"
|
v-for="(si, i) in suggestions"
|
||||||
:key="`${i}-${si.key}`"
|
:key="`${i}-${si.key}`"
|
||||||
ref="suggestions"
|
ref="suggestions"
|
||||||
class="hover:bg-blue-100 cursor-pointer"
|
class="hover:bg-blue-50 cursor-pointer"
|
||||||
:class="idx === i ? 'bg-blue-100' : ''"
|
:class="idx === i ? 'bg-blue-100' : ''"
|
||||||
@click="select(i)"
|
@click="select(i)"
|
||||||
>
|
>
|
||||||
|
<!-- Doc Search List Item -->
|
||||||
<div
|
<div
|
||||||
|
v-if="si.group === 'Docs'"
|
||||||
|
class="flex w-full justify-between px-3 items-center"
|
||||||
|
style="height: 48px"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<p class="text-gray-900">
|
||||||
|
{{ si.label }}
|
||||||
|
</p>
|
||||||
|
<p class="text-gray-600 text-sm ml-3">
|
||||||
|
{{ si.more.filter(Boolean).join(', ') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="text-sm text-right justify-self-end"
|
||||||
|
:class="`text-${groupColorMap[si.group]}-500`"
|
||||||
|
>
|
||||||
|
{{ si.schemaLabel }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Doc Search List Item -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
class="flex flex-row w-full justify-between px-3 items-center"
|
class="flex flex-row w-full justify-between px-3 items-center"
|
||||||
style="height: 48px"
|
style="height: 48px"
|
||||||
>
|
>
|
||||||
@ -75,8 +99,8 @@
|
|||||||
{{ si.label }}
|
{{ si.label }}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
class="text-base px-2 py-1 rounded-xl flex items-center"
|
class="text-sm text-right justify-self-end"
|
||||||
:class="groupColorClassMap[si.group]"
|
:class="`text-${groupColorMap[si.group]}-500`"
|
||||||
>
|
>
|
||||||
{{ groupLabelMap[si.group] }}
|
{{ groupLabelMap[si.group] }}
|
||||||
</div>
|
</div>
|
||||||
@ -90,36 +114,65 @@
|
|||||||
<div class="m-1 flex justify-between flex-col gap-2 text-sm select-none">
|
<div class="m-1 flex justify-between flex-col gap-2 text-sm select-none">
|
||||||
<!-- Group Filters -->
|
<!-- Group Filters -->
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-1">
|
||||||
<button
|
<button
|
||||||
v-for="(g, i) in searchGroups"
|
v-for="g in searchGroups"
|
||||||
:key="g"
|
:key="g"
|
||||||
class="border px-1 py-0.5 rounded-lg"
|
class="border px-1 py-0.5 rounded-lg"
|
||||||
:class="getGroupFilterButtonClass(g)"
|
:class="getGroupFilterButtonClass(g)"
|
||||||
@click="groupFilters[g] = !groupFilters[g]"
|
@click="searcher.set(g, !searcher.filters.groupFilters[g])"
|
||||||
>
|
>
|
||||||
{{ groupLabelMap[g]
|
{{ groupLabelMap[g] }}
|
||||||
}}<span
|
|
||||||
class="ml-2 whitespace-nowrap brightness-50 tracking-tighter"
|
|
||||||
:class="`text-${groupColorMap[g]}-500`"
|
|
||||||
>{{ modKey(String(i + 1)) }}</span
|
|
||||||
>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="
|
class="hover:bg-gray-100 px-2 py-0.5 rounded text-gray-800"
|
||||||
bg-gray-100
|
@click="showMore = !showMore"
|
||||||
hover:bg-gray-200
|
|
||||||
px-2
|
|
||||||
py-0.5
|
|
||||||
rounded
|
|
||||||
text-gray-800
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
{{ t`More Filters` }}
|
{{ showMore ? t`Less Filters` : t`More Filters` }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Additional Filters -->
|
||||||
|
<div v-if="showMore" class="-mt-1">
|
||||||
|
<!-- Group Skip Filters -->
|
||||||
|
<div class="flex gap-1 text-gray-800">
|
||||||
|
<button
|
||||||
|
v-for="s in ['skipTables', 'skipTransactions']"
|
||||||
|
:key="s"
|
||||||
|
class="border px-1 py-0.5 rounded-lg"
|
||||||
|
:class="{ 'bg-gray-200': searcher.filters[s] }"
|
||||||
|
@click="searcher.set(s, !searcher.filters[s])"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
s === 'skipTables' ? t`Skip Child Tables` : t`Skip Transactions`
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schema Name Filters -->
|
||||||
|
<div class="flex mt-1 gap-1 text-blue-500 flex-wrap">
|
||||||
|
<button
|
||||||
|
v-for="sf in schemaFilters"
|
||||||
|
:key="sf.value"
|
||||||
|
class="
|
||||||
|
border
|
||||||
|
px-1
|
||||||
|
py-0.5
|
||||||
|
rounded-lg
|
||||||
|
border-blue-100
|
||||||
|
whitespace-nowrap
|
||||||
|
"
|
||||||
|
:class="{ 'bg-blue-100': searcher.filters.schemaFilters[sf.value] }"
|
||||||
|
@click="
|
||||||
|
searcher.set(sf.value, !searcher.filters.schemaFilters[sf.value])
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ sf.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Keybindings Help -->
|
<!-- Keybindings Help -->
|
||||||
<div class="flex text-sm text-gray-500 justify-between">
|
<div class="flex text-sm text-gray-500 justify-between">
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
@ -127,19 +180,37 @@
|
|||||||
<p>↩ {{ t`Select` }}</p>
|
<p>↩ {{ t`Select` }}</p>
|
||||||
<p><span class="tracking-tighter">esc</span> {{ t`Close` }}</p>
|
<p><span class="tracking-tighter">esc</span> {{ t`Close` }}</p>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="suggestions.length">
|
|
||||||
{{ t`${suggestions.length} out of ${totalLength} shown` }}
|
<p v-if="searcher?.numSearches" class="ml-auto mr-2">
|
||||||
|
{{ t`${suggestions.length} out of ${searcher.numSearches}` }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="border border-gray-100 rounded flex justify-self-end"
|
||||||
|
v-if="(searcher?.numSearches ?? 0) > 50"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-for="c in allowedLimits.filter(
|
||||||
|
(c) => c < searcher.numSearches || c === -1
|
||||||
|
)"
|
||||||
|
:key="c + '-count'"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
@click="limit = parseInt(c)"
|
||||||
|
class="w-9"
|
||||||
|
:class="limit === c ? 'bg-gray-100' : ''"
|
||||||
|
>
|
||||||
|
{{ c === -1 ? t`All` : c }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
import { t } from 'fyo';
|
|
||||||
import { fuzzyMatch } from 'src/utils';
|
|
||||||
import { getBgTextColorClass } from 'src/utils/colors';
|
import { getBgTextColorClass } from 'src/utils/colors';
|
||||||
import { docSearch, getSearchList, searchGroups } from 'src/utils/search';
|
import { getGroupLabelMap, searchGroups } from 'src/utils/search';
|
||||||
import { routeTo } from 'src/utils/ui';
|
|
||||||
import { useKeys } from 'src/utils/vueUtils';
|
import { useKeys } from 'src/utils/vueUtils';
|
||||||
import { getIsNullOrUndef } from 'utils/';
|
import { getIsNullOrUndef } from 'utils/';
|
||||||
import { nextTick, watch } from 'vue';
|
import { nextTick, watch } from 'vue';
|
||||||
@ -156,24 +227,18 @@ export default {
|
|||||||
searchGroups,
|
searchGroups,
|
||||||
openModal: false,
|
openModal: false,
|
||||||
inputValue: '',
|
inputValue: '',
|
||||||
searchList: [],
|
showMore: false,
|
||||||
docSearch: null,
|
limit: 50,
|
||||||
totalLength: 0,
|
allowedLimits: [50, 100, 500, -1],
|
||||||
groupFilters: {
|
|
||||||
List: true,
|
|
||||||
Report: true,
|
|
||||||
Create: true,
|
|
||||||
Page: true,
|
|
||||||
Docs: true,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
inject: ['searcher'],
|
||||||
components: { Modal },
|
components: { Modal },
|
||||||
async mounted() {
|
async mounted() {
|
||||||
this.docSearch = docSearch;
|
if (fyo.store.isDevelopment) {
|
||||||
await this.docSearch.fetchKeywords();
|
window.search = this;
|
||||||
|
}
|
||||||
|
|
||||||
this.makeSearchList();
|
|
||||||
watch(this.keys, (keys) => {
|
watch(this.keys, (keys) => {
|
||||||
if (
|
if (
|
||||||
keys.size === 2 &&
|
keys.size === 2 &&
|
||||||
@ -221,11 +286,12 @@ export default {
|
|||||||
const digit = matches[1];
|
const digit = matches[1];
|
||||||
const index = parseInt(digit) - 1;
|
const index = parseInt(digit) - 1;
|
||||||
const group = searchGroups[index];
|
const group = searchGroups[index];
|
||||||
if (!group || this.groupFilters[group] === undefined) {
|
const value = this.searcher.filters.groupFilters[group];
|
||||||
|
if (!group || typeof value !== 'boolean') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.groupFilters[group] = !this.groupFilters[group];
|
this.searcher.set(group, !value);
|
||||||
},
|
},
|
||||||
modKey(key) {
|
modKey(key) {
|
||||||
key = key.toUpperCase();
|
key = key.toUpperCase();
|
||||||
@ -237,6 +303,7 @@ export default {
|
|||||||
},
|
},
|
||||||
open() {
|
open() {
|
||||||
this.openModal = true;
|
this.openModal = true;
|
||||||
|
this.searcher.updateKeywords();
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
this.$refs.input.focus();
|
this.$refs.input.focus();
|
||||||
});
|
});
|
||||||
@ -246,9 +313,6 @@ export default {
|
|||||||
this.reset();
|
this.reset();
|
||||||
},
|
},
|
||||||
reset() {
|
reset() {
|
||||||
this.searchGroups.forEach((g) => {
|
|
||||||
this.groupFilters[g] = true;
|
|
||||||
});
|
|
||||||
this.inputValue = '';
|
this.inputValue = '';
|
||||||
},
|
},
|
||||||
up() {
|
up() {
|
||||||
@ -271,19 +335,8 @@ export default {
|
|||||||
const ref = this.$refs.suggestions[this.idx];
|
const ref = this.$refs.suggestions[this.idx];
|
||||||
ref.scrollIntoView({ block: 'nearest' });
|
ref.scrollIntoView({ block: 'nearest' });
|
||||||
},
|
},
|
||||||
async makeSearchList() {
|
|
||||||
const searchList = getSearchList();
|
|
||||||
this.searchList = searchList.map((d) => {
|
|
||||||
if (d.route && !d.action) {
|
|
||||||
d.action = () => {
|
|
||||||
routeTo(d.route);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return d;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
getGroupFilterButtonClass(g) {
|
getGroupFilterButtonClass(g) {
|
||||||
const isOn = this.groupFilters[g];
|
const isOn = this.searcher.filters.groupFilters[g];
|
||||||
const color = this.groupColorMap[g];
|
const color = this.groupColorMap[g];
|
||||||
if (isOn) {
|
if (isOn) {
|
||||||
return `${getBgTextColorClass(color)} border-${color}-100`;
|
return `${getBgTextColorClass(color)} border-${color}-100`;
|
||||||
@ -294,13 +347,24 @@ export default {
|
|||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
groupLabelMap() {
|
groupLabelMap() {
|
||||||
return {
|
return getGroupLabelMap();
|
||||||
Create: t`Create`,
|
},
|
||||||
List: t`List`,
|
schemaFilters() {
|
||||||
Report: t`Report`,
|
const schemaNames = Object.keys(this.searcher?.searchables) ?? [];
|
||||||
Docs: t`Docs`,
|
return schemaNames
|
||||||
Page: t`Page`,
|
.map((sn) => {
|
||||||
};
|
const schema = fyo.schemaMap[sn];
|
||||||
|
const value = sn;
|
||||||
|
const label = schema.label;
|
||||||
|
let index = 1;
|
||||||
|
if (schema.isSubmittable) {
|
||||||
|
index = 0;
|
||||||
|
} else if (schema.isChild) {
|
||||||
|
index = 2;
|
||||||
|
}
|
||||||
|
return { value, label, index };
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.index - b.index);
|
||||||
},
|
},
|
||||||
groupColorMap() {
|
groupColorMap() {
|
||||||
return {
|
return {
|
||||||
@ -318,31 +382,12 @@ export default {
|
|||||||
}, {});
|
}, {});
|
||||||
},
|
},
|
||||||
suggestions() {
|
suggestions() {
|
||||||
const filters = new Set(
|
const suggestions = this.searcher.search(this.inputValue);
|
||||||
this.searchGroups.filter((g) => this.groupFilters[g])
|
if (this.limit === -1) {
|
||||||
);
|
return suggestions;
|
||||||
|
|
||||||
const nonDocs = 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);
|
|
||||||
|
|
||||||
let docs = [];
|
|
||||||
/*
|
|
||||||
if (this.groupFilters.Docs && this.inputValue) {
|
|
||||||
docs = this.docSearch.search(this.inputValue);
|
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
||||||
const all = [docs, nonDocs].flat();
|
return suggestions.slice(0, this.limit);
|
||||||
// eslint-disable-next-line
|
|
||||||
this.totalLength = all.length;
|
|
||||||
return all.slice(0, 50);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -17,7 +17,9 @@ export default function registerIpcRendererListeners() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(...stuff);
|
if (fyo.store.isDevelopment) {
|
||||||
|
console.log(...stuff);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', function () {
|
document.addEventListener('visibilitychange', function () {
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { ModelNameEnum } from 'models/types';
|
||||||
import ChartOfAccounts from 'src/pages/ChartOfAccounts.vue';
|
import ChartOfAccounts from 'src/pages/ChartOfAccounts.vue';
|
||||||
import Dashboard from 'src/pages/Dashboard/Dashboard.vue';
|
import Dashboard from 'src/pages/Dashboard/Dashboard.vue';
|
||||||
import DataImport from 'src/pages/DataImport.vue';
|
import DataImport from 'src/pages/DataImport.vue';
|
||||||
@ -116,6 +117,20 @@ const routes: RouteRecordRaw[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export function getEntryRoute(schemaName: string, name: string) {
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
ModelNameEnum.SalesInvoice,
|
||||||
|
ModelNameEnum.PurchaseInvoice,
|
||||||
|
ModelNameEnum.JournalEntry,
|
||||||
|
].includes(schemaName as ModelNameEnum)
|
||||||
|
) {
|
||||||
|
return `/edit/${schemaName}/${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/list/${schemaName}?edit=1&schemaName=${schemaName}&name=${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
const router = createRouter({ routes, history: createWebHistory() });
|
const router = createRouter({ routes, history: createWebHistory() });
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
@ -39,9 +39,9 @@ export function stringifyCircular(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fuzzyMatch(keyword: string, candidate: string) {
|
export function fuzzyMatch(input: string, target: string) {
|
||||||
const keywordLetters = [...keyword];
|
const keywordLetters = [...input];
|
||||||
const candidateLetters = [...candidate];
|
const candidateLetters = [...target];
|
||||||
|
|
||||||
let keywordLetter = keywordLetters.shift();
|
let keywordLetter = keywordLetters.shift();
|
||||||
let candidateLetter = candidateLetters.shift();
|
let candidateLetter = candidateLetters.shift();
|
||||||
@ -63,7 +63,7 @@ export function fuzzyMatch(keyword: string, candidate: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (keywordLetter !== undefined) {
|
if (keywordLetter !== undefined) {
|
||||||
distance = -1;
|
distance = Number.MAX_SAFE_INTEGER;
|
||||||
isMatch = false;
|
isMatch = false;
|
||||||
} else {
|
} else {
|
||||||
distance += candidateLetters.length;
|
distance += candidateLetters.length;
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { t } from 'fyo';
|
import { Fyo, t } from 'fyo';
|
||||||
import { DocValueMap } from 'fyo/core/types';
|
import { RawValueMap } from 'fyo/core/types';
|
||||||
import { Dictionary, groupBy } from 'lodash';
|
import { groupBy } from 'lodash';
|
||||||
import { ModelNameEnum } from 'models/types';
|
import { ModelNameEnum } from 'models/types';
|
||||||
import { reports } from 'reports';
|
import { reports } from 'reports';
|
||||||
import { OptionField } from 'schemas/types';
|
import { OptionField } from 'schemas/types';
|
||||||
import { fyo } from 'src/initFyo';
|
import { getEntryRoute } from 'src/router';
|
||||||
import { GetAllOptions } from 'utils/db/types';
|
import { GetAllOptions } from 'utils/db/types';
|
||||||
import { fuzzyMatch } from '.';
|
import { fuzzyMatch } from '.';
|
||||||
import { routeTo } from './ui';
|
import { routeTo } from './ui';
|
||||||
@ -31,7 +31,46 @@ interface DocSearchItem extends SearchItem {
|
|||||||
more: string[];
|
more: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openQuickEditDoc(schemaName: string) {
|
type SearchItems = (DocSearchItem | SearchItem)[];
|
||||||
|
|
||||||
|
interface Searchable {
|
||||||
|
needsUpdate: boolean;
|
||||||
|
schemaName: string;
|
||||||
|
fields: string[];
|
||||||
|
meta: string[];
|
||||||
|
isChild: boolean;
|
||||||
|
isSubmittable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Keyword {
|
||||||
|
values: string[];
|
||||||
|
meta: Record<string, string | boolean | undefined>;
|
||||||
|
priority: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchFilters {
|
||||||
|
groupFilters: Record<SearchGroup, boolean>;
|
||||||
|
skipTables: boolean;
|
||||||
|
skipTransactions: boolean;
|
||||||
|
schemaFilters: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchIntermediate {
|
||||||
|
suggestions: SearchItems;
|
||||||
|
previousInput?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGroupLabelMap() {
|
||||||
|
return {
|
||||||
|
Create: t`Create`,
|
||||||
|
List: t`List`,
|
||||||
|
Report: t`Report`,
|
||||||
|
Docs: t`Docs`,
|
||||||
|
Page: t`Page`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openQuickEditDoc(schemaName: string, fyo: Fyo) {
|
||||||
await routeTo(`/list/${schemaName}`);
|
await routeTo(`/list/${schemaName}`);
|
||||||
const doc = await fyo.doc.getNewDoc(schemaName);
|
const doc = await fyo.doc.getNewDoc(schemaName);
|
||||||
const { openQuickEdit } = await import('src/utils/ui');
|
const { openQuickEdit } = await import('src/utils/ui');
|
||||||
@ -42,14 +81,14 @@ async function openQuickEditDoc(schemaName: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openFormEditDoc(schemaName: string) {
|
async function openFormEditDoc(schemaName: string, fyo: Fyo) {
|
||||||
const doc = fyo.doc.getNewDoc(schemaName);
|
const doc = fyo.doc.getNewDoc(schemaName);
|
||||||
const name = doc.name;
|
const name = doc.name;
|
||||||
|
|
||||||
routeTo(`/edit/${schemaName}/${name}`);
|
routeTo(`/edit/${schemaName}/${name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCreateList(): SearchItem[] {
|
function getCreateList(fyo: Fyo): SearchItem[] {
|
||||||
const quickEditCreateList = [
|
const quickEditCreateList = [
|
||||||
ModelNameEnum.Item,
|
ModelNameEnum.Item,
|
||||||
ModelNameEnum.Party,
|
ModelNameEnum.Party,
|
||||||
@ -60,7 +99,7 @@ function getCreateList(): SearchItem[] {
|
|||||||
label: fyo.schemaMap[schemaName]?.label,
|
label: fyo.schemaMap[schemaName]?.label,
|
||||||
group: 'Create',
|
group: 'Create',
|
||||||
action() {
|
action() {
|
||||||
openQuickEditDoc(schemaName);
|
openQuickEditDoc(schemaName, fyo);
|
||||||
},
|
},
|
||||||
} as SearchItem)
|
} as SearchItem)
|
||||||
);
|
);
|
||||||
@ -75,7 +114,7 @@ function getCreateList(): SearchItem[] {
|
|||||||
label: fyo.schemaMap[schemaName]?.label,
|
label: fyo.schemaMap[schemaName]?.label,
|
||||||
group: 'Create',
|
group: 'Create',
|
||||||
action() {
|
action() {
|
||||||
openFormEditDoc(schemaName);
|
openFormEditDoc(schemaName, fyo);
|
||||||
},
|
},
|
||||||
} as SearchItem)
|
} as SearchItem)
|
||||||
);
|
);
|
||||||
@ -129,7 +168,7 @@ function getCreateList(): SearchItem[] {
|
|||||||
return [quickEditCreateList, formEditCreateList, filteredCreateList].flat();
|
return [quickEditCreateList, formEditCreateList, filteredCreateList].flat();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getReportList(): SearchItem[] {
|
function getReportList(fyo: Fyo): SearchItem[] {
|
||||||
const hasGstin = !!fyo.singles?.AccountingSettings?.gstin;
|
const hasGstin = !!fyo.singles?.AccountingSettings?.gstin;
|
||||||
return Object.keys(reports)
|
return Object.keys(reports)
|
||||||
.filter((r) => {
|
.filter((r) => {
|
||||||
@ -149,7 +188,7 @@ function getReportList(): SearchItem[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getListViewList(): SearchItem[] {
|
function getListViewList(fyo: Fyo): SearchItem[] {
|
||||||
let schemaNames = [
|
let schemaNames = [
|
||||||
ModelNameEnum.Account,
|
ModelNameEnum.Account,
|
||||||
ModelNameEnum.Party,
|
ModelNameEnum.Party,
|
||||||
@ -228,36 +267,47 @@ function getSetupList(): SearchItem[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSearchList() {
|
function getNonDocSearchList(fyo: Fyo) {
|
||||||
return [
|
return [
|
||||||
getListViewList(),
|
getListViewList(fyo),
|
||||||
getCreateList(),
|
getCreateList(fyo),
|
||||||
getReportList(),
|
getReportList(fyo),
|
||||||
getSetupList(),
|
getSetupList(),
|
||||||
].flat();
|
]
|
||||||
|
.flat()
|
||||||
|
.map((d) => {
|
||||||
|
if (d.route && !d.action) {
|
||||||
|
d.action = () => {
|
||||||
|
routeTo(d.route!);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Searchable {
|
export class Search {
|
||||||
schemaName: string;
|
/**
|
||||||
fields: string[];
|
* A simple fuzzy searcher.
|
||||||
meta: string[];
|
*
|
||||||
isChild: boolean;
|
* How the Search works:
|
||||||
isSubmittable: boolean;
|
* - Pulls `keywordFields` (string columns) from the db.
|
||||||
}
|
* - `name` or `parent` (parent doc's name) is used as the main
|
||||||
|
* label.
|
||||||
|
* - The `name`, `keywordFields` and schema label are used as
|
||||||
|
* search target terms.
|
||||||
|
* - Input is split on `' '` (whitespace) and each part has to completely
|
||||||
|
* or partially match the search target terms.
|
||||||
|
* - Non matches are ignored.
|
||||||
|
* - Each letter in the input narrows the search using the `this._intermediate`
|
||||||
|
* object where the incremental searches are stored.
|
||||||
|
* - Search index is marked for updation when a doc is entered or deleted.
|
||||||
|
* - Marked indices are rebuilt when the modal is opened.
|
||||||
|
*/
|
||||||
|
|
||||||
interface Keyword {
|
_obsSet: boolean = false;
|
||||||
values: string[];
|
numSearches: number = 0;
|
||||||
meta: Record<string, string | boolean | undefined>;
|
searchables: Record<string, Searchable>;
|
||||||
priority: number;
|
keywords: Record<string, Keyword[]>;
|
||||||
}
|
|
||||||
|
|
||||||
interface Keywords {
|
|
||||||
searchable: Searchable;
|
|
||||||
keywords: Keyword[];
|
|
||||||
}
|
|
||||||
|
|
||||||
class Search {
|
|
||||||
keywords: Record<string, Keywords>;
|
|
||||||
priorityMap: Record<string, number> = {
|
priorityMap: Record<string, number> = {
|
||||||
[ModelNameEnum.SalesInvoice]: 125,
|
[ModelNameEnum.SalesInvoice]: 125,
|
||||||
[ModelNameEnum.PurchaseInvoice]: 100,
|
[ModelNameEnum.PurchaseInvoice]: 100,
|
||||||
@ -267,67 +317,126 @@ class Search {
|
|||||||
[ModelNameEnum.JournalEntry]: 50,
|
[ModelNameEnum.JournalEntry]: 50,
|
||||||
};
|
};
|
||||||
|
|
||||||
_groupedKeywords?: Dictionary<Keyword[]>;
|
filters: SearchFilters = {
|
||||||
|
groupFilters: {
|
||||||
|
List: true,
|
||||||
|
Report: true,
|
||||||
|
Create: true,
|
||||||
|
Page: true,
|
||||||
|
Docs: true,
|
||||||
|
},
|
||||||
|
schemaFilters: {},
|
||||||
|
skipTables: false,
|
||||||
|
skipTransactions: false,
|
||||||
|
};
|
||||||
|
|
||||||
constructor() {
|
fyo: Fyo;
|
||||||
|
|
||||||
|
_intermediate: SearchIntermediate = { suggestions: [] };
|
||||||
|
|
||||||
|
_nonDocSearchList: SearchItem[];
|
||||||
|
_groupLabelMap?: Record<SearchGroup, string>;
|
||||||
|
|
||||||
|
constructor(fyo: Fyo) {
|
||||||
|
this.fyo = fyo;
|
||||||
this.keywords = {};
|
this.keywords = {};
|
||||||
|
this.searchables = {};
|
||||||
|
this._nonDocSearchList = getNonDocSearchList(fyo);
|
||||||
}
|
}
|
||||||
|
|
||||||
get groupedKeywords() {
|
/**
|
||||||
if (!this._groupedKeywords || !Object.keys(this._groupedKeywords!).length) {
|
* these getters are used for hacky two way binding between the
|
||||||
this._groupedKeywords = this.getGroupedKeywords();
|
* `skipT*` filters and the `schemaFilters`.
|
||||||
}
|
*/
|
||||||
|
|
||||||
return this._groupedKeywords!;
|
get skipTables() {
|
||||||
}
|
let value = true;
|
||||||
|
for (const val of Object.values(this.searchables)) {
|
||||||
search(keyword: string /*, array: DocSearchItem[]*/): DocSearchItem[] {
|
if (!val.isChild) {
|
||||||
const array: DocSearchItem[] = [];
|
continue;
|
||||||
if (!keyword) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupedKeywords = this.groupedKeywords;
|
|
||||||
const keys = Object.keys(groupedKeywords).sort(
|
|
||||||
(a, b) => parseFloat(b) - parseFloat(a)
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const key of keys) {
|
|
||||||
for (const kw of groupedKeywords[key]) {
|
|
||||||
let isMatch = false;
|
|
||||||
for (const word of kw.values) {
|
|
||||||
isMatch ||= fuzzyMatch(keyword, word).isMatch;
|
|
||||||
if (isMatch) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isMatch) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
array.push({
|
|
||||||
label: kw.values[0],
|
|
||||||
schemaLabel: fyo.schemaMap[kw.meta.schemaName as string]?.label!,
|
|
||||||
more: kw.values.slice(1),
|
|
||||||
group: 'Docs',
|
|
||||||
action: () => {
|
|
||||||
console.log('selected', kw);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
value &&= !this.filters.schemaFilters[val.schemaName];
|
||||||
}
|
}
|
||||||
return array;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
getGroupedKeywords() {
|
get skipTransactions() {
|
||||||
const keywords = Object.values(this.keywords);
|
let value = true;
|
||||||
return groupBy(keywords.map((kw) => kw.keywords).flat(), 'priority');
|
for (const val of Object.values(this.searchables)) {
|
||||||
|
if (!val.isSubmittable) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
value &&= !this.filters.schemaFilters[val.schemaName];
|
||||||
|
}
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchKeywords() {
|
set(filterName: string, value: boolean) {
|
||||||
const searchables = this._getSearchables();
|
/**
|
||||||
for (const searchable of searchables) {
|
* When a filter is set, intermediate is reset
|
||||||
|
* this way the groups are rebuild with the filters
|
||||||
|
* applied.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (filterName in this.filters.groupFilters) {
|
||||||
|
this.filters.groupFilters[filterName as SearchGroup] = value;
|
||||||
|
} else if (filterName in this.searchables) {
|
||||||
|
this.filters.schemaFilters[filterName] = value;
|
||||||
|
this.filters.skipTables = this.skipTables;
|
||||||
|
this.filters.skipTransactions = this.skipTransactions;
|
||||||
|
} else if (filterName === 'skipTables') {
|
||||||
|
Object.values(this.searchables)
|
||||||
|
.filter(({ isChild }) => isChild)
|
||||||
|
.forEach(({ schemaName }) => {
|
||||||
|
this.filters.schemaFilters[schemaName] = !value;
|
||||||
|
});
|
||||||
|
this.filters.skipTables = value;
|
||||||
|
} else if (filterName === 'skipTransactions') {
|
||||||
|
Object.values(this.searchables)
|
||||||
|
.filter(({ isSubmittable }) => isSubmittable)
|
||||||
|
.forEach(({ schemaName }) => {
|
||||||
|
this.filters.schemaFilters[schemaName] = !value;
|
||||||
|
});
|
||||||
|
this.filters.skipTransactions = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._setIntermediate([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializeKeywords() {
|
||||||
|
this._setSearchables();
|
||||||
|
await this.updateKeywords();
|
||||||
|
this._setDocObservers();
|
||||||
|
this._setSchemaFilters();
|
||||||
|
this._groupLabelMap = getGroupLabelMap();
|
||||||
|
this._setFilterDefaults();
|
||||||
|
}
|
||||||
|
|
||||||
|
_setFilterDefaults() {
|
||||||
|
const totalChildKeywords = Object.values(this.searchables)
|
||||||
|
.filter((s) => s.isChild)
|
||||||
|
.map((s) => this.keywords[s.schemaName].length)
|
||||||
|
.reduce((a, b) => a + b);
|
||||||
|
|
||||||
|
if (totalChildKeywords > 2_000) {
|
||||||
|
this.set('skipTables', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_setSchemaFilters() {
|
||||||
|
for (const name in this.searchables) {
|
||||||
|
this.filters.schemaFilters[name] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateKeywords() {
|
||||||
|
for (const searchable of Object.values(this.searchables)) {
|
||||||
|
if (!searchable.needsUpdate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const options: GetAllOptions = {
|
const options: GetAllOptions = {
|
||||||
fields: [searchable.fields, searchable.meta].flat(),
|
fields: [searchable.fields, searchable.meta].flat(),
|
||||||
order: 'desc',
|
order: 'desc',
|
||||||
@ -337,18 +446,256 @@ class Search {
|
|||||||
options.orderBy = 'modified';
|
options.orderBy = 'modified';
|
||||||
}
|
}
|
||||||
|
|
||||||
const maps = await fyo.db.getAllRaw(searchable.schemaName, options);
|
const maps = await this.fyo.db.getAllRaw(searchable.schemaName, options);
|
||||||
this._addToSearchable(maps, searchable);
|
this._setKeywords(maps, searchable);
|
||||||
|
this.searchables[searchable.schemaName].needsUpdate = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._setPriority();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_getSearchables(): Searchable[] {
|
_searchSuggestions(input: string): SearchItems {
|
||||||
const searchable: Searchable[] = [];
|
const matches: { si: SearchItem | DocSearchItem; distance: number }[] = [];
|
||||||
for (const schemaName of Object.keys(fyo.schemaMap)) {
|
|
||||||
const schema = fyo.schemaMap[schemaName];
|
for (const si of this._intermediate.suggestions) {
|
||||||
if (!schema?.keywordFields?.length) {
|
const label = si.label;
|
||||||
|
const groupLabel =
|
||||||
|
(si as DocSearchItem).schemaLabel || this._groupLabelMap![si.group];
|
||||||
|
const more = (si as DocSearchItem).more ?? [];
|
||||||
|
const values = [label, more, groupLabel].flat();
|
||||||
|
|
||||||
|
const { isMatch, distance } = this._getMatchAndDistance(input, values);
|
||||||
|
|
||||||
|
if (isMatch) {
|
||||||
|
matches.push({ si, distance });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matches.sort((a, b) => a.distance - b.distance);
|
||||||
|
const suggestions = matches.map((m) => m.si);
|
||||||
|
this._setIntermediate(suggestions, input);
|
||||||
|
return suggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
_shouldUseSuggestions(input?: string): boolean {
|
||||||
|
if (!input) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { suggestions, previousInput } = this._intermediate;
|
||||||
|
if (!suggestions?.length || !previousInput) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.startsWith(previousInput)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setIntermediate(suggestions: SearchItems, previousInput?: string) {
|
||||||
|
this.numSearches = suggestions.length;
|
||||||
|
this._intermediate.suggestions = suggestions;
|
||||||
|
this._intermediate.previousInput = previousInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
search(input?: string): SearchItems {
|
||||||
|
const useSuggestions = this._shouldUseSuggestions(input);
|
||||||
|
/**
|
||||||
|
* If the suggestion list is already populated
|
||||||
|
* and the input is an extention of the previous
|
||||||
|
* then use the suggestions.
|
||||||
|
*/
|
||||||
|
if (useSuggestions) {
|
||||||
|
return this._searchSuggestions(input!);
|
||||||
|
} else {
|
||||||
|
this._setIntermediate([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the suggestion list.
|
||||||
|
*/
|
||||||
|
const groupedKeywords = this._getGroupedKeywords();
|
||||||
|
const keys = Object.keys(groupedKeywords);
|
||||||
|
if (!keys.includes('0')) {
|
||||||
|
keys.push('0');
|
||||||
|
}
|
||||||
|
|
||||||
|
keys.sort((a, b) => parseFloat(b) - parseFloat(a));
|
||||||
|
const array: SearchItems = [];
|
||||||
|
for (const key of keys) {
|
||||||
|
this._pushDocSearchItems(groupedKeywords[key], array, input);
|
||||||
|
if (key === '0') {
|
||||||
|
this._pushNonDocSearchItems(array, input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._setIntermediate(array, input);
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pushDocSearchItems(keywords: Keyword[], array: SearchItems, input?: string) {
|
||||||
|
if (!input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.filters.groupFilters.Docs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subArray = this._getSubSortedArray(
|
||||||
|
keywords,
|
||||||
|
input
|
||||||
|
) as DocSearchItem[];
|
||||||
|
array.push(...subArray);
|
||||||
|
}
|
||||||
|
|
||||||
|
_pushNonDocSearchItems(array: SearchItems, input?: string) {
|
||||||
|
const filtered = this._nonDocSearchList.filter(
|
||||||
|
(si) => this.filters.groupFilters[si.group]
|
||||||
|
);
|
||||||
|
const subArray = this._getSubSortedArray(filtered, input) as SearchItem[];
|
||||||
|
array.push(...subArray);
|
||||||
|
}
|
||||||
|
|
||||||
|
_getSubSortedArray(
|
||||||
|
items: (SearchItem | Keyword)[],
|
||||||
|
input?: string
|
||||||
|
): SearchItems {
|
||||||
|
const subArray: { item: SearchItem | DocSearchItem; distance: number }[] =
|
||||||
|
[];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const isSearchItem = !!(item as SearchItem).group;
|
||||||
|
|
||||||
|
if (!input && isSearchItem) {
|
||||||
|
subArray.push({ item: item as SearchItem, distance: 0 });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = this._getValueList(item).filter(Boolean);
|
||||||
|
const { isMatch, distance } = this._getMatchAndDistance(input, values);
|
||||||
|
|
||||||
|
if (!isMatch) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSearchItem) {
|
||||||
|
subArray.push({ item: item as SearchItem, distance });
|
||||||
|
} else {
|
||||||
|
subArray.push({
|
||||||
|
item: this._getDocSearchItemFromKeyword(item as Keyword),
|
||||||
|
distance,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subArray.sort((a, b) => a.distance - b.distance);
|
||||||
|
return subArray.map(({ item }) => item);
|
||||||
|
}
|
||||||
|
|
||||||
|
_getMatchAndDistance(input: string, values: string[]) {
|
||||||
|
/**
|
||||||
|
* All the parts should match with something.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let distance = Number.MAX_SAFE_INTEGER;
|
||||||
|
for (const part of input.split(' ').filter(Boolean)) {
|
||||||
|
const match = this._getInternalMatch(part, values);
|
||||||
|
if (!match.isMatch) {
|
||||||
|
return { isMatch: false, distance: Number.MAX_SAFE_INTEGER };
|
||||||
|
}
|
||||||
|
|
||||||
|
distance = match.distance < distance ? match.distance : distance;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isMatch: true, distance };
|
||||||
|
}
|
||||||
|
|
||||||
|
_getInternalMatch(input: string, values: string[]) {
|
||||||
|
let isMatch = false;
|
||||||
|
let distance = Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
|
for (const k of values) {
|
||||||
|
const match = fuzzyMatch(input, k);
|
||||||
|
isMatch ||= match.isMatch;
|
||||||
|
|
||||||
|
if (match.distance < distance) {
|
||||||
|
distance = match.distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isMatch, distance };
|
||||||
|
}
|
||||||
|
|
||||||
|
_getValueList(item: SearchItem | Keyword): string[] {
|
||||||
|
const { label, group } = item as SearchItem;
|
||||||
|
if (group && group !== 'Docs') {
|
||||||
|
return [label, group];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { values, meta } = item as Keyword;
|
||||||
|
const schemaLabel = meta.schemaName as string;
|
||||||
|
return [values, schemaLabel].flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
_getDocSearchItemFromKeyword(keyword: Keyword): DocSearchItem {
|
||||||
|
const schemaName = keyword.meta.schemaName as string;
|
||||||
|
const schemaLabel = this.fyo.schemaMap[schemaName]?.label!;
|
||||||
|
const route = this._getRouteFromKeyword(keyword);
|
||||||
|
return {
|
||||||
|
label: keyword.values[0],
|
||||||
|
schemaLabel,
|
||||||
|
more: keyword.values.slice(1),
|
||||||
|
group: 'Docs',
|
||||||
|
action: async () => {
|
||||||
|
await routeTo(route);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_getRouteFromKeyword(keyword: Keyword): string {
|
||||||
|
const { parent, parentSchemaName, schemaName } = keyword.meta;
|
||||||
|
if (parent && parentSchemaName) {
|
||||||
|
return getEntryRoute(parentSchemaName as string, parent as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getEntryRoute(schemaName as string, keyword.values[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
_getGroupedKeywords() {
|
||||||
|
/**
|
||||||
|
* filter out the ignored groups
|
||||||
|
* group by the keyword priority
|
||||||
|
*/
|
||||||
|
const keywords: Keyword[] = [];
|
||||||
|
const schemaNames = Object.keys(this.keywords);
|
||||||
|
for (const sn of schemaNames) {
|
||||||
|
const searchable = this.searchables[sn];
|
||||||
|
if (!this.filters.schemaFilters[sn] || !this.filters.groupFilters.Docs) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchable.isChild && this.filters.skipTables) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchable.isSubmittable && this.filters.skipTransactions) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
keywords.push(...this.keywords[sn]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupBy(keywords.flat(), 'priority');
|
||||||
|
}
|
||||||
|
|
||||||
|
_setSearchables() {
|
||||||
|
for (const schemaName of Object.keys(this.fyo.schemaMap)) {
|
||||||
|
const schema = this.fyo.schemaMap[schemaName];
|
||||||
|
if (!schema?.keywordFields?.length || this.searchables[schemaName]) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -362,61 +709,62 @@ class Search {
|
|||||||
meta.push('submitted', 'cancelled');
|
meta.push('submitted', 'cancelled');
|
||||||
}
|
}
|
||||||
|
|
||||||
searchable.push({
|
this.searchables[schemaName] = {
|
||||||
schemaName,
|
schemaName,
|
||||||
fields,
|
fields,
|
||||||
meta,
|
meta,
|
||||||
isChild: !!schema.isChild,
|
isChild: !!schema.isChild,
|
||||||
isSubmittable: !!schema.isSubmittable,
|
isSubmittable: !!schema.isSubmittable,
|
||||||
|
needsUpdate: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_setDocObservers() {
|
||||||
|
if (this._obsSet) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { schemaName } of Object.values(this.searchables)) {
|
||||||
|
this.fyo.doc.observer.on(`sync:${schemaName}`, () => {
|
||||||
|
this.searchables[schemaName].needsUpdate = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.fyo.doc.observer.on(`delete:${schemaName}`, () => {
|
||||||
|
this.searchables[schemaName].needsUpdate = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return searchable;
|
this._obsSet = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
_setPriority() {
|
_setKeywords(maps: RawValueMap[], searchable: Searchable) {
|
||||||
for (const schemaName in this.keywords) {
|
|
||||||
const kw = this.keywords[schemaName];
|
|
||||||
const basePriority = this.priorityMap[schemaName] ?? 0;
|
|
||||||
|
|
||||||
for (const k of kw.keywords) {
|
|
||||||
k.priority += basePriority;
|
|
||||||
|
|
||||||
if (k.meta.submitted) {
|
|
||||||
k.priority += 25;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (k.meta.cancelled) {
|
|
||||||
k.priority -= 200;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (kw.searchable.isChild) {
|
|
||||||
k.priority -= 150;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_addToSearchable(maps: DocValueMap[], searchable: Searchable) {
|
|
||||||
if (!maps.length) {
|
if (!maps.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.keywords[searchable.schemaName] ??= { searchable, keywords: [] };
|
this.keywords[searchable.schemaName] = [];
|
||||||
|
|
||||||
for (const map of maps) {
|
for (const map of maps) {
|
||||||
const keyword: Keyword = { values: [], meta: {}, priority: 0 };
|
const keyword: Keyword = { values: [], meta: {}, priority: 0 };
|
||||||
this._setKeywords(map, searchable, keyword);
|
this._setKeywordValues(map, searchable, keyword);
|
||||||
this._setMeta(map, searchable, keyword);
|
this._setMeta(map, searchable, keyword);
|
||||||
this.keywords[searchable.schemaName]!.keywords.push(keyword);
|
this.keywords[searchable.schemaName]!.push(keyword);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._setPriority(searchable);
|
||||||
}
|
}
|
||||||
|
|
||||||
_setKeywords(map: DocValueMap, searchable: Searchable, keyword: Keyword) {
|
_setKeywordValues(
|
||||||
|
map: RawValueMap,
|
||||||
|
searchable: Searchable,
|
||||||
|
keyword: Keyword
|
||||||
|
) {
|
||||||
// Set individual field values
|
// Set individual field values
|
||||||
for (const fn of searchable.fields) {
|
for (const fn of searchable.fields) {
|
||||||
let value = map[fn] as string | undefined;
|
let value = map[fn] as string | undefined;
|
||||||
const field = fyo.getField(searchable.schemaName, fn);
|
const field = this.fyo.getField(searchable.schemaName, fn);
|
||||||
|
|
||||||
const { options } = field as OptionField;
|
const { options } = field as OptionField;
|
||||||
if (options) {
|
if (options) {
|
||||||
value = options.find((o) => o.value === value)?.label ?? value;
|
value = options.find((o) => o.value === value)?.label ?? value;
|
||||||
@ -426,7 +774,7 @@ class Search {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_setMeta(map: DocValueMap, searchable: Searchable, keyword: Keyword) {
|
_setMeta(map: RawValueMap, searchable: Searchable, keyword: Keyword) {
|
||||||
// Set the meta map
|
// Set the meta map
|
||||||
for (const fn of searchable.meta) {
|
for (const fn of searchable.meta) {
|
||||||
const meta = map[fn];
|
const meta = map[fn];
|
||||||
@ -438,12 +786,29 @@ class Search {
|
|||||||
}
|
}
|
||||||
|
|
||||||
keyword.meta.schemaName = searchable.schemaName;
|
keyword.meta.schemaName = searchable.schemaName;
|
||||||
|
if (keyword.meta.parent) {
|
||||||
|
keyword.values.unshift(keyword.meta.parent as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_setPriority(searchable: Searchable) {
|
||||||
|
const keywords = this.keywords[searchable.schemaName] ?? [];
|
||||||
|
const basePriority = this.priorityMap[searchable.schemaName] ?? 0;
|
||||||
|
|
||||||
|
for (const k of keywords) {
|
||||||
|
k.priority += basePriority;
|
||||||
|
|
||||||
|
if (k.meta.submitted) {
|
||||||
|
k.priority += 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (k.meta.cancelled) {
|
||||||
|
k.priority -= 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchable.isChild) {
|
||||||
|
k.priority -= 150;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const docSearch = new Search();
|
|
||||||
|
|
||||||
if (fyo.store.isDevelopment) {
|
|
||||||
//@ts-ignore
|
|
||||||
window.search = docSearch;
|
|
||||||
}
|
|
||||||
|
@ -129,7 +129,7 @@ export function openSettings(tab: SettingsTab) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function routeTo(route: string | RouteLocationRaw) {
|
export async function routeTo(route: string | RouteLocationRaw) {
|
||||||
let routeOptions = route;
|
const routeOptions = route;
|
||||||
if (
|
if (
|
||||||
typeof route === 'string' &&
|
typeof route === 'string' &&
|
||||||
route === router.currentRoute.value.fullPath
|
route === router.currentRoute.value.fullPath
|
||||||
@ -138,10 +138,10 @@ export async function routeTo(route: string | RouteLocationRaw) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof route === 'string') {
|
if (typeof route === 'string') {
|
||||||
routeOptions = { path: route };
|
return await router.push(route);
|
||||||
}
|
}
|
||||||
|
|
||||||
await router.push(routeOptions);
|
return await router.push(routeOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteDocWithPrompt(doc: Doc) {
|
export async function deleteDocWithPrompt(doc: Doc) {
|
||||||
|
Loading…
Reference in New Issue
Block a user