support drag-and-drop to change query display orders

This commit is contained in:
MaysWind
2026-01-07 23:08:30 +08:00
parent d462d0164c
commit ab88b0bf44
4 changed files with 364 additions and 362 deletions
+35 -27
View File
@@ -28,7 +28,7 @@ export class InsightsExplorerBasicInfo implements InsightsExplorerInfoResponse {
public name: string; public name: string;
public displayOrder: number; public displayOrder: number;
public hidden: boolean; public hidden: boolean;
public data: Record<string, string | number | string[]> = {}; public data: Record<string, string | number | object[]> = {};
private constructor(id: string, name: string, displayOrder: number, hidden: boolean) { private constructor(id: string, name: string, displayOrder: number, hidden: boolean) {
this.id = id; this.id = id;
@@ -98,9 +98,9 @@ export class InsightsExplorer implements InsightsExplorerInfoResponse {
this.chartSortingType = chartSortingType; this.chartSortingType = chartSortingType;
} }
public get data(): Record<string, string | number | string[]> { public get data(): Record<string, string | number | object[]> {
return { return {
queries: this.queries.map(q => q.toJson()), queries: this.queries.map(q => q.toJsonObject()),
timezoneUsedForDateRange: this.timezoneUsedForDateRange, timezoneUsedForDateRange: this.timezoneUsedForDateRange,
chartType: this.chartType, chartType: this.chartType,
categoryDimension: this.categoryDimension, categoryDimension: this.categoryDimension,
@@ -139,10 +139,10 @@ export class InsightsExplorer implements InsightsExplorerInfoResponse {
if (data) { if (data) {
if (Array.isArray(data['queries'])) { if (Array.isArray(data['queries'])) {
const textualQueries = data['queries'] as string[]; const queryItems = data['queries'] as object[];
for (const textualQuery of textualQueries) { for (const queryItem of queryItems) {
const query = TransactionExplorerQuery.parse(textualQuery); const query = TransactionExplorerQuery.parse(queryItem);
if (query) { if (query) {
queries.push(query); queries.push(query);
@@ -190,13 +190,13 @@ export class InsightsExplorer implements InsightsExplorerInfoResponse {
); );
} }
public static createNewExplorer(name?: string): InsightsExplorer { public static createNewExplorer(newQueryId: string): InsightsExplorer {
return new InsightsExplorer( return new InsightsExplorer(
'', '',
name || '', '',
0, 0,
false, false,
[TransactionExplorerQuery.create()], [TransactionExplorerQuery.create(newQueryId)],
InsightsExplorer.Default.timezoneUsedForDateRange, InsightsExplorer.Default.timezoneUsedForDateRange,
InsightsExplorer.Default.chartType, InsightsExplorer.Default.chartType,
InsightsExplorer.Default.categoryDimension, InsightsExplorer.Default.categoryDimension,
@@ -209,14 +209,14 @@ export class InsightsExplorer implements InsightsExplorerInfoResponse {
export interface InsightsExplorerCreateRequest { export interface InsightsExplorerCreateRequest {
readonly name: string; readonly name: string;
readonly data: Record<string, string | number | string[]>; readonly data: Record<string, string | number | object[]>;
readonly clientSessionId?: string; readonly clientSessionId?: string;
} }
export interface InsightsExplorerModifyRequest { export interface InsightsExplorerModifyRequest {
readonly id: string; readonly id: string;
readonly name: string; readonly name: string;
readonly data: Record<string, string | number | string[]>; readonly data: Record<string, string | number | object[]>;
readonly hidden: boolean; readonly hidden: boolean;
readonly clientSessionId?: string; readonly clientSessionId?: string;
} }
@@ -244,7 +244,7 @@ export interface InsightsExplorerInfoResponse {
readonly name: string; readonly name: string;
readonly displayOrder: number; readonly displayOrder: number;
readonly hidden: boolean; readonly hidden: boolean;
readonly data: Record<string, string | number | string[]>; readonly data: Record<string, string | number | object[]>;
} }
interface ExpressionNode { interface ExpressionNode {
@@ -253,10 +253,12 @@ interface ExpressionNode {
} }
export class TransactionExplorerQuery { export class TransactionExplorerQuery {
public id: string;
public name: string; public name: string;
public conditions: TransactionExplorerConditionWithRelation[]; public conditions: TransactionExplorerConditionWithRelation[];
private constructor(name: string, conditions: TransactionExplorerConditionWithRelation[]) { private constructor(id: string, name: string, conditions: TransactionExplorerConditionWithRelation[]) {
this.id = id;
this.name = name; this.name = name;
this.conditions = conditions; this.conditions = conditions;
} }
@@ -450,7 +452,7 @@ export class TransactionExplorerQuery {
return finalTokens; return finalTokens;
} }
public clone(): TransactionExplorerQuery { public clone(newId: string): TransactionExplorerQuery {
const clonedConditions: TransactionExplorerConditionWithRelation[] = []; const clonedConditions: TransactionExplorerConditionWithRelation[] = [];
for (const condition of this.conditions) { for (const condition of this.conditions) {
@@ -463,29 +465,35 @@ export class TransactionExplorerQuery {
clonedConditions.push(clonedCondition); clonedConditions.push(clonedCondition);
} }
return new TransactionExplorerQuery(this.name, clonedConditions); return new TransactionExplorerQuery(newId, this.name, clonedConditions);
} }
public toJson(): string { public toJsonObject(): object {
return JSON.stringify({ return {
id: this.id,
name: this.name, name: this.name,
conditions: this.conditions.map(condition => condition.toJsonObject()) conditions: this.conditions.map(condition => condition.toJsonObject())
}); };
} }
public static create(): TransactionExplorerQuery { public static create(id: string): TransactionExplorerQuery {
return new TransactionExplorerQuery("", []); return new TransactionExplorerQuery(id, "", []);
} }
public static parse(json: string): TransactionExplorerQuery | null { public static parse(obj: object): TransactionExplorerQuery | null {
const parsed = JSON.parse(json); if (!('id' in obj) || !('name' in obj) || !('conditions' in obj)) {
const nameFieldValue = parsed['name'] as unknown;
const conditionsFieldValue = parsed['conditions'] as unknown;
if (typeof nameFieldValue !== 'string' || !Array.isArray(conditionsFieldValue)) {
return null; return null;
} }
const idFieldValue = obj['id'] as unknown;
const nameFieldValue = obj['name'] as unknown;
const conditionsFieldValue = obj['conditions'] as unknown;
if (typeof idFieldValue !== 'string' || typeof nameFieldValue !== 'string' || !Array.isArray(conditionsFieldValue)) {
return null;
}
const id: string = idFieldValue;
const name: string = nameFieldValue; const name: string = nameFieldValue;
const conditions: TransactionExplorerConditionWithRelation[] = []; const conditions: TransactionExplorerConditionWithRelation[] = [];
@@ -505,7 +513,7 @@ export class TransactionExplorerQuery {
conditions.push(condition); conditions.push(condition);
} }
return new TransactionExplorerQuery(name, conditions); return new TransactionExplorerQuery(id, name, conditions);
} }
} }
+4 -3
View File
@@ -48,6 +48,7 @@ import {
getDateRangeByDateType, getDateRangeByDateType,
getFiscalYearFromUnixTime getFiscalYearFromUnixTime
} from '@/lib/datetime.ts'; } from '@/lib/datetime.ts';
import { generateRandomUUID } from '@/lib/misc.ts';
import services, { type ApiResponsePromise } from '@/lib/services.ts'; import services, { type ApiResponsePromise } from '@/lib/services.ts';
import logger from '@/lib/logger.ts'; import logger from '@/lib/logger.ts';
@@ -473,7 +474,7 @@ export const useExplorersStore = defineStore('explorers', () => {
const allInsightsExplorerBasicInfos = ref<InsightsExplorerBasicInfo[]>([]); const allInsightsExplorerBasicInfos = ref<InsightsExplorerBasicInfo[]>([]);
const allInsightsExplorerBasicInfosMap = ref<Record<string, InsightsExplorerBasicInfo>>({}); const allInsightsExplorerBasicInfosMap = ref<Record<string, InsightsExplorerBasicInfo>>({});
const currentInsightsExplorer = ref<InsightsExplorer>(InsightsExplorer.createNewExplorer()); const currentInsightsExplorer = ref<InsightsExplorer>(InsightsExplorer.createNewExplorer(generateRandomUUID()));
const insightsExplorerListStateInvalid = ref<boolean>(true); const insightsExplorerListStateInvalid = ref<boolean>(true);
const allTransactions = computed<TransactionInsightDataItem[]>(() => { const allTransactions = computed<TransactionInsightDataItem[]>(() => {
@@ -743,7 +744,7 @@ export const useExplorersStore = defineStore('explorers', () => {
transactionExplorerFilter.value.startTime = 0; transactionExplorerFilter.value.startTime = 0;
transactionExplorerFilter.value.endTime = 0; transactionExplorerFilter.value.endTime = 0;
transactionExplorerAllData.value = []; transactionExplorerAllData.value = [];
currentInsightsExplorer.value = InsightsExplorer.createNewExplorer(); currentInsightsExplorer.value = InsightsExplorer.createNewExplorer(generateRandomUUID());
transactionExplorerStateInvalid.value = true; transactionExplorerStateInvalid.value = true;
} }
@@ -784,7 +785,7 @@ export const useExplorersStore = defineStore('explorers', () => {
} }
if (resetQuery) { if (resetQuery) {
currentInsightsExplorer.value = InsightsExplorer.createNewExplorer(); currentInsightsExplorer.value = InsightsExplorer.createNewExplorer(generateRandomUUID());
} }
} }
+2 -2
View File
@@ -340,7 +340,7 @@ function init(initProps: InsightsExplorerProps): void {
loadExplorer(initProps.initId); loadExplorer(initProps.initId);
} }
} else { } else {
explorersStore.updateCurrentInsightsExplorer(InsightsExplorer.createNewExplorer()); explorersStore.updateCurrentInsightsExplorer(InsightsExplorer.createNewExplorer(generateRandomUUID()));
} }
if (!needReload && !explorersStore.transactionExplorerStateInvalid && !explorersStore.insightsExplorerListStateInvalid) { if (!needReload && !explorersStore.transactionExplorerStateInvalid && !explorersStore.insightsExplorerListStateInvalid) {
@@ -394,7 +394,7 @@ function createNewExplorer(): void {
return; return;
} }
explorersStore.updateCurrentInsightsExplorer(InsightsExplorer.createNewExplorer()); explorersStore.updateCurrentInsightsExplorer(InsightsExplorer.createNewExplorer(generateRandomUUID()));
router.push(getFilterLinkUrl()); router.push(getFilterLinkUrl());
} }
@@ -10,340 +10,345 @@
@click="clearAllQueries">{{ tt('Clear All') }}</v-btn> @click="clearAllQueries">{{ tt('Clear All') }}</v-btn>
</div> </div>
</v-card-subtitle> </v-card-subtitle>
<v-card-text class="pt-0"> <v-card-text class="pt-0 pb-0">
<div :key="queryIndex" v-for="(query, queryIndex) in queries"> <draggable-list
<v-card border class="card-title-with-bg mt-4"> item-key="id"
<v-card-title class="d-flex align-center py-2 px-5"> handle=".drag-handle"
<v-icon :icon="mdiTextBoxSearchOutline" size="20" /> ghost-class="dragging-item"
<span class="query-name text-subtitle-1 ms-2" v-if="editingQuery !== query">{{ query.name || tt('format.misc.queryIndex', { index: queryIndex + 1 }) }}</span> v-model="queries"
<div class="query-name-edit ms-2" v-if="editingQuery === query"> >
<v-text-field autofocus type="text" density="compact" variant="underlined" <template #item="{ element, index }">
:disabled="loading || disabled" <v-card border class="card-title-with-bg mt-4 mb-8">
:placeholder="tt('format.misc.queryIndex', { index: queryIndex + 1 })" <v-card-title class="d-flex align-center py-2 px-5">
v-text-field-auto-width="{ minWidth: 20, maxWidth: 300, auxSpanId: `query-name-aux-span-${queryIndex + 1}` }" <v-icon :icon="mdiTextBoxSearchOutline" size="20" />
v-model="editingQueryName" <span class="query-name text-subtitle-1 ms-2" v-if="editingQuery !== element">{{ element.name || tt('format.misc.queryIndex', { index: index + 1 }) }}</span>
@keyup.esc="cancelUpdateQueryName" <div class="query-name-edit ms-2" v-if="editingQuery === element">
@keyup.enter="updateQueryName(query)" /> <v-text-field autofocus type="text" density="compact" variant="underlined"
<span :id="`query-name-aux-span-${queryIndex + 1}`" /> :disabled="loading || disabled"
</div> :placeholder="tt('format.misc.queryIndex', { index: index + 1 })"
<v-btn class="ms-2" density="compact" color="primary" variant="text" size="small" v-text-field-auto-width="{ minWidth: 20, maxWidth: 300, auxSpanId: `query-name-aux-span-${index + 1}-${element.id}` }"
:icon="true" :disabled="loading || disabled" v-model="editingQueryName"
@click="updateQueryName(query)" @keyup.esc="cancelUpdateQueryName"
v-if="editingQuery === query"> @keyup.enter="updateQueryName(element)" />
<v-icon :icon="mdiCheck" size="18" /> <span :id="`query-name-aux-span-${index + 1}-${element.id}`" />
<v-tooltip activator="parent">{{ tt('Update') }}</v-tooltip> </div>
</v-btn> <v-btn class="ms-2" density="compact" color="primary" variant="text" size="small"
<v-btn class="ms-2" density="compact" color="default" variant="text" size="small" :icon="true" :disabled="loading || disabled"
:icon="true" :disabled="loading || disabled" @click="updateQueryName(element)"
@click="cancelUpdateQueryName" v-if="editingQuery === element">
v-if="editingQuery === query"> <v-icon :icon="mdiCheck" size="18" />
<v-icon :icon="mdiClose" size="18" /> <v-tooltip activator="parent">{{ tt('Update') }}</v-tooltip>
<v-tooltip activator="parent">{{ tt('Cancel') }}</v-tooltip> </v-btn>
</v-btn> <v-btn class="ms-2" density="compact" color="default" variant="text" size="small"
<v-btn class="ms-2" density="compact" color="default" variant="text" size="small" :icon="true" :disabled="loading || disabled"
:icon="true" :disabled="loading || disabled || !!editingQuery" @click="cancelUpdateQueryName"
@click="editingQueryName = query.name; editingQuery = query" v-if="editingQuery === element">
v-if="!editingQuery || editingQuery !== query"> <v-icon :icon="mdiClose" size="18" />
<v-icon :icon="mdiPencilOutline" size="18" /> <v-tooltip activator="parent">{{ tt('Cancel') }}</v-tooltip>
<v-tooltip activator="parent">{{ tt('Modify Query Name') }}</v-tooltip> </v-btn>
</v-btn> <v-btn class="ms-2" density="compact" color="default" variant="text" size="small"
<v-btn class="ms-2" density="compact" color="default" variant="text" size="small" :icon="true" :disabled="loading || disabled || !!editingQuery"
:icon="true" :disabled="loading || disabled || !!editingQuery" @click="editingQueryName = element.name; editingQuery = element"
@click="duplicateQuery(query)" v-if="!editingQuery || editingQuery !== element">
v-if="!editingQuery || editingQuery !== query"> <v-icon :icon="mdiPencilOutline" size="18" />
<v-icon :icon="mdiContentCopy" size="18" /> <v-tooltip activator="parent">{{ tt('Modify Query Name') }}</v-tooltip>
<v-tooltip activator="parent">{{ tt('Duplicate') }}</v-tooltip> </v-btn>
</v-btn> <v-btn class="ms-2" density="compact" color="default" variant="text" size="small"
<v-spacer /> :icon="true" :disabled="loading || disabled || !!editingQuery"
<v-switch class="bidirectional-switch ms-2" color="secondary" @click="duplicateQuery(element)"
:disabled="loading || disabled || !!editingQuery || !query.conditions || query.conditions.length < 1" v-if="!editingQuery || editingQuery !== element">
:label="tt('Expression')" <v-icon :icon="mdiContentCopy" size="18" />
v-model="showExpression[queryIndex]" <v-tooltip activator="parent">{{ tt('Duplicate') }}</v-tooltip>
@click="showExpression[queryIndex] = !showExpression[queryIndex]"> </v-btn>
<template #prepend> <v-spacer />
<span>{{ tt('Editor') }}</span> <v-switch class="bidirectional-switch ms-2" color="secondary"
</template> :disabled="loading || disabled || !!editingQuery || !element.conditions || element.conditions.length < 1"
</v-switch> :label="tt('Expression')"
<v-btn class="ms-2" density="compact" color="default" variant="text" size="small" v-model="showExpression[element.id]"
:icon="true" :disabled="loading || disabled || !!editingQuery || queries.length < 1" @click="showExpression[element.id] = !showExpression[element.id]">
@click="removeQuery(queryIndex)"> <template #prepend>
<v-icon :icon="mdiClose" size="18" /> <span>{{ tt('Editor') }}</span>
<v-tooltip activator="parent">{{ tt('Remove Query') }}</v-tooltip> </template>
</v-btn> </v-switch>
</v-card-title> <v-btn class="ms-2" density="compact" color="default" variant="text" size="small"
:icon="true" :disabled="loading || disabled || !!editingQuery || queries.length < 1 || (queries.length === 1 && (!element.conditions || element.conditions.length < 1))"
@click="removeQuery(index)">
<v-icon :icon="mdiClose" size="18" />
<v-tooltip activator="parent">{{ tt('Remove Query') }}</v-tooltip>
</v-btn>
<span class="ms-2 mb-1">
<v-icon :class="!loading && !disabled && !editingQuery && queries.length > 1 ? 'drag-handle' : 'disabled'"
:icon="mdiDrag"/>
<v-tooltip activator="parent" v-if="!loading && !disabled && !editingQuery && queries.length > 1">{{ tt('Drag to Reorder') }}</v-tooltip>
</span>
</v-card-title>
<v-divider /> <v-divider />
<v-card-text> <v-card-text>
<v-row> <v-row>
<v-col cols="12"> <v-col cols="12">
<div class="text-center py-4" v-if="!query.conditions || query.conditions.length < 1"> <div class="text-center py-4" v-if="!element.conditions || element.conditions.length < 1">
{{ tt('No conditions defined. All transactions will match.') }} {{ tt('No conditions defined. All transactions will match.') }}
</div> </div>
<div v-else-if="query.conditions && query.conditions.length > 0 && !showExpression[queryIndex]"> <div v-else-if="element.conditions && element.conditions.length > 0 && !showExpression[element.id]">
<div :key="conditionIndex" v-for="(conditionWithRelation, conditionIndex) in query.conditions"> <div :key="conditionIndex" v-for="(conditionWithRelation, conditionIndex) in element.conditions">
<div class="d-flex overflow-x-auto align-center gap-2 mb-4"> <div class="d-flex overflow-x-auto align-center gap-2 mb-4">
<v-select
disabled
class="flex-0-0"
width="120px"
density="compact"
item-title="displayName"
item-value="value"
:items="[{ value: TransactionExplorerConditionRelation.First, displayName: tt('WHERE') }]"
:model-value="TransactionExplorerConditionRelation.First"
v-if="conditionIndex < 1"
/>
<v-select
class="flex-0-0"
width="120px"
density="compact"
item-title="displayName"
item-value="value"
:disabled="loading || disabled || !!editingQuery"
:items="[
{ value: TransactionExplorerConditionRelation.And, displayName: tt('AND') },
{ value: TransactionExplorerConditionRelation.Or, displayName: tt('OR') }
]"
v-model="conditionWithRelation.relation"
v-else-if="conditionIndex >= 1"
/>
<v-select
class="flex-0-0"
density="compact"
item-title="name"
item-value="value"
:disabled="loading || disabled || !!editingQuery"
:items="allTransactionExplorerConditionFields"
@update:model-value="updateConditionField(queryIndex, conditionIndex, TransactionExplorerConditionField.valueOf($event))"
v-model="conditionWithRelation.condition.field"
/>
<v-select
class="flex-0-0"
density="compact"
item-title="name"
item-value="value"
:disabled="loading || disabled || !!editingQuery"
:items="getAllTransactionExplorerConditionOperators(conditionWithRelation.getSupportedOperators())"
v-model="conditionWithRelation.condition.operator"
/>
<div class="d-flex w-100 flex-1-1" style="min-width: 280px;">
<v-select <v-select
multiple chips closable-chips disabled
class="flex-0-0"
width="120px"
density="compact" density="compact"
item-title="displayName" item-title="displayName"
item-value="type" item-value="value"
:items="[{ value: TransactionExplorerConditionRelation.First, displayName: tt('WHERE') }]"
:model-value="TransactionExplorerConditionRelation.First"
v-if="conditionIndex < 1"
/>
<v-select
class="flex-0-0"
width="120px"
density="compact"
item-title="displayName"
item-value="value"
:disabled="loading || disabled || !!editingQuery" :disabled="loading || disabled || !!editingQuery"
:placeholder="tt('None')"
:items="[ :items="[
{ type: TransactionType.Expense, displayName: tt('Expense') }, { value: TransactionExplorerConditionRelation.And, displayName: tt('AND') },
{ type: TransactionType.Income, displayName: tt('Income') }, { value: TransactionExplorerConditionRelation.Or, displayName: tt('OR') }
{ type: TransactionType.Transfer, displayName: tt('Transfer') }
]" ]"
v-model="conditionWithRelation.condition.value" v-model="conditionWithRelation.relation"
v-if="conditionWithRelation.condition.field === TransactionExplorerConditionField.TransactionType.value" v-else-if="conditionIndex >= 1"
> />
<template #item="{ props, item }">
<v-list-item :value="item.value" v-bind="props">
<template #title>
<v-list-item-title>
<div class="d-flex align-center">{{ item.title }}</div>
</v-list-item-title>
</template>
</v-list-item>
</template>
</v-select>
<v-text-field <v-select
class="always-cursor-pointer text-field-truncate" class="flex-0-0"
density="compact" density="compact"
item-title="displayName" item-title="name"
item-value="type" item-value="value"
persistent-placeholder :disabled="loading || disabled || !!editingQuery"
:readonly="true" :items="allTransactionExplorerConditionFields"
:disabled="loading || disabled || !!editingQuery || !hasAnyTransactionCategory" @update:model-value="updateConditionField(element, conditionIndex, TransactionExplorerConditionField.valueOf($event))"
:placeholder="tt('None')" v-model="conditionWithRelation.condition.field"
:model-value="getFilteredTransactionCategoriesDisplayContent(arrayItemToObjectField(conditionWithRelation.condition.value as string[], true))"
@click="currentCondition = conditionWithRelation.condition; showFilterTransactionCategoriesDialog = true"
v-else-if="conditionWithRelation.condition.field === TransactionExplorerConditionField.TransactionCategory.value"
/> />
<v-text-field <v-select
class="always-cursor-pointer text-field-truncate" class="flex-0-0"
density="compact" density="compact"
item-title="displayName" item-title="name"
item-value="type" item-value="value"
persistent-placeholder :disabled="loading || disabled || !!editingQuery"
:readonly="true" :items="getAllTransactionExplorerConditionOperators(conditionWithRelation.getSupportedOperators())"
:disabled="loading || disabled || !!editingQuery || !hasAnyAccount" v-model="conditionWithRelation.condition.operator"
:placeholder="tt('None')"
:model-value="getFilteredAccountsDisplayContent(arrayItemToObjectField(conditionWithRelation.condition.value as string[], true))"
@click="currentCondition = conditionWithRelation.condition; showFilterSourceAccountsDialog = true"
v-else-if="conditionWithRelation.condition.field === TransactionExplorerConditionField.SourceAccount.value"
/> />
<v-text-field <div class="d-flex w-100 flex-1-1" style="min-width: 280px;">
class="always-cursor-pointer text-field-truncate" <v-select
density="compact" multiple chips closable-chips
item-title="displayName"
item-value="type"
persistent-placeholder
:readonly="true"
:disabled="loading || disabled || !!editingQuery || !hasAnyAccount"
:placeholder="tt('None')"
:model-value="getFilteredAccountsDisplayContent(arrayItemToObjectField(conditionWithRelation.condition.value as string[], true))"
@click="currentCondition = conditionWithRelation.condition; showFilterDestinationAccountsDialog = true"
v-else-if="conditionWithRelation.condition.field === TransactionExplorerConditionField.DestinationAccount.value"
/>
<div class="d-flex w-100 align-center gap-2"
v-else-if="conditionWithRelation.condition.field === TransactionExplorerConditionField.SourceAmount.value ||
conditionWithRelation.condition.field === TransactionExplorerConditionField.DestinationAmount.value">
<amount-input density="compact"
:currency="defaultCurrency"
:disabled="loading || disabled || !!editingQuery"
v-model="conditionWithRelation.condition.value[0]"
/>
<span class="ms-2 me-2"
v-if="conditionWithRelation.condition.operator === TransactionExplorerConditionOperator.Between.value ||
conditionWithRelation.condition.operator === TransactionExplorerConditionOperator.NotBetween.value">~</span>
<amount-input density="compact"
:currency="defaultCurrency"
:disabled="loading || disabled || !!editingQuery"
v-model="conditionWithRelation.condition.value[1]"
v-if="conditionWithRelation.condition.operator === TransactionExplorerConditionOperator.Between.value ||
conditionWithRelation.condition.operator === TransactionExplorerConditionOperator.NotBetween.value"
/>
</div>
<v-text-field disabled density="compact"
:placeholder="tt('None')"
v-else-if="conditionWithRelation.condition.field === TransactionExplorerConditionField.GeoLocation.value"
/>
<div class="d-flex w-100" v-else-if="conditionWithRelation.condition.field === TransactionExplorerConditionField.TransactionTag.value">
<v-text-field
disabled
persistent-placeholder
density="compact" density="compact"
:placeholder="tt('None')" item-title="displayName"
v-if="conditionWithRelation.condition.field === TransactionExplorerConditionField.TransactionTag.value && item-value="type"
(conditionWithRelation.condition.operator === TransactionExplorerConditionOperator.IsEmpty.value || conditionWithRelation.condition.operator === TransactionExplorerConditionOperator.IsNotEmpty.value)"
/>
<v-autocomplete
density="compact"
item-title="name"
item-value="id"
auto-select-first
persistent-placeholder
multiple
chips
closable-chips
:disabled="loading || disabled || !!editingQuery" :disabled="loading || disabled || !!editingQuery"
:placeholder="tt('None')" :placeholder="tt('None')"
:items="allTags" :items="[
{ type: TransactionType.Expense, displayName: tt('Expense') },
{ type: TransactionType.Income, displayName: tt('Income') },
{ type: TransactionType.Transfer, displayName: tt('Transfer') }
]"
v-model="conditionWithRelation.condition.value" v-model="conditionWithRelation.condition.value"
v-model:search="tagSearchContent" v-if="conditionWithRelation.condition.field === TransactionExplorerConditionField.TransactionType.value"
v-else-if="conditionWithRelation.condition.operator !== TransactionExplorerConditionOperator.IsEmpty.value && conditionWithRelation.condition.operator !== TransactionExplorerConditionOperator.IsNotEmpty.value"
> >
<template #chip="{ props, item }">
<v-chip :prepend-icon="mdiPound" :text="item.title" v-bind="props"/>
</template>
<template #item="{ props, item }"> <template #item="{ props, item }">
<v-list-item :value="item.value" v-bind="props" v-if="!item.raw.hidden"> <v-list-item :value="item.value" v-bind="props">
<template #title> <template #title>
<v-list-item-title> <v-list-item-title>
<div class="d-flex align-center"> <div class="d-flex align-center">{{ item.title }}</div>
<v-icon size="20" start :icon="mdiPound"/>
<span>{{ item.title }}</span>
</div>
</v-list-item-title>
</template>
</v-list-item>
<v-list-item :disabled="true" v-bind="props"
v-if="item.raw.hidden && item.raw.name.toLowerCase().indexOf(tagSearchContent.toLowerCase()) >= 0 && isAllFilteredTagHidden">
<template #title>
<v-list-item-title>
<div class="d-flex align-center">
<v-icon size="20" start :icon="mdiPound"/>
<span>{{ item.title }}</span>
</div>
</v-list-item-title> </v-list-item-title>
</template> </template>
</v-list-item> </v-list-item>
</template> </template>
</v-select>
<template #no-data> <v-text-field
<v-list class="py-0"> class="always-cursor-pointer text-field-truncate"
<v-list-item>{{ tt('No available tag') }}</v-list-item> density="compact"
</v-list> item-title="displayName"
</template> item-value="type"
</v-autocomplete> persistent-placeholder
:readonly="true"
:disabled="loading || disabled || !!editingQuery || !hasAnyTransactionCategory"
:placeholder="tt('None')"
:model-value="getFilteredTransactionCategoriesDisplayContent(arrayItemToObjectField(conditionWithRelation.condition.value as string[], true))"
@click="currentCondition = conditionWithRelation.condition; showFilterTransactionCategoriesDialog = true"
v-else-if="conditionWithRelation.condition.field === TransactionExplorerConditionField.TransactionCategory.value"
/>
<v-text-field
class="always-cursor-pointer text-field-truncate"
density="compact"
item-title="displayName"
item-value="type"
persistent-placeholder
:readonly="true"
:disabled="loading || disabled || !!editingQuery || !hasAnyAccount"
:placeholder="tt('None')"
:model-value="getFilteredAccountsDisplayContent(arrayItemToObjectField(conditionWithRelation.condition.value as string[], true))"
@click="currentCondition = conditionWithRelation.condition; showFilterSourceAccountsDialog = true"
v-else-if="conditionWithRelation.condition.field === TransactionExplorerConditionField.SourceAccount.value"
/>
<v-text-field
class="always-cursor-pointer text-field-truncate"
density="compact"
item-title="displayName"
item-value="type"
persistent-placeholder
:readonly="true"
:disabled="loading || disabled || !!editingQuery || !hasAnyAccount"
:placeholder="tt('None')"
:model-value="getFilteredAccountsDisplayContent(arrayItemToObjectField(conditionWithRelation.condition.value as string[], true))"
@click="currentCondition = conditionWithRelation.condition; showFilterDestinationAccountsDialog = true"
v-else-if="conditionWithRelation.condition.field === TransactionExplorerConditionField.DestinationAccount.value"
/>
<div class="d-flex w-100 align-center gap-2"
v-else-if="conditionWithRelation.condition.field === TransactionExplorerConditionField.SourceAmount.value ||
conditionWithRelation.condition.field === TransactionExplorerConditionField.DestinationAmount.value">
<amount-input density="compact"
:currency="defaultCurrency"
:disabled="loading || disabled || !!editingQuery"
v-model="conditionWithRelation.condition.value[0]"
/>
<span class="ms-2 me-2"
v-if="conditionWithRelation.condition.operator === TransactionExplorerConditionOperator.Between.value ||
conditionWithRelation.condition.operator === TransactionExplorerConditionOperator.NotBetween.value">~</span>
<amount-input density="compact"
:currency="defaultCurrency"
:disabled="loading || disabled || !!editingQuery"
v-model="conditionWithRelation.condition.value[1]"
v-if="conditionWithRelation.condition.operator === TransactionExplorerConditionOperator.Between.value ||
conditionWithRelation.condition.operator === TransactionExplorerConditionOperator.NotBetween.value"
/>
</div>
<v-text-field disabled density="compact"
:placeholder="tt('None')"
v-else-if="conditionWithRelation.condition.field === TransactionExplorerConditionField.GeoLocation.value"
/>
<div class="d-flex w-100" v-else-if="conditionWithRelation.condition.field === TransactionExplorerConditionField.TransactionTag.value">
<v-text-field
disabled
persistent-placeholder
density="compact"
:placeholder="tt('None')"
v-if="conditionWithRelation.condition.field === TransactionExplorerConditionField.TransactionTag.value &&
(conditionWithRelation.condition.operator === TransactionExplorerConditionOperator.IsEmpty.value || conditionWithRelation.condition.operator === TransactionExplorerConditionOperator.IsNotEmpty.value)"
/>
<v-autocomplete
density="compact"
item-title="name"
item-value="id"
auto-select-first
persistent-placeholder
multiple
chips
closable-chips
:disabled="loading || disabled || !!editingQuery"
:placeholder="tt('None')"
:items="allTags"
v-model="conditionWithRelation.condition.value"
v-model:search="tagSearchContent"
v-else-if="conditionWithRelation.condition.operator !== TransactionExplorerConditionOperator.IsEmpty.value && conditionWithRelation.condition.operator !== TransactionExplorerConditionOperator.IsNotEmpty.value"
>
<template #chip="{ props, item }">
<v-chip :prepend-icon="mdiPound" :text="item.title" v-bind="props"/>
</template>
<template #item="{ props, item }">
<v-list-item :value="item.value" v-bind="props" v-if="!item.raw.hidden">
<template #title>
<v-list-item-title>
<div class="d-flex align-center">
<v-icon size="20" start :icon="mdiPound"/>
<span>{{ item.title }}</span>
</div>
</v-list-item-title>
</template>
</v-list-item>
<v-list-item :disabled="true" v-bind="props"
v-if="item.raw.hidden && item.raw.name.toLowerCase().indexOf(tagSearchContent.toLowerCase()) >= 0 && isAllFilteredTagHidden">
<template #title>
<v-list-item-title>
<div class="d-flex align-center">
<v-icon size="20" start :icon="mdiPound"/>
<span>{{ item.title }}</span>
</div>
</v-list-item-title>
</template>
</v-list-item>
</template>
<template #no-data>
<v-list class="py-0">
<v-list-item>{{ tt('No available tag') }}</v-list-item>
</v-list>
</template>
</v-autocomplete>
</div>
<v-text-field disabled density="compact"
:placeholder="tt('None')"
v-else-if="conditionWithRelation.condition.field === TransactionExplorerConditionField.Pictures.value"
/>
<v-text-field disabled density="compact"
:placeholder="tt('None')"
v-else-if="conditionWithRelation.condition.field === TransactionExplorerConditionField.Description.value &&
conditionWithRelation.condition.operator === TransactionExplorerConditionOperator.IsEmpty.value || conditionWithRelation.condition.operator === TransactionExplorerConditionOperator.IsNotEmpty.value"
/>
<v-text-field density="compact"
:disabled="loading || disabled || !!editingQuery"
:placeholder="tt('None')"
v-model="conditionWithRelation.condition.value"
v-else-if="conditionWithRelation.condition.field === TransactionExplorerConditionField.Description.value &&
conditionWithRelation.condition.operator !== TransactionExplorerConditionOperator.IsEmpty.value && conditionWithRelation.condition.operator !== TransactionExplorerConditionOperator.IsNotEmpty.value"
/>
</div> </div>
<v-text-field disabled density="compact" <v-btn color="default" density="compact"
:placeholder="tt('None')" variant="text" size="small"
v-else-if="conditionWithRelation.condition.field === TransactionExplorerConditionField.Pictures.value" :icon="true"
/> :disabled="loading || disabled || !!editingQuery"
@click="removeCondition(element, conditionIndex)">
<v-text-field disabled density="compact" <v-icon :icon="mdiClose" size="18" />
:placeholder="tt('None')" <v-tooltip activator="parent">{{ tt('Remove Condition') }}</v-tooltip>
v-else-if="conditionWithRelation.condition.field === TransactionExplorerConditionField.Description.value && </v-btn>
conditionWithRelation.condition.operator === TransactionExplorerConditionOperator.IsEmpty.value || conditionWithRelation.condition.operator === TransactionExplorerConditionOperator.IsNotEmpty.value"
/>
<v-text-field density="compact"
:disabled="loading || disabled || !!editingQuery"
:placeholder="tt('None')"
v-model="conditionWithRelation.condition.value"
v-else-if="conditionWithRelation.condition.field === TransactionExplorerConditionField.Description.value &&
conditionWithRelation.condition.operator !== TransactionExplorerConditionOperator.IsEmpty.value && conditionWithRelation.condition.operator !== TransactionExplorerConditionOperator.IsNotEmpty.value"
/>
</div> </div>
<v-btn color="default" density="compact"
variant="text" size="small"
:icon="true"
:disabled="loading || disabled || !!editingQuery"
@click="removeCondition(queryIndex, conditionIndex)">
<v-icon :icon="mdiClose" size="18" />
<v-tooltip activator="parent">{{ tt('Remove Condition') }}</v-tooltip>
</v-btn>
</div> </div>
</div> </div>
</div> <div v-else-if="element.conditions && element.conditions.length > 0 && showExpression[element.id]">
<div v-else-if="query.conditions && query.conditions.length > 0 && showExpression[queryIndex]"> <div class="w-100 code-container">
<div class="w-100 code-container"> <v-textarea class="w-100 always-cursor-text mb-4" :readonly="true"
<v-textarea class="w-100 always-cursor-text mb-4" :readonly="true" :value="getExpression(element, index)"></v-textarea>
:value="getExpression(queryIndex)"></v-textarea> </div>
</div> </div>
</div>
<v-btn class="px-2" density="comfortable" color="primary" variant="text" size="small" <v-btn class="px-2" density="comfortable" color="primary" variant="text" size="small"
:prepend-icon="mdiPlus" :prepend-icon="mdiPlus"
:disabled="loading || disabled || !!editingQuery || showExpression[queryIndex]" :disabled="loading || disabled || !!editingQuery || showExpression[element.id]"
@click="addCondition(queryIndex)"> @click="addCondition(element)">
{{ tt('Add Condition') }} {{ tt('Add Condition') }}
</v-btn> </v-btn>
</v-col> </v-col>
</v-row> </v-row>
</v-card-text> </v-card-text>
</v-card> </v-card>
</template>
<div class="query-group-separator d-flex align-center justify-center my-4" </draggable-list>
v-if="queries.length > 1 && queryIndex < queries.length - 1">
<v-chip color="primary" variant="outlined" size="small">
{{ tt('or') }}
</v-chip>
</div>
</div>
</v-card-text> </v-card-text>
<v-dialog width="800" v-model="showFilterSourceAccountsDialog"> <v-dialog width="800" v-model="showFilterSourceAccountsDialog">
@@ -407,6 +412,8 @@ import {
arrayItemToObjectField arrayItemToObjectField
} from '@/lib/common.ts'; } from '@/lib/common.ts';
import { generateRandomUUID } from '@/lib/misc.ts';
import logger from '@/lib/logger.ts'; import logger from '@/lib/logger.ts';
import { import {
@@ -416,6 +423,7 @@ import {
mdiContentCopy, mdiContentCopy,
mdiCheck, mdiCheck,
mdiClose, mdiClose,
mdiDrag,
mdiPound mdiPound
} from '@mdi/js'; } from '@mdi/js';
@@ -444,7 +452,7 @@ const explorersStore = useExplorersStore();
const snackbar = useTemplateRef<SnackBarType>('snackbar'); const snackbar = useTemplateRef<SnackBarType>('snackbar');
const currentCondition = ref<TransactionExplorerCondition | undefined>(undefined); const currentCondition = ref<TransactionExplorerCondition | undefined>(undefined);
const showExpression = ref<Record<number, boolean>>({}); const showExpression = ref<Record<string, boolean>>({});
const showFilterSourceAccountsDialog = ref<boolean>(false); const showFilterSourceAccountsDialog = ref<boolean>(false);
const showFilterDestinationAccountsDialog = ref<boolean>(false); const showFilterDestinationAccountsDialog = ref<boolean>(false);
const showFilterTransactionCategoriesDialog = ref<boolean>(false); const showFilterTransactionCategoriesDialog = ref<boolean>(false);
@@ -452,7 +460,12 @@ const tagSearchContent = ref<string>('');
const editingQuery = ref<TransactionExplorerQuery | undefined>(undefined); const editingQuery = ref<TransactionExplorerQuery | undefined>(undefined);
const editingQueryName = ref<string>(''); const editingQueryName = ref<string>('');
const queries = computed<TransactionExplorerQuery[]>(() => explorersStore.currentInsightsExplorer.queries); const queries = computed<TransactionExplorerQuery[]>({
get: () => explorersStore.currentInsightsExplorer.queries,
set: (value: TransactionExplorerQuery[]) => {
explorersStore.currentInsightsExplorer.queries = value;
}
});
const defaultCurrency = computed<string>(() => userStore.currentUserDefaultCurrency); const defaultCurrency = computed<string>(() => userStore.currentUserDefaultCurrency);
const hasAnyAccount = computed<boolean>(() => accountsStore.allPlainAccounts.length > 0); const hasAnyAccount = computed<boolean>(() => accountsStore.allPlainAccounts.length > 0);
@@ -545,7 +558,7 @@ function getFilteredTransactionCategoriesDisplayContent(filterTransactionCategor
} }
function addQuery(): void { function addQuery(): void {
queries.value.push(TransactionExplorerQuery.create()); queries.value.push(TransactionExplorerQuery.create(generateRandomUUID()));
} }
function updateQueryName(query: TransactionExplorerQuery): void { function updateQueryName(query: TransactionExplorerQuery): void {
@@ -560,7 +573,7 @@ function cancelUpdateQueryName(): void {
} }
function duplicateQuery(query: TransactionExplorerQuery): void { function duplicateQuery(query: TransactionExplorerQuery): void {
queries.value.push(query.clone()); queries.value.push(query.clone(generateRandomUUID()));
} }
function removeQuery(queryIndex: number): void { function removeQuery(queryIndex: number): void {
@@ -583,33 +596,21 @@ function removeQuery(queryIndex: number): void {
showExpression.value = newShowExpression; showExpression.value = newShowExpression;
if (queries.value.length < 1) { if (queries.value.length < 1) {
queries.value.push(TransactionExplorerQuery.create()); queries.value.push(TransactionExplorerQuery.create(generateRandomUUID()));
} }
} }
function clearAllQueries(): void { function clearAllQueries(): void {
queries.value.length = 0; queries.value.length = 0;
queries.value.push(TransactionExplorerQuery.create()); queries.value.push(TransactionExplorerQuery.create(generateRandomUUID()));
} }
function addCondition(queryIndex: number): void { function addCondition(query: TransactionExplorerQuery): void {
const query = queries.value[queryIndex];
if (!query) {
return;
}
const newCondition = query.addNewCondition(TransactionExplorerConditionField.TransactionType, query.conditions.length < 1); const newCondition = query.addNewCondition(TransactionExplorerConditionField.TransactionType, query.conditions.length < 1);
query.conditions.push(newCondition); query.conditions.push(newCondition);
} }
function removeCondition(queryIndex: number, conditionIndex: number): void { function removeCondition(query: TransactionExplorerQuery, conditionIndex: number): void {
const query = queries.value[queryIndex];
if (!query) {
return;
}
query.conditions.splice(conditionIndex, 1); query.conditions.splice(conditionIndex, 1);
if (conditionIndex === 0 && query.conditions.length > 0) { if (conditionIndex === 0 && query.conditions.length > 0) {
@@ -621,17 +622,11 @@ function removeCondition(queryIndex: number, conditionIndex: number): void {
} }
} }
function updateConditionField(queryIndex: number, conditionIndex: number, newField: TransactionExplorerConditionField | undefined): void { function updateConditionField(query: TransactionExplorerQuery, conditionIndex: number, newField: TransactionExplorerConditionField | undefined): void {
if (!newField) { if (!newField) {
return; return;
} }
const query = queries.value[queryIndex];
if (!query) {
return;
}
const oldConditionWithRelation = query.conditions[conditionIndex]; const oldConditionWithRelation = query.conditions[conditionIndex];
if (!oldConditionWithRelation) { if (!oldConditionWithRelation) {
@@ -675,10 +670,8 @@ function updateTransactionCategories(changed: boolean, selectedCategoryIds?: str
showFilterTransactionCategoriesDialog.value = false; showFilterTransactionCategoriesDialog.value = false;
} }
function getExpression(queryIndex: number): string { function getExpression(query: TransactionExplorerQuery, queryIndex: number): string {
const query = queries.value[queryIndex]; if (!query.conditions || query.conditions.length < 1) {
if (!query || !query.conditions || query.conditions.length < 1) {
return ''; return '';
} }
@@ -692,7 +685,7 @@ function getExpression(queryIndex: number): string {
} }
if (queries.value.length === 0) { if (queries.value.length === 0) {
queries.value.push(TransactionExplorerQuery.create()); queries.value.push(TransactionExplorerQuery.create(generateRandomUUID()));
} }
</script> </script>