Add dialog showing diff of changes to save

Add a save dialog at the bottom of the screen that is present whenever
there are unsaved changes.  This dialog provides a diff between the
client and server state so that the user can easily confirm the changes
they are about to make are the correct changes before applying them to
the server.
This commit is contained in:
Hornwitser 2025-06-30 15:43:15 +02:00
parent 60f898e986
commit 1d2edf7535
12 changed files with 630 additions and 7 deletions

48
components/DiffEntry.vue Normal file
View file

@ -0,0 +1,48 @@
<template>
<div
v-if="entries.length"
class="diffEntry"
>
<div class="title">
{{ title }}:
</div>
<div
v-for="[type, text], index in entries"
:key="index"
:class="type"
>
{{ text }}
</div>
</div>
</template>
<script lang="ts" setup>
defineProps<{
title: string,
entries: (["removed" | "added", string][]);
}>();
</script>
<style scoped>
.diffEntry {
display: grid;
grid-template-columns: 5rem 1fr;
column-gap: 1rem;
}
.removed {
grid-column: 2 / 2;
color: color-mix(in srgb, CanvasText, red 40%);
}
.removed::before {
content: "- ";
font-family: monospace;
}
.added {
grid-column: 2 / 2;
color: color-mix(in srgb, CanvasText, green 40%);
}
.added::before {
content: "+ ";
font-family: monospace;
}
</style>

View file

@ -0,0 +1,43 @@
<template>
<DiffEntry
v-if='state !== "modified" || after !== before'
:title
:entries
/>
</template>
<script lang="ts" setup>
import type { ApiEntity } from '~/shared/types/api';
import type { Id } from '~/shared/types/common';
import type { ClientEntity } from '~/utils/client-entity';
const props = defineProps<{
title: string,
before: Id | undefined,
after: Id | undefined,
entities: ClientMap<ClientEntity<ApiEntity> & { name?: string }>,
state: "deleted" | "created" | "modified",
}>();
function getName(id?: Id) {
if (id === undefined)
return undefined;
const entity = props.entities.get(id);
if (entity?.name !== undefined)
return entity.name;
return String(id);
}
const entries = computed(() => {
const result: ["removed" | "added", string][] = [];
const beforeName = getName(props.before);
if (props.state !== "created" && beforeName) {
result.push(["removed", beforeName]);
}
const afterName = getName(props.after);
if (props.state !== "deleted" && afterName) {
result.push(["added", afterName]);
}
return result;
})
</script>

View file

@ -0,0 +1,56 @@
<template>
<DiffEntry
:title
:entries
/>
</template>
<script lang="ts" setup>
import type { ApiEntity } from '~/shared/types/api';
import type { Id } from '~/shared/types/common';
import type { ClientEntity } from '~/utils/client-entity';
const props = defineProps<{
title: string,
before: Set<Id>,
after: Set<Id>,
entities: ClientMap<ClientEntity<ApiEntity> & { name?: string }>,
state: "deleted" | "created" | "modified",
}>();
function getName(id?: Id) {
if (id === undefined)
return undefined;
const entity = props.entities.get(id);
if (entity?.name !== undefined)
return entity.name;
return String(id);
}
const added = computed(() => {
if (props.state === "deleted")
return new Set<number>();
if (props.state === "created")
return props.after;
return toRaw(props.after).difference(props.before)
});
const removed = computed(() => {
if (props.state === "deleted")
return props.before;
if (props.state === "created")
return new Set<number>();
return toRaw(props.before).difference(props.after);
});
const entries = computed((): ["added" | "removed", string][] => {
return [
...[...removed.value]
.map(getName)
.filter(name => name !== undefined)
.map((name) => ["removed", name] as ["removed", string]),
...[...added.value]
.map(getName)
.filter(name => name !== undefined)
.map((name) => ["added", name] as ["added", string]),
];
});
</script>

View file

@ -0,0 +1,36 @@
<template>
<DiffEntry
:title
:entries
/>
</template>
<script lang="ts" setup>
const props = defineProps<{
title: string,
before: string,
after: string,
state: "deleted" | "created" | "modified",
}>();
const entries = computed((): ["added" | "removed", string][] => {
if (props.state === "created") {
return props.after ? [["added", props.after]] : [];
}
if (props.state === "deleted") {
return props.before ? [["removed", props.before]] : [];
}
if (props.state === "modified") {
if (props.after === props.before)
return [];
if (!props.after)
return [["removed", props.before]];
if (!props.before)
return [["added", props.after]];
return [
["removed", props.before],
["added", props.after],
];
}
return [];
});
</script>

View file

@ -0,0 +1,64 @@
<template>
<div>
<h2>Changes</h2>
<section v-if="locations.length">
<h3>Locations</h3>
<DiffScheduleLocation
v-for="location in locations"
:key="location.id"
:location
/>
</section>
<section v-if="events.length">
<h3>Events</h3>
<DiffScheduleEvent
v-for="event in events"
:key="event.id"
:event
:schedule
/>
</section>
<section v-if="roles.length">
<h3>Roles</h3>
<DiffScheduleRole
v-for="role in roles"
:key="role.id"
:role
/>
</section>
<section v-if="shifts.length">
<h3>Shifts</h3>
<DiffScheduleShift
v-for="shift in shifts"
:key="shift.id"
:shift
:schedule
/>
</section>
</div>
</template>
<script lang="ts" setup>
const props = defineProps<{
schedule: ClientSchedule
}>();
const locations = computed(() => {
return [...props.schedule.locations.values()].filter(location => location.isModified());
});
const events = computed(() => {
return [...props.schedule.events.values()].filter(event => event.isModified());
});
const roles = computed(() => {
return [...props.schedule.roles.values()].filter(role => role.isModified());
});
const shifts = computed(() => {
return [...props.schedule.shifts.values()].filter(shift => shift.isModified());
});
</script>
<style>
h2 {
margin-block-start: 0.2rem;
}
</style>

View file

@ -0,0 +1,95 @@
<template>
<div>
<h4>{{ state }} {{ event.name }}</h4>
<DiffFieldString
title="Name"
:before="event.serverName"
:after="event.name"
:state
/>
<DiffFieldString
title="Public"
:before='event.serverCrew ? "No" : "Yes"'
:after='event.crew ? "No" : "Yes"'
:state
/>
<DiffFieldString
title="Description"
:before="event.serverDescription"
:after="event.description"
:state
/>
<DiffScheduleEventSlot
v-for="[state, slot] in slots"
:key="slot.id"
:slot
:schedule
:state
/>
</div>
</template>
<script lang="ts" setup>
const props = defineProps<{
event: ClientScheduleEvent,
schedule: ClientSchedule,
}>();
const state = computed(() => {
if (props.event.deleted) return "deleted";
if (props.event.isNew()) return "created";
return "modified";
});
const slots = computed((): [
"deleted" | "created" | "modified", ClientScheduleEventSlot
][] => {
const afterIds = props.event.slotIds;
const beforeIds = props.event.serverSlotIds;
if (state.value === "deleted") {
return (
[...beforeIds]
.map(id => props.schedule.eventSlots.get(id))
.filter(slot => slot !== undefined)
.map(slot => ["deleted", slot] as ["deleted", ClientScheduleEventSlot])
);
}
if (state.value === "created") {
return (
[...afterIds]
.map(id => props.schedule.eventSlots.get(id))
.filter(slot => slot !== undefined)
.map(slot => ["created", slot] as ["created", ClientScheduleEventSlot])
);
}
const added = [...toRaw(afterIds).difference(beforeIds)]
.map(id => props.schedule.eventSlots.get(id))
.filter(slot => slot !== undefined)
.filter(slot => !slot.deleted)
.map(slot => ["created", slot] as ["created", ClientScheduleEventSlot])
;
const removed = [...new Set(
[...toRaw(beforeIds).difference(afterIds)]
.map(id => props.schedule.eventSlots.get(id))
.filter(slot => slot !== undefined)
).union(
new Set(
[...afterIds]
.map(id => props.schedule.eventSlots.get(id))
.filter(slot => slot !== undefined)
.filter(slot => slot.deleted)
)
)]
.map(slot => ["deleted", slot] as ["deleted", ClientScheduleEventSlot])
;
const modified = [...toRaw(afterIds).intersection(beforeIds)]
.map(id => props.schedule.eventSlots.get(id))
.filter(slot => slot !== undefined)
.filter(slot => slot.isModified() && !slot.deleted)
.map(slot => ["modified", slot] as ["modified", ClientScheduleEventSlot])
;
return [
...added,
...removed,
...modified,
];
});
</script>

View file

@ -0,0 +1,41 @@
<template>
<div>
<h5>{{ state }} slot at {{ slot.start.toFormat("yyyy-LL-dd HH:mm") }}</h5>
<DiffFieldString
title="Start"
:before='slot.serverStart.toFormat("yyyy-LL-dd HH:mm")'
:after='slot.start.toFormat("yyyy-LL-dd HH:mm")'
:state
/>
<DiffFieldString
title="End"
:before='slot.serverEnd.toFormat("yyyy-LL-dd HH:mm")'
:after='slot.end.toFormat("yyyy-LL-dd HH:mm")'
:state
/>
<DiffFieldSetEntityId
title="Locations"
:before="slot.serverLocationIds"
:after="slot.locationIds"
:entities="schedule.locations"
:state
/>
<DiffFieldSetEntityId
title="Assigned"
:before="slot.serverAssigned"
:after="slot.assigned"
:entities="usersStore.users"
:state
/>
</div>
</template>
<script lang="ts" setup>
defineProps<{
schedule: ClientSchedule,
slot: ClientScheduleEventSlot,
state: "deleted" | "created" | "modified",
}>();
const usersStore = useUsersStore();
</script>

View file

@ -0,0 +1,28 @@
<template>
<div>
<h4>{{ state }} {{ location.name }}</h4>
<DiffFieldString
title="Name"
:before="location.serverName"
:after="location.name"
:state
/>
<DiffFieldString
title="Description"
:before="location.serverDescription"
:after="location.description"
:state
/>
</div>
</template>
<script lang="ts" setup>
const props = defineProps<{
location: ClientScheduleLocation,
}>();
const state = computed(() => {
if (props.location.deleted) return "deleted";
if (props.location.isNew()) return "created";
return "modified";
});
</script>

View file

@ -0,0 +1,28 @@
<template>
<div>
<h4>{{ state }} {{ role.name }}</h4>
<DiffFieldString
title="Name"
:before="role.serverName"
:after="role.name"
:state
/>
<DiffFieldString
title="Description"
:before="role.serverDescription"
:after="role.description"
:state
/>
</div>
</template>
<script lang="ts" setup>
const props = defineProps<{
role: ClientScheduleRole,
}>();
const state = computed(() => {
if (props.role.deleted) return "deleted";
if (props.role.isNew()) return "created";
return "modified";
});
</script>

View file

@ -0,0 +1,96 @@
<template>
<div>
<h4>{{ state }} {{ shift.name }}</h4>
<DiffFieldString
title="Name"
:before="shift.serverName"
:after="shift.name"
:state
/>
<DiffFieldEntityId
title="Role"
:before="shift.serverRoleId"
:after="shift.roleId"
:entities="schedule.roles"
:state
/>
<DiffFieldString
title="Description"
:before="shift.serverDescription"
:after="shift.description"
:state
/>
<DiffScheduleShiftSlot
v-for="[state, slot] in slots"
:key="slot.id"
:slot
:schedule
:state
/>
</div>
</template>
<script lang="ts" setup>
const props = defineProps<{
shift: ClientScheduleShift,
schedule: ClientSchedule,
}>();
const state = computed(() => {
if (props.shift.deleted) return "deleted";
if (props.shift.isNew()) return "created";
return "modified";
});
const slots = computed((): [
"deleted" | "created" | "modified", ClientScheduleShiftSlot
][] => {
const afterIds = props.shift.slotIds;
const beforeIds = props.shift.serverSlotIds;
if (state.value === "deleted") {
return (
[...beforeIds]
.map(id => props.schedule.shiftSlots.get(id))
.filter(slot => slot !== undefined)
.map(slot => ["deleted", slot] as ["deleted", ClientScheduleShiftSlot])
);
}
if (state.value === "created") {
return (
[...afterIds]
.map(id => props.schedule.shiftSlots.get(id))
.filter(slot => slot !== undefined)
.map(slot => ["created", slot] as ["created", ClientScheduleShiftSlot])
);
}
const added = [...toRaw(afterIds).difference(beforeIds)]
.map(id => props.schedule.shiftSlots.get(id))
.filter(slot => slot !== undefined)
.filter(slot => !slot.deleted)
.map(slot => ["created", slot] as ["created", ClientScheduleShiftSlot])
;
const removed = [...new Set(
[...toRaw(beforeIds).difference(afterIds)]
.map(id => props.schedule.shiftSlots.get(id))
.filter(slot => slot !== undefined)
).union(
new Set(
[...afterIds]
.map(id => props.schedule.shiftSlots.get(id))
.filter(slot => slot !== undefined)
.filter(slot => slot.deleted)
)
)]
.map(slot => ["deleted", slot] as ["deleted", ClientScheduleShiftSlot])
;
const modified = [...toRaw(afterIds).intersection(beforeIds)]
.map(id => props.schedule.shiftSlots.get(id))
.filter(slot => slot !== undefined)
.filter(slot => slot.isModified() && !slot.deleted)
.map(slot => ["modified", slot] as ["modified", ClientScheduleShiftSlot])
;
return [
...added,
...removed,
...modified,
];
});
</script>

View file

@ -0,0 +1,34 @@
<template>
<div>
<h5>{{ state }} slot at {{ slot.start.toFormat("yyyy-LL-dd HH:mm") }}</h5>
<DiffFieldString
title="Start"
:before='slot.serverStart.toFormat("yyyy-LL-dd HH:mm")'
:after='slot.start.toFormat("yyyy-LL-dd HH:mm")'
:state
/>
<DiffFieldString
title="End"
:before='slot.serverEnd.toFormat("yyyy-LL-dd HH:mm")'
:after='slot.end.toFormat("yyyy-LL-dd HH:mm")'
:state
/>
<DiffFieldSetEntityId
title="Assigned"
:before="slot.serverAssigned"
:after="slot.assigned"
:entities="usersStore.users"
:state
/>
</div>
</template>
<script lang="ts" setup>
defineProps<{
schedule: ClientSchedule,
slot: ClientScheduleShiftSlot,
state: "deleted" | "created" | "modified",
}>();
const usersStore = useUsersStore();
</script>

View file

@ -78,13 +78,31 @@
<TableScheduleShiftSlots :edit="true" :roleId="roleFilter" :shiftSlotFilter />
</template>
</Tabs>
<p v-if="schedule.isModified()">
Changes are not saved yet.
<button
type="button"
@click="saveChanges"
>Save Changes</button>
</p>
<section
class="saveDialog"
:class="{ visible: schedule.isModified() }"
>
<DiffSchedule
v-if="reviewOpen"
:schedule
/>
<hr v-if="reviewOpen">
<section class="saveLine">
<span>Changes are not saved yet.</span>
<button
type="button"
v-if="reviewOpen"
@click="saveChanges"
>Save changes</button>
<button
type="button"
class="review"
@click="reviewOpen = !reviewOpen"
>
{{ reviewOpen ? "Close" : "Review" }}
</button>
</section>
</section>
</main>
</template>
@ -156,6 +174,8 @@ const roleFilter = computed({
}),
});
const reviewOpen = ref(false);
async function saveChanges() {
try {
await $fetch("/api/schedule", {
@ -168,3 +188,37 @@ async function saveChanges() {
}
}
</script>
<style scoped>
.saveDialog {
visibility: hidden;
max-height: calc(100dvh - 2rem);
overflow-y: auto;
max-width: 30rem;
padding: 0.2rem 0.4rem;
border: 1px solid CanvasText;
background-color: color-mix(in srgb, Canvas, CanvasText 10%);
margin-block-start: 1rem;
margin-block-end: 0.5rem;
margin-inline: auto;
}
.saveDialog.visible {
visibility: visible;
position: sticky;
bottom: 0.5rem;
}
.saveDialog hr {
margin-block-start: 0.4rem;
margin-block-end: 0.2rem;
}
.saveLine {
display: flex;
gap: 0.5rem;
}
.saveLine span {
margin-inline-end: auto;
}
button.review {
width: 4.5rem;
}
</style>