Bug fixes + Save status
Some checks failed
linter / quality (push) Successful in 4m14s
tests / ci (push) Failing after 12m30s

This commit is contained in:
2025-09-27 14:09:05 +02:00
parent cb242e59ba
commit b89fd67d57
10 changed files with 194 additions and 33 deletions

View File

@@ -97,6 +97,7 @@ class ResumeComponentPlacementController extends Controller
$resumeComponentPlacement->push(); $resumeComponentPlacement->push();
$resumeComponentPlacement->refresh(); $resumeComponentPlacement->refresh();
$resumeComponentPlacement->load('componentData.component', 'componentData.inputData.componentInput.dataType');
return response()->json($resumeComponentPlacement); return response()->json($resumeComponentPlacement);
} }

View File

@@ -28,7 +28,7 @@ class UpdateResumeComponentPlacementRequest extends FormRequest
'component_data.component.id' => 'required|exists:resume_components,id', 'component_data.component.id' => 'required|exists:resume_components,id',
'component_data.input_data' => 'required|array', 'component_data.input_data' => 'required|array',
'component_data.input_data.*.id' => 'required|exists:resume_component_input_data,id', 'component_data.input_data.*.id' => 'required|exists:resume_component_input_data,id',
'component_data.input_data.*.value' => 'required|string', 'component_data.input_data.*.value' => 'present',
'component_data.input_data.*.component_input.id' => 'required|exists:resume_component_inputs,id', 'component_data.input_data.*.component_input.id' => 'required|exists:resume_component_inputs,id',
]; ];
} }

View File

@@ -33,7 +33,7 @@
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"jspdf": "^3.0.1", "jspdf": "^3.0.1",
"laravel-vite-plugin": "^2.0.0", "laravel-vite-plugin": "^2.0.0",
"lucide-vue-next": "^0.468.0", "lucide-vue-next": "^0.512.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"reka-ui": "^2.2.0", "reka-ui": "^2.2.0",
"tailwind-merge": "^3.2.0", "tailwind-merge": "^3.2.0",

21
pnpm-lock.yaml generated
View File

@@ -36,8 +36,8 @@ importers:
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.0.0(vite@7.1.2(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)) version: 2.0.0(vite@7.1.2(@types/node@22.17.2)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1))
lucide-vue-next: lucide-vue-next:
specifier: ^0.468.0 specifier: ^0.512.0
version: 0.468.0(vue@3.5.18(typescript@5.9.2)) version: 0.512.0(vue@3.5.18(typescript@5.9.2))
pinia: pinia:
specifier: ^3.0.3 specifier: ^3.0.3
version: 3.0.3(typescript@5.9.2)(vue@3.5.18(typescript@5.9.2)) version: 3.0.3(typescript@5.9.2)(vue@3.5.18(typescript@5.9.2))
@@ -526,6 +526,9 @@ packages:
'@jridgewell/trace-mapping@0.3.30': '@jridgewell/trace-mapping@0.3.30':
resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -1624,8 +1627,8 @@ packages:
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-vue-next@0.468.0: lucide-vue-next@0.512.0:
resolution: {integrity: sha512-quV/6T8YB1XK0VOEnebg3Byd8Rsan5/m95cvjnuHV4vcS3qEnLAybkrSh0hk3ppavx+V7R1PjNW+mGDvcBdz4A==} resolution: {integrity: sha512-FNZSsb/0ieJ1b10B3HjGFNGPXOtVEeKqSeEW/m2Is5hy7CrkkBTrTMloTw9tQ3pitfMwvAHLg5MnhWJLULKr1w==}
peerDependencies: peerDependencies:
vue: '>=3.0.1' vue: '>=3.0.1'
@@ -2693,7 +2696,7 @@ snapshots:
'@jridgewell/source-map@0.3.11': '@jridgewell/source-map@0.3.11':
dependencies: dependencies:
'@jridgewell/gen-mapping': 0.3.13 '@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.30 '@jridgewell/trace-mapping': 0.3.31
optional: true optional: true
'@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/sourcemap-codec@1.5.5': {}
@@ -2703,6 +2706,12 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
optional: true
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
@@ -3836,7 +3845,7 @@ snapshots:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
lucide-vue-next@0.468.0(vue@3.5.18(typescript@5.9.2)): lucide-vue-next@0.512.0(vue@3.5.18(typescript@5.9.2)):
dependencies: dependencies:
vue: 3.5.18(typescript@5.9.2) vue: 3.5.18(typescript@5.9.2)

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import { useResumesStore } from '@/stores/resume';
import { computed, ref, watch } from 'vue';
import { CloudCheck, LoaderCircle, CircleAlert } from 'lucide-vue-next';
const resumeStore = useResumesStore();
const DISAPPEAR_DELAY = 5000;
const isSaving = computed(() => resumeStore.isSaving);
const error = ref<string | null>(null);
watch(() => resumeStore.savingError, (newError) => {
error.value = newError;
if (newError) {
showSaved.value = false;
}
});
const showSaved = ref(false);
let showSavedTimeout: ReturnType<typeof setTimeout>;
watch(isSaving, (newIsSaving: boolean) => {
if (!newIsSaving && !error.value) {
if (showSavedTimeout) {
clearTimeout(showSavedTimeout);
}
showSaved.value = true;
showSavedTimeout = setTimeout(() => {
showSaved.value = false;
}, DISAPPEAR_DELAY);
} else {
showSaved.value = false;
}
});
</script>
<template>
<div class="flex justify-between gap-2 items-center p-3 h-4">
<p class="text-red-400">{{ error }}</p>
<Transition mode="out-in" appear>
<CircleAlert class="w-4 h-4 text-red-500" v-if="error"/>
<LoaderCircle class="w-4 h-4 animate-spin" v-else-if="isSaving"/>
<CloudCheck class="w-4 h-4" v-else-if="showSaved" title="Sauvegardé"/>
</Transition>
</div>
</template>
<style lang="css" scoped>
.v-enter-active,
.v-leave-active {
transition: opacity 0.3s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
</style>

View File

@@ -40,7 +40,7 @@ function removeResume(deletedResume: Resume) {
if (resumeStore.resumes.length > 0) { if (resumeStore.resumes.length > 0) {
window.location.href = route("resumes.edit", resumeStore.resumes[0], false); window.location.href = route("resumes.edit", resumeStore.resumes[0], false);
} else { } else {
window.location.href = route("resumes.create"); window.location.href = route("dashboard");
} }
} }
} }

View File

@@ -28,20 +28,21 @@ async function sendChangedData(newData: ResumeInputData[]) {
clearTimeout(delayedSendTimeout); clearTimeout(delayedSendTimeout);
} }
delayedSendTimeout = setTimeout(async () => { delayedSendTimeout = setTimeout(async () => {
const { data, error } = await httpApi(route('resume-component-placements.update', newSelectedComponent), { // const { data, error } = await httpApi(route('resume-component-placements.update', newSelectedComponent), {
method: 'POST', // method: 'POST',
headers: { // headers: {
'Content-Type': 'application/json', // 'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '', // 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
'Accept': 'application/json' // 'Accept': 'application/json'
}, // },
body: JSON.stringify({ ...newSelectedComponent, _method: 'PUT' }) // body: JSON.stringify({ ...newSelectedComponent, _method: 'PUT' })
}, {immediate: true}); // }, {immediate: true});
// Handle error // // Handle error
if (error) { // if (error) {
console.error('Failed to update component placement:', error, data); // console.error('Failed to update component placement:', error, data);
return; // return;
} // }
resumeStore.modifyCurrentSelectedResumePlacementToApi(newSelectedComponent!);
}, SEND_CHANGED_DATA_DELAY); }, SEND_CHANGED_DATA_DELAY);

View File

@@ -5,6 +5,7 @@ import ComponentsSelectionList from './ComponentsSelectionList.vue';
import { computed } from 'vue'; import { computed } from 'vue';
import { useResumesStore } from '@/stores/resume'; import { useResumesStore } from '@/stores/resume';
import { useShowComponentSelectionStore } from '@/stores/ui'; import { useShowComponentSelectionStore } from '@/stores/ui';
import SaveStatusIcon from '../SaveStatusIcon.vue';
const resumeStore = useResumesStore(); const resumeStore = useResumesStore();
const selectedComponent = resumeStore.currentSelectedResumePlacement; const selectedComponent = resumeStore.currentSelectedResumePlacement;
@@ -14,7 +15,8 @@ const showComponentSelection = computed<boolean>(() => showComponentSelectionSto
</script> </script>
<template> <template>
<div class="flex h-full flex-1 gap-4 rounded-xl p-4 overflow-x-auto max-w-[25%] bg-accent relative"> <div class="flex flex-col h-full flex-1 gap-4 rounded-xl p-4 overflow-x-auto max-w-[25%] bg-accent relative">
<SaveStatusIcon />
<Transition mode="out-in" appear> <Transition mode="out-in" appear>
<ResumeComponentEdit v-if="selectedComponent != null" :key="selectedComponent ? selectedComponent.id : 'form'" /> <ResumeComponentEdit v-if="selectedComponent != null" :key="selectedComponent ? selectedComponent.id : 'form'" />
<ComponentsSelectionList v-else-if="showComponentSelection" /> <ComponentsSelectionList v-else-if="showComponentSelection" />

View File

@@ -29,6 +29,23 @@ const breadcrumbs: BreadcrumbItem[] = [
}, },
]; ];
function isSaving() {
return resumeStore.isSaving;
}
// Confirmation when closing the tab if saving
window.addEventListener("beforeunload", function (e) {
if (!isSaving()) return;
console.log('Saving in progress, showing confirmation dialog.');
const confirmationMessage = 'Des changements sont toujours en cours de sauvegarde. '
+ 'Si vous quittez avant de sauvegarder, vos modifications seront perdues.';
(e || window.event).returnValue = confirmationMessage; //Gecko + IE
return confirmationMessage; //Gecko + Webkit, Safari, Chrome etc.
});
</script> </script>
<template> <template>

View File

@@ -10,6 +10,8 @@ const useResumesStore = defineStore('resumes', {
resumesAreFetched: false as boolean, resumesAreFetched: false as boolean,
currentResumeIndex: -1 as number, currentResumeIndex: -1 as number,
selectedResumePlacementIndex: -1 as number, selectedResumePlacementIndex: -1 as number,
isSaving: false as boolean,
savingError: null as string | null,
}), }),
getters: { getters: {
hasResumes: (state) => computed(() => state.resumes.length > 0), hasResumes: (state) => computed(() => state.resumes.length > 0),
@@ -48,9 +50,19 @@ const useResumesStore = defineStore('resumes', {
}, },
}, },
actions: { actions: {
setIsSaving(saving: boolean) {
this.isSaving = saving;
if (saving) {
this.savingError = null;
}
},
setSavingError(error: string | null) {
this.setIsSaving(false);
this.savingError = error;
},
async fetchResumes() { async fetchResumes() {
try { try {
this.resumesAreFetched = false; this.setIsSaving(true);
// get from cache // get from cache
const cachedResumes = localStorage.resumes; const cachedResumes = localStorage.resumes;
if (cachedResumes) { if (cachedResumes) {
@@ -60,9 +72,13 @@ const useResumesStore = defineStore('resumes', {
const { data: resumes, error } = await httpApi<Resume[]>(route("resumes.index")); const { data: resumes, error } = await httpApi<Resume[]>(route("resumes.index"));
if (error || !resumes) { if (error || !resumes) {
console.error('Failed to fetch resumes:', error); console.error('Failed to fetch resumes:', error);
this.setSavingError(error);
return; return;
} }
this.setIsSaving(false);
this.setResumes(resumes); this.setResumes(resumes);
this.resumesAreFetched = false;
// Store in cache // Store in cache
this.saveResumesToCache(); this.saveResumesToCache();
@@ -70,6 +86,7 @@ const useResumesStore = defineStore('resumes', {
this.resumesAreFetched = true; this.resumesAreFetched = true;
} catch (error) { } catch (error) {
console.error('Failed to fetch resumes:', error); console.error('Failed to fetch resumes:', error);
this.setSavingError(error as string);
} }
}, },
async updateResumeToApi(resumeIndex: number) { async updateResumeToApi(resumeIndex: number) {
@@ -83,6 +100,7 @@ const useResumesStore = defineStore('resumes', {
return; return;
} }
this.setIsSaving(true);
const resumeToUpdate = this.resumes[resumeIndex]; const resumeToUpdate = this.resumes[resumeIndex];
const { data: updatedResume, error } = await httpApi<null>(route("resumes.update", { resume: resumeToUpdate.id }), { const { data: updatedResume, error } = await httpApi<null>(route("resumes.update", { resume: resumeToUpdate.id }), {
method: 'PUT', method: 'PUT',
@@ -93,12 +111,16 @@ const useResumesStore = defineStore('resumes', {
}, },
body: JSON.stringify(resumeToUpdate), body: JSON.stringify(resumeToUpdate),
}); });
this.setIsSaving(false);
this.setSavingError("Poor nigga detected !");
if (error || !updatedResume) { if (error || !updatedResume) {
console.error('Failed to update resumes:', error); console.error('Failed to update resumes:', error);
this.setSavingError(error as string);
return; return;
} }
} catch (error) { } catch (error) {
console.error('Failed to update resumes:', error); console.error('Failed to update resumes:', error);
this.setSavingError(error as string);
} }
}, },
async updateCurrentResumeToApi() { async updateCurrentResumeToApi() {
@@ -108,7 +130,38 @@ const useResumesStore = defineStore('resumes', {
localStorage.resumes = JSON.stringify(this.resumes); localStorage.resumes = JSON.stringify(this.resumes);
}, },
setResumes(resumes: Array<Resume>) { setResumes(resumes: Array<Resume>) {
this.resumes = resumes; // this.resumes = resumes;
// for (const resume of resumes) {
// const existingResume = this.resumes.find(r => r.id === resume.id);
// if (existingResume) {
// Object.assign(existingResume, resume);
// if (this.currentResumeIndex >= 0 && this.resumes[this.currentResumeIndex].id === resume.id) {
// // Update the current resume reference if it's the same resume
// this.currentResumeIndex = this.resumes.indexOf(existingResume);
// }
// } else {
// this.resumes.push(resume);
// }
// }
// Do a copy of the resumes array
// Set the resumes array to resumes
// For each old resume, look for it in the new resumes array by id
// If found, keep the reference of the old resume (to keep reactivity)
// If not found, add the new resume to the array
const currentResumeId = this.currentResumeIndex >= 0 && this.currentResumeIndex < this.resumes.length ? this.resumes[this.currentResumeIndex].id : null;
// this.resumes = resumes;
for (const oldResume of this.resumes) {
const existingResume = resumes.find(r => r.id === oldResume.id);
if (existingResume) {
existingResume.components_placements = oldResume.components_placements;
if (oldResume.id === currentResumeId) {
this.currentResumeIndex = this.resumes.indexOf(oldResume);
}
}
}
this.resumes = resumes
}, },
addResume(resume: Resume) { addResume(resume: Resume) {
this.resumes.push(resume); this.resumes.push(resume);
@@ -141,11 +194,15 @@ const useResumesStore = defineStore('resumes', {
this.updateCurrentResume(resume); this.updateCurrentResume(resume);
}, },
setAndUpdateCurrentResumeWhenFetched(resume: Resume) { setAndUpdateCurrentResumeWhenFetched(resume: Resume) {
if (this.resumesAreFetched) {
this.setAndUpdateCurrentResume(resume);
} else {
watch(() => this.resumesAreFetched, (newVal) => { watch(() => this.resumesAreFetched, (newVal) => {
if (newVal === true) { if (newVal === true) {
this.setAndUpdateCurrentResume(resume); this.setAndUpdateCurrentResume(resume);
} }
}); });
}
}, },
updateCurrentResume(updatedResume: Resume) { updateCurrentResume(updatedResume: Resume) {
if (this.currentResumeIndex < 0 || this.currentResumeIndex >= this.resumes.length) return; if (this.currentResumeIndex < 0 || this.currentResumeIndex >= this.resumes.length) return;
@@ -191,6 +248,7 @@ const useResumesStore = defineStore('resumes', {
} }
console.debug("Updating component placement:", componentPlacement); console.debug("Updating component placement:", componentPlacement);
this.setIsSaving(true);
const { data: updatedPlacement, error } = await httpApi<ResumeComponentPlacement>(route("resume-component-placements.update", componentPlacement.id), { const { data: updatedPlacement, error } = await httpApi<ResumeComponentPlacement>(route("resume-component-placements.update", componentPlacement.id), {
method: 'PUT', method: 'PUT',
headers: { headers: {
@@ -200,8 +258,10 @@ const useResumesStore = defineStore('resumes', {
}, },
body: JSON.stringify(componentPlacement), body: JSON.stringify(componentPlacement),
}); });
this.setIsSaving(false);
if (error || !updatedPlacement) { if (error || !updatedPlacement) {
console.error('Failed to update resume placements:', error); console.error('Failed to update resume placements:', error);
this.setSavingError(error as string);
return; return;
} }
@@ -210,6 +270,7 @@ const useResumesStore = defineStore('resumes', {
this.saveResumesToCache(); this.saveResumesToCache();
} catch (error) { } catch (error) {
console.error('Failed to update resume placements:', error); console.error('Failed to update resume placements:', error);
this.setSavingError(error as string);
} }
}, },
setSelectedResumePlacement(index: number) { setSelectedResumePlacement(index: number) {
@@ -238,6 +299,10 @@ const useResumesStore = defineStore('resumes', {
if (!this.hasCurrentSelectedResumePlacement.value) return; if (!this.hasCurrentSelectedResumePlacement.value) return;
this.modifyResumePlacements(this.selectedResumePlacementIndex, updatedPlacement); this.modifyResumePlacements(this.selectedResumePlacementIndex, updatedPlacement);
}, },
modifyCurrentSelectedResumePlacementToApi(updatedPlacement: ResumeComponentPlacement) {
this.modifyCurrentSelectedResumePlacement(updatedPlacement);
this.updateCurrentResumePlacementsToApi(this.selectedResumePlacementIndex);
},
swapComponentsPlacementsOrder(indexA: number, indexB: number) { swapComponentsPlacementsOrder(indexA: number, indexB: number) {
if (!this.hasCurrentResume.value || !this.currentResume.value?.components_placements) return; if (!this.hasCurrentResume.value || !this.currentResume.value?.components_placements) return;
const currentResume = this.currentResume.value; const currentResume = this.currentResume.value;
@@ -266,6 +331,7 @@ const useResumesStore = defineStore('resumes', {
// It will return the new component_data that need to be set to the placement // It will return the new component_data that need to be set to the placement
const placementToUnlink = currentResume.components_placements![index]; const placementToUnlink = currentResume.components_placements![index];
try { try {
this.setIsSaving(true);
const { data: newComponentData, error } = await httpApi<ResumeComponentData>(route("resume-component-placements.unlink", placementToUnlink.id), { const { data: newComponentData, error } = await httpApi<ResumeComponentData>(route("resume-component-placements.unlink", placementToUnlink.id), {
method: 'PUT', method: 'PUT',
headers: { headers: {
@@ -274,8 +340,10 @@ const useResumesStore = defineStore('resumes', {
'Accept': 'application/json' 'Accept': 'application/json'
}, },
}); });
this.setIsSaving(false);
if (error || !newComponentData) { if (error || !newComponentData) {
console.error('Failed to unlink resume placement:', error); console.error('Failed to unlink resume placement:', error);
this.setSavingError(error as string);
return; return;
} }
@@ -286,6 +354,7 @@ const useResumesStore = defineStore('resumes', {
this.saveResumesToCache(); this.saveResumesToCache();
} catch (error) { } catch (error) {
console.error('Failed to unlink resume placement:', error); console.error('Failed to unlink resume placement:', error);
this.setSavingError(error as string);
} }
}, },
async deleteComponentPlacement(index: number) { async deleteComponentPlacement(index: number) {
@@ -295,6 +364,7 @@ const useResumesStore = defineStore('resumes', {
const placementToDelete = currentResume.components_placements![index]; const placementToDelete = currentResume.components_placements![index];
try { try {
this.setIsSaving(true);
const { error } = await httpApi<null>(route("resume-component-placements.destroy", placementToDelete.id), { const { error } = await httpApi<null>(route("resume-component-placements.destroy", placementToDelete.id), {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
@@ -302,8 +372,10 @@ const useResumesStore = defineStore('resumes', {
'Accept': 'application/json' 'Accept': 'application/json'
}, },
}); });
this.setIsSaving(false);
if (error) { if (error) {
console.error('Failed to delete resume placement:', error); console.error('Failed to delete resume placement:', error);
this.setSavingError(error as string);
return; return;
} }
@@ -317,6 +389,7 @@ const useResumesStore = defineStore('resumes', {
} }
} catch (error) { } catch (error) {
console.error('Failed to delete resume placement:', error); console.error('Failed to delete resume placement:', error);
this.setSavingError(error as string);
} }
}, },
clearSelectedResumePlacement() { clearSelectedResumePlacement() {