fixed unsaved changes

This commit is contained in:
2025-06-20 11:52:45 +02:00
parent b5114ac6fb
commit 3c954ac715
8 changed files with 223 additions and 167 deletions

View File

@ -0,0 +1,24 @@
import type { SelectHaexPasswordsGroups } from '~~/src-tauri/database/schemas/vault'
export const usePasswordGroup = () => {
const areItemsEqual = (
groupA: unknown | unknown[] | null,
groupB: unknown | unknown[] | null,
) => {
if (groupA === null && groupB === null) return true
if (Array.isArray(groupA) && Array.isArray(groupB)) {
console.log('compare object arrays', groupA, groupB)
if (groupA.length === groupB.length) return true
return groupA.some((group, index) => {
return areObjectsEqual(group, groupA[index])
})
}
return areObjectsEqual(groupA, groupB)
}
return {
areItemsEqual,
}
}

View File

@ -52,7 +52,7 @@
class="btn-primary btn-outline flex-1-1 min-w-40" class="btn-primary btn-outline flex-1-1 min-w-40"
> >
<Icon name="mdi:plus" /> <Icon name="mdi:plus" />
<p class="hidden sm:inline-flex">{{ t('add') }}</p> <p class="hidden sm:inline-block">{{ t('add') }}</p>
</UiButton> </UiButton>
</div> </div>
</div> </div>

View File

@ -77,7 +77,7 @@ watch(
try { try {
const foundGroup = await readGroupAsync(currentGroupId.value) const foundGroup = await readGroupAsync(currentGroupId.value)
if (foundGroup) { if (foundGroup) {
original.value = JSON.stringify(foundGroup) original.value = JSON.parse(JSON.stringify(foundGroup))
group.value = foundGroup group.value = foundGroup
} }
} catch (error) { } catch (error) {
@ -86,22 +86,11 @@ watch(
}, },
{ immediate: true }, { immediate: true },
) )
/* watch(
currentGroup,
(n, o) => {
console.log('currentGroup', currentGroup.value, n, o)
original.value = JSON.stringify(currentGroup.value)
group.value = JSON.parse(original.value)
ignoreChanges.value = false
},
{ immediate: true },
) */
const read_only = ref(false) const read_only = ref(false)
const hasChanges = computed( const { areItemsEqual } = usePasswordGroup()
() => JSON.stringify(group.value) !== original.value, const hasChanges = computed(() => !!!areItemsEqual(group.value, original.value))
)
const onClose = () => { const onClose = () => {
if (showConfirmDeleteDialog.value || showUnsavedChangesDialog.value) return if (showConfirmDeleteDialog.value || showUnsavedChangesDialog.value) return
@ -121,7 +110,7 @@ const onSaveAsync = async () => {
ignoreChanges.value = true ignoreChanges.value = true
await updateAsync(group.value) await updateAsync(group.value)
await syncGroupItemsAsync(group.value.id) await syncGroupItemsAsync()
add({ type: 'success', text: t('change.success') }) add({ type: 'success', text: t('change.success') })
onClose() onClose()
} catch (error) { } catch (error) {
@ -141,7 +130,7 @@ const onDeleteAsync = async () => {
try { try {
const parentId = group.value.parentId const parentId = group.value.parentId
await deleteGroupAsync(group.value.id, inTrashGroup.value) await deleteGroupAsync(group.value.id, inTrashGroup.value)
await syncGroupItemsAsync(parentId) await syncGroupItemsAsync()
showConfirmDeleteDialog.value = false showConfirmDeleteDialog.value = false
ignoreChanges.value = true ignoreChanges.value = true
await navigateTo( await navigateTo(

View File

@ -194,7 +194,7 @@ const onPasteAsync = async () => {
[...selectedGroupItems.value], [...selectedGroupItems.value],
currentGroupId.value, currentGroupId.value,
) )
await syncGroupItemsAsync(currentGroupId.value) await syncGroupItemsAsync()
selectedGroupItems.value = [] selectedGroupItems.value = []
selectedItems.value.clear() selectedItems.value.clear()
} catch (error) { } catch (error) {
@ -235,7 +235,7 @@ const onDeleteAsync = async () => {
} }
} }
selectedItems.value.clear() selectedItems.value.clear()
await syncGroupItemsAsync(currentGroupId.value) await syncGroupItemsAsync()
} }
const keys = useMagicKeys() const keys = useMagicKeys()
watch(keys.delete, async () => { watch(keys.delete, async () => {

View File

@ -1,5 +1,11 @@
<template> <template>
<div> <div>
<div class="flex flex-col">
<p>
{{ item.originalDetails }}
</p>
{{ item.details }}
</div>
<HaexPassItem <HaexPassItem
:history="item.history" :history="item.history"
:read_only :read_only
@ -11,83 +17,8 @@
v-model:key-values="item.keyValues" v-model:key-values="item.keyValues"
/> />
<!-- <div
class="fixed bottom-4 flex justify-between transition-all pointer-events-none right-0 sm:items-center items-end"
:class="[isVisible ? 'left-15 ' : 'left-0']"
>
<div class="flex items-center justify-center flex-1">
<UiTooltip :tooltip="t('abort')">
<UiButton
class="btn-accent btn-square"
@click="onClose"
>
<Icon name="mdi:close" />
</UiButton>
</UiTooltip>
</div>
<UiTooltip
v-show="read_only && !hasChanges"
:tooltip="t('edit')"
>
<UiButton
class="btn-xl btn-square btn-primary"
@click="read_only = false"
>
<Icon
name="mdi:pencil-outline"
class="size-11 shrink-0"
/>
</UiButton>
</UiTooltip>
<UiTooltip
v-show="!read_only && !hasChanges"
:tooltip="t('noEdit')"
>
<UiButton
class="btn-xl btn-square btn-primary"
@click="read_only = true"
>
<Icon
name="mdi:pencil-off-outline"
class="size-11 shrink-0"
/>
</UiButton>
</UiTooltip>
<UiTooltip
:tooltip="t('save')"
v-show="!read_only && hasChanges"
>
<UiButton
class="btn-xl btn-square btn-primary motion-duration-2000"
:class="{ 'motion-preset-pulse-sm': hasChanges }"
@click="onUpdateAsync"
>
<Icon
name="mdi:content-save-outline"
class="size-11 shrink-0"
/>
</UiButton>
</UiTooltip>
<div class="flex items-center justify-center flex-1">
<UiTooltip :tooltip="t('delete')">
<UiButton
class="btn-square btn-error"
@click="showConfirmDeleteDialog = true"
>
<Icon
name="mdi:trash-outline"
class="shrink-0"
/>
</UiButton>
</UiTooltip>
</div>
</div> -->
<HaexPassMenuBottom <HaexPassMenuBottom
:has-changes
:show-edit-button="read_only && !hasChanges" :show-edit-button="read_only && !hasChanges"
:show-readonly-button="!read_only && !hasChanges" :show-readonly-button="!read_only && !hasChanges"
:show-save-button="!read_only && hasChanges" :show-save-button="!read_only && hasChanges"
@ -119,6 +50,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { usePasswordGroup } from '~/components/haex/pass/group/composables'
import type { import type {
SelectHaexPasswordsItemDetails, SelectHaexPasswordsItemDetails,
SelectHaexPasswordsItemHistory, SelectHaexPasswordsItemHistory,
@ -145,8 +77,8 @@ const item = reactive<{
keyValues: SelectHaexPasswordsItemKeyValues[] keyValues: SelectHaexPasswordsItemKeyValues[]
keyValuesAdd: SelectHaexPasswordsItemKeyValues[] keyValuesAdd: SelectHaexPasswordsItemKeyValues[]
keyValuesDelete: SelectHaexPasswordsItemKeyValues[] keyValuesDelete: SelectHaexPasswordsItemKeyValues[]
originalDetails: string | null originalDetails: SelectHaexPasswordsItemDetails | null
originalKeyValues: string | null originalKeyValues: SelectHaexPasswordsItemKeyValues[] | null
}>({ }>({
details: { details: {
id: '', id: '',
@ -164,7 +96,18 @@ const item = reactive<{
history: [], history: [],
keyValuesAdd: [], keyValuesAdd: [],
keyValuesDelete: [], keyValuesDelete: [],
originalDetails: null, originalDetails: {
id: '',
createdAt: null,
icon: null,
note: null,
password: null,
tags: null,
title: null,
updateAt: null,
url: null,
username: null,
},
originalKeyValues: null, originalKeyValues: null,
}) })
@ -179,8 +122,12 @@ watch(
item.history = JSON.parse(JSON.stringify(currentItem.value?.history)) item.history = JSON.parse(JSON.stringify(currentItem.value?.history))
item.keyValuesAdd = [] item.keyValuesAdd = []
item.keyValuesDelete = [] item.keyValuesDelete = []
item.originalDetails = JSON.stringify(currentItem.value?.details) item.originalDetails = JSON.parse(
item.originalKeyValues = JSON.stringify(currentItem.value?.keyValues) JSON.stringify(currentItem.value?.details),
)
item.originalKeyValues = JSON.parse(
JSON.stringify(currentItem.value?.keyValues),
)
}, },
{ immediate: true }, { immediate: true },
) )
@ -201,7 +148,7 @@ const onUpdateAsync = async () => {
keyValuesDelete: item.keyValuesDelete, keyValuesDelete: item.keyValuesDelete,
}) })
if (newId) add({ type: 'success', text: t('success.update') }) if (newId) add({ type: 'success', text: t('success.update') })
syncGroupItemsAsync(currentGroupId.value) syncGroupItemsAsync()
ignoreChanges.value = true ignoreChanges.value = true
onClose() onClose()
} catch (error) { } catch (error) {
@ -224,7 +171,7 @@ const deleteItemAsync = async () => {
await deleteAsync(item.details.id, inTrashGroup.value) await deleteAsync(item.details.id, inTrashGroup.value)
showConfirmDeleteDialog.value = false showConfirmDeleteDialog.value = false
add({ type: 'success', text: t('success.delete') }) add({ type: 'success', text: t('success.delete') })
await syncGroupItemsAsync(currentGroupId.value) await syncGroupItemsAsync()
onClose() onClose()
} catch (errro) { } catch (errro) {
add({ add({
@ -234,11 +181,12 @@ const deleteItemAsync = async () => {
} }
} }
const { areItemsEqual } = usePasswordGroup()
const hasChanges = computed( const hasChanges = computed(
() => () =>
!!( !!(
item.originalDetails !== JSON.stringify(item.details) || !areItemsEqual(item.originalDetails, item.details) ||
item.originalKeyValues !== JSON.stringify(item.keyValues) || !areItemsEqual(item.originalKeyValues, item.keyValues) ||
item.keyValuesAdd.length || item.keyValuesAdd.length ||
item.keyValuesDelete.length item.keyValuesDelete.length
), ),

View File

@ -1,5 +1,6 @@
<template> <template>
<div> <div>
{{ currentGroup?.id }} {{ currentGroupId }}
<HaexPassItem <HaexPassItem
:default-icon="currentGroup?.icon" :default-icon="currentGroup?.icon"
:history="item.history" :history="item.history"
@ -10,6 +11,7 @@
/> />
<HaexPassMenuBottom <HaexPassMenuBottom
:has-changes
@close="onClose" @close="onClose"
@save="onCreateAsync" @save="onCreateAsync"
show-close-button show-close-button
@ -17,33 +19,13 @@
> >
</HaexPassMenuBottom> </HaexPassMenuBottom>
<!-- <div <HaexPassDialogUnsavedChanges
class="fixed bottom-4 flex justify-between transition-all pointer-events-none right-0 sm:items-center items-end" :has-changes="hasChanges"
:class="[isVisible ? 'left-15 ' : 'left-0']" v-model:ignore-changes="ignoreChanges"
> @abort="showUnsavedChangesDialog = false"
<div class="flex items-center justify-center flex-1"> @confirm="onConfirmIgnoreChanges"
<UiTooltip :tooltip="t('abort')"> v-model:open="showUnsavedChangesDialog"
<UiButton />
class="btn-error btn-square"
@click="onClose"
>
<Icon name="mdi:close" />
</UiButton>
</UiTooltip>
</div>
<UiTooltip :tooltip="t('create')">
<UiButton
class="btn-xl btn-square btn-primary"
@click="onCreateAsync"
>
<Icon
name="mdi:content-save-outline"
class="size-11 shrink-0"
/>
</UiButton>
</UiTooltip>
<div class="flex items-center justify-center flex-1"></div>
</div> -->
</div> </div>
</template> </template>
@ -64,17 +46,14 @@ defineProps({
withCopyButton: Boolean, withCopyButton: Boolean,
}) })
const { isVisible } = storeToRefs(useSidebarStore())
const { t } = useI18n() const { t } = useI18n()
const item = reactive<{ const item = reactive<{
details: SelectHaexPasswordsItemDetails details: SelectHaexPasswordsItemDetails
history: SelectHaexPasswordsItemHistory[] history: SelectHaexPasswordsItemHistory[]
keyValuesAdd: SelectHaexPasswordsItemKeyValues[] keyValuesAdd: SelectHaexPasswordsItemKeyValues[]
keyValuesDelete: SelectHaexPasswordsItemKeyValues[] originalDetails: SelectHaexPasswordsItemDetails
originalDetails: string | null originalKeyValuesAdd: []
originalKeyValues: string | null
}>({ }>({
details: { details: {
id: '', id: '',
@ -90,13 +69,23 @@ const item = reactive<{
}, },
history: [], history: [],
keyValuesAdd: [], keyValuesAdd: [],
keyValuesDelete: [], originalDetails: {
originalDetails: null, id: '',
originalKeyValues: null, createdAt: null,
icon: null,
note: null,
password: null,
tags: null,
title: null,
updateAt: null,
url: null,
username: null,
},
originalKeyValuesAdd: [],
}) })
const { add } = useSnackbar() const { add } = useSnackbar()
const { currentGroup } = storeToRefs(usePasswordGroupStore()) const { currentGroup, currentGroupId } = storeToRefs(usePasswordGroupStore())
const { syncGroupItemsAsync } = usePasswordGroupStore() const { syncGroupItemsAsync } = usePasswordGroupStore()
const { addAsync } = usePasswordItemStore() const { addAsync } = usePasswordItemStore()
@ -107,9 +96,11 @@ const onCreateAsync = async () => {
item.keyValuesAdd, item.keyValuesAdd,
currentGroup.value, currentGroup.value,
) )
if (newId) { if (newId) {
ignoreChanges.value = true
add({ type: 'success', text: t('success') }) add({ type: 'success', text: t('success') })
syncGroupItemsAsync(currentGroup.value?.id) await syncGroupItemsAsync()
onClose() onClose()
} }
} catch (error) { } catch (error) {
@ -117,7 +108,32 @@ const onCreateAsync = async () => {
} }
} }
const onClose = () => useRouter().back() const ignoreChanges = ref(false)
const onClose = () => {
if (showUnsavedChangesDialog.value) return
if (hasChanges.value && !ignoreChanges.value)
return (showUnsavedChangesDialog.value = true)
useRouter().back()
}
const { areItemsEqual } = usePasswordGroup()
const hasChanges = computed(
() =>
!!(
!areItemsEqual(item.originalDetails, item.details) ||
item.keyValuesAdd.length
),
)
const showUnsavedChangesDialog = ref(false)
const onConfirmIgnoreChanges = () => {
showUnsavedChangesDialog.value = false
ignoreChanges.value = true
onClose()
}
</script> </script>
<i18n lang="yaml"> <i18n lang="yaml">

View File

@ -6,7 +6,6 @@ import {
type InsertHaexPasswordsGroups, type InsertHaexPasswordsGroups,
type SelectHaexPasswordsGroupItems, type SelectHaexPasswordsGroupItems,
type SelectHaexPasswordsGroups, type SelectHaexPasswordsGroups,
type SelectHaexPasswordsItemDetails,
} from '~~/src-tauri/database/schemas/vault' } from '~~/src-tauri/database/schemas/vault'
export const trashId = 'trash' export const trashId = 'trash'
@ -28,14 +27,6 @@ export const usePasswordGroupStore = defineStore('passwordGroupStore', () => {
currentGroupId.value ? readGroupAsync(currentGroupId.value) : null, currentGroupId.value ? readGroupAsync(currentGroupId.value) : null,
) )
/* const currentGroupItems = reactive<{
items: SelectHaexPasswordsItemDetails[]
groups: SelectHaexPasswordsGroups[]
}>({
items: [],
groups: [],
}) */
const selectedGroupItems = ref<IPasswordMenuItem[]>() const selectedGroupItems = ref<IPasswordMenuItem[]>()
const breadCrumbs = computed(() => getParentChain(currentGroupId.value)) const breadCrumbs = computed(() => getParentChain(currentGroupId.value))
@ -55,15 +46,14 @@ export const usePasswordGroupStore = defineStore('passwordGroupStore', () => {
return chain.reverse() return chain.reverse()
} }
const syncGroupItemsAsync = async (currentGroupId?: string | null) => { const syncGroupItemsAsync = async () => {
const { addNotificationAsync } = useNotificationStore() const { syncItemsAsync } = usePasswordItemStore()
const { readByGroupIdAsync, syncItemsAsync } = usePasswordItemStore()
groups.value = await readGroupsAsync() groups.value = await readGroupsAsync()
await syncItemsAsync() await syncItemsAsync()
currentGroup.value = groups.value?.find( /* currentGroup.value = groups.value?.find(
(group) => group.id === currentGroupId, (group) => group.id === currentGroupId,
) ) */
/* try { /* try {
currentGroupItems.groups = currentGroupItems.groups =
@ -80,7 +70,7 @@ export const usePasswordGroupStore = defineStore('passwordGroupStore', () => {
} */ } */
} }
watch(currentGroupId, () => syncGroupItemsAsync(currentGroupId.value), { watch(currentGroupId, () => syncGroupItemsAsync(), {
immediate: true, immediate: true,
}) })
@ -90,6 +80,7 @@ export const usePasswordGroupStore = defineStore('passwordGroupStore', () => {
return { return {
addGroupAsync, addGroupAsync,
areGroupsEqual,
breadCrumbs, breadCrumbs,
createTrashIfNotExistsAsync, createTrashIfNotExistsAsync,
currentGroup, currentGroup,
@ -131,13 +122,11 @@ const addGroupAsync = async (group: Partial<InsertHaexPasswordsGroups>) => {
const readGroupAsync = async (groupId: string) => { const readGroupAsync = async (groupId: string) => {
const { currentVault } = useVaultStore() const { currentVault } = useVaultStore()
const group = await currentVault.drizzle.query.haexPasswordsGroups.findFirst({
return ( where: eq(haexPasswordsGroups.id, groupId),
await currentVault.drizzle })
?.select() console.log('readGroupAsync', groupId, group)
.from(haexPasswordsGroups) return group
.where(eq(haexPasswordsGroups.id, groupId))
).at(0)
} }
const readGroupsAsync = async (filter?: { parentId?: string | null }) => { const readGroupsAsync = async (filter?: { parentId?: string | null }) => {
@ -274,8 +263,6 @@ const insertGroupItemsAsync = async (
const targetGroup = groups.find((group) => group.id === groupdId) const targetGroup = groups.find((group) => group.id === groupdId)
console.log('insertGroupItemsAsync', items, targetGroup)
for (const item of items) { for (const item of items) {
if (item.type === 'group') { if (item.type === 'group') {
const updateGroup = groups.find((group) => group.id === item.id) const updateGroup = groups.find((group) => group.id === item.id)
@ -297,7 +284,7 @@ const insertGroupItemsAsync = async (
.where(eq(haexPasswordsGroupItems.itemId, item.id)) .where(eq(haexPasswordsGroupItems.itemId, item.id))
} }
} }
return syncGroupItemsAsync(targetGroup?.id) return syncGroupItemsAsync()
} }
const createTrashIfNotExistsAsync = async () => { const createTrashIfNotExistsAsync = async () => {
@ -340,3 +327,20 @@ const deleteGroupAsync = async (groupId: string, final: boolean = false) => {
await updateAsync({ id: groupId, parentId: trashId }) await updateAsync({ id: groupId, parentId: trashId })
} }
} }
const areGroupsEqual = (
groupA: unknown | unknown[] | null,
groupB: unknown | unknown[] | null,
) => {
if (groupA === null && groupB === null) return true
if (Array.isArray(groupA) && Array.isArray(groupB)) {
console.log('compare object arrays', groupA, groupB)
if (groupA.length === groupB.length) return true
return groupA.some((group, index) => {
return areObjectsEqual(group, groupA[index])
})
}
return areObjectsEqual(groupA, groupB)
}

View File

@ -155,3 +155,78 @@ export const getContrastingTextColor = (
// Ein Wert > 186 wird oft als "hell" genug für schwarzen Text angesehen. // Ein Wert > 186 wird oft als "hell" genug für schwarzen Text angesehen.
return luminance > 186 ? 'black' : 'white' return luminance > 186 ? 'black' : 'white'
} }
/**
* Eine "Type Guard"-Funktion, die prüft, ob ein Wert ein Objekt (aber nicht null) ist.
* Wenn sie `true` zurückgibt, weiß TypeScript, dass der Wert sicher als Objekt behandelt werden kann.
* @param value Der zu prüfende Wert vom Typ `unknown`.
* @returns {boolean} `true`, wenn der Wert ein Objekt ist.
*/
export const isObject = (value: unknown): value is Record<string, unknown> => {
return typeof value === 'object' && value !== null
}
/**
* Führt einen typsicheren, tiefen Vergleich (deep comparison) von zwei Werten durch.
* Gibt `true` zurück, wenn die Werte als gleich angesehen werden.
*
* @param valueA Der erste Wert für den Vergleich.
* @param valueB Der zweite Wert für den Vergleich.
* @returns {boolean} `true`, wenn die Werte gleich sind, andernfalls `false`.
*/
export const areObjectsEqual = (valueA: unknown, valueB: unknown): boolean => {
console.log('areObjectsEqual', valueA, valueB)
// 1. Schneller Check für exakt die gleiche Referenz oder primitive Gleichheit
if (valueA === valueB) {
return true
}
// DEINE SONDERREGEL: Behandle `null` und einen leeren String `""` als gleichwertig.
const areNullAndEmptyString =
(valueA === null && valueB === '') || (valueA === '' && valueB === null)
if (areNullAndEmptyString) {
return true
}
// 2. Nutzen der Type Guard: Wenn beide Werte keine Objekte sind,
// und die vorherigen Checks fehlschlugen, sind sie ungleich.
if (!isObject(valueA) || !isObject(valueB)) {
console.log('areObjectsEqual no objects', valueA, valueB)
return false
}
// Ab hier weiß TypeScript dank der Type Guard, dass valueA und valueB Objekte sind.
// 3. Holen der Schlüssel und Vergleich der Anzahl
const keysA = Object.keys(valueA)
const keysB = Object.keys(valueB)
if (keysA.length !== keysB.length) {
console.log('areObjectsEqual length')
return false
}
// 4. Iteration über alle Schlüssel und rekursiver Vergleich der Werte
for (const key of keysA) {
// Prüfen, ob der Schlüssel auch im zweiten Objekt überhaupt existiert
if (!keysB.includes(key)) {
console.log('areObjectsEqual keys')
return false
}
// Die Werte der Schlüssel sind wieder `unknown`, daher nutzen wir Rekursion.
const nestedValueA = valueA[key]
const nestedValueB = valueB[key]
// Wenn der rekursive Aufruf für einen der Werte `false` zurückgibt,
// sind die gesamten Objekte ungleich.
if (!areObjectsEqual(nestedValueA, nestedValueB)) {
console.log('areObjectsEqual nested')
return false
}
}
// 5. Wenn die Schleife durchläuft, sind die Objekte gleich
return true
}