Add dirty-tracking to Archive Content edit: disable Save, hide Cancel when unchanged

- ArchiveContentForm interface + factory for controlled input bindings
- obj_changed bindable prop wired to Cancel button visibility in parent page
- Split Save button: edit mode disables when clean, create mode always enabled
- Post-upload/select/remove syncs orig snapshot so file ops do not dirty the form
- Fix archive_content_id_random / archive_id_random → V3 field names in edit component
- Add missing file_extension field to ae_ArchiveContent type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-05-07 16:57:58 -04:00
parent c71fc65be9
commit c7ebeebe29
3 changed files with 160 additions and 145 deletions

View File

@@ -863,6 +863,7 @@ export interface ae_ArchiveContent extends ae_BaseObj {
hosted_file_id_random?: string; // NO LONGER USE "_random"
filename?: string;
file_extension?: string;
subdirectory_path?: string;
}

View File

@@ -51,6 +51,8 @@ import { page } from '$app/state';
// let ae_tmp: key_val = {};
// let ae_triggers: key_val = {};
let archive_content_edit_dirty = $state(false);
// *** Quickly pull out data from parent(s)
let ae_acct = $derived(data[data.account_id]);
$effect(() => {
@@ -502,8 +504,8 @@ onDestroy(() => {
{#snippet header()}
<div class="flex w-full flex-row items-center justify-between">
<h3 class="text-lg font-semibold">
{#if $ae_loc.trusted_access}
<!-- <div class="ae_options"> -->
{#if $ae_loc.trusted_access && (!$idaa_slct.archive_content_id || archive_content_edit_dirty)}
<!-- Hidden in edit mode when nothing has changed — no unsaved changes to cancel -->
<button
type="button"
onclick={() => {
@@ -517,7 +519,6 @@ onDestroy(() => {
title={`View meeting: ${$lq__archive_content_obj?.name}`}>
<span class="fas fa-eye m-1"></span> Cancel
</button>
<!-- </div> -->
{/if}
<span class="text-sm text-gray-500"> Edit Content: </span>
@@ -526,7 +527,7 @@ onDestroy(() => {
</div>
{/snippet}
<Archive_content_obj_id_edit {lq__archive_content_obj} />
<Archive_content_obj_id_edit {lq__archive_content_obj} bind:obj_changed={archive_content_edit_dirty} />
{#snippet footer()}
<button

View File

@@ -28,9 +28,10 @@ import Element_manage_hosted_file_li_wrap from '$lib/elements/element_manage_hos
interface Props {
log_lvl?: number;
lq__archive_content_obj: any;
obj_changed?: boolean;
}
let { log_lvl = 0, lq__archive_content_obj }: Props = $props();
let { log_lvl = 0, lq__archive_content_obj, obj_changed = $bindable(false) }: Props = $props();
let create_archive_content_obj_promise: any;
let delete_archive_content_obj_promise: any;
@@ -42,9 +43,7 @@ let prom_api__archive_content_obj__hosted_file: any = $state();
let description_new_html = $state(
$idaa_slct.archive_content_obj?.description ?? ''
);
let description_changed = $state(false);
let notes_new_html = $state($idaa_slct.archive_content_obj?.notes ?? '');
let notes_changed = $state(false);
let disable_submit_btn = $state(false);
let slct_hosted_file_kv: key_val = $state({});
@@ -83,7 +82,7 @@ if ($idaa_slct.archive_content_id) {
} else {
$idaa_slct.archive_content_id = null;
$idaa_slct.archive_content_obj = {
archive_id_random: null,
archive_id: null,
// archive_content_id_random: null,
archive_content_type: 'hosted_file',
name: null,
@@ -114,6 +113,56 @@ if ($idaa_slct.archive_content_id) {
);
}
// Form shape for controlled inputs — enables dirty detection via snapshot comparison.
// Initialized AFTER the init block above so new-item defaults are in place.
interface ArchiveContentForm {
name: string;
archive_content_type: string;
filename: string;
file_extension: string;
original_datetime_date: string;
original_datetime_time: string;
original_timezone: string;
original_location: string;
hide: boolean;
priority: boolean | null;
enable: boolean;
sort: number | null;
group: string;
}
function create_archive_content_form(obj: any, current_timezone = ''): ArchiveContentForm {
return {
name: obj?.name ?? '',
archive_content_type: obj?.archive_content_type ?? 'hosted_file',
filename: obj?.filename ?? '',
file_extension: obj?.file_extension ?? '',
original_datetime_date: obj?.original_datetime
? ae_util.iso_datetime_formatter(obj.original_datetime, 'date_iso')
: '',
original_datetime_time: obj?.original_datetime
? ae_util.iso_datetime_formatter(obj.original_datetime, 'time_iso')
: '',
original_timezone: obj?.original_timezone ?? current_timezone,
original_location: obj?.original_location ?? '',
hide: obj?.hide ?? false,
priority: obj?.priority ?? null,
enable: obj?.enable ?? true,
sort: obj?.sort ?? null,
group: obj?.group ?? ''
};
}
let archive_content_form = $state(
create_archive_content_form($idaa_slct.archive_content_obj, $ae_loc.current_timezone)
);
let orig_archive_content_form = $state(
create_archive_content_form($idaa_slct.archive_content_obj, $ae_loc.current_timezone)
);
// Plain (non-reactive) snapshots for TipTap content comparison
let orig_description_html = $idaa_slct.archive_content_obj?.description ?? '';
let orig_notes_html = $idaa_slct.archive_content_obj?.notes ?? '';
// Timezone lookup — reactive IDB query; background refresh handled by liveQuery + TTL
// Sort: sort DESC (higher = first, NULL=0 last), then name ASC — matches Aether backend convention.
const lq__lu_time_zone = liveQuery(() =>
@@ -138,6 +187,13 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
};
}
function mark_archive_content_form_saved() {
orig_archive_content_form = JSON.parse(JSON.stringify(archive_content_form));
orig_description_html = description_new_html;
orig_notes_html = notes_new_html;
obj_changed = false;
}
async function handle_submit_form(event: SubmitEvent) {
if (log_lvl > 1) {
console.log('*** handle_submit_form() ***');
@@ -163,7 +219,7 @@ async function handle_submit_form(event: SubmitEvent) {
let archive_content_do: key_val = {};
if (!$idaa_slct.archive_content_id) {
archive_content_do['archive_id_random'] = $idaa_slct.archive_id;
archive_content_do['archive_id'] = $idaa_slct.archive_id;
}
archive_content_do['name'] = archive_content_di.name;
@@ -253,21 +309,16 @@ async function handle_submit_form(event: SubmitEvent) {
}
$idaa_slct.archive_content_id =
archive_content_obj_create_result.archive_content_id_random;
// $idaa_slct.archive_content_obj = await $lq__archive_content_obj;
// $idaa_slct.archive_content_obj.hosted_file_id_li = [];
// $idaa_slct.archive_content_obj.hosted_file_obj_li = [];
// $idaa_slct.archive_content_obj.upload_complete = false;
// $idaa_slct.archive_content_obj.description_new_html = '';
archive_content_obj_create_result.archive_content_id;
$idaa_slct.archive_content_obj =
archive_content_obj_create_result;
$idaa_slct.archive_content_obj.archive_content_id =
archive_content_obj_create_result.archive_content_id_random; // This is because we need use the string ID, not int ID.
$idaa_slct.archive_content_obj.upload_complete = false;
$idaa_slct.archive_content_obj.hosted_file_id_li = [];
$idaa_slct.archive_content_obj.hosted_file_obj_li = [];
mark_archive_content_form_saved();
return archive_content_obj_create_result;
})
.catch(function (error: any) {
@@ -305,6 +356,7 @@ async function handle_submit_form(event: SubmitEvent) {
// We need to do all of this since the DB object has changed and the SLCT object does automatically update (yet...??? Svelte 5?).
// $idaa_slct.archive_content_obj = await $lq__archive_content_obj;
mark_archive_content_form_saved();
$idaa_slct.archive_content_id = null;
$idaa_slct.archive_content_obj = {};
$idaa_sess.archives.show__modal_edit__archive_content_id = false;
@@ -350,6 +402,12 @@ async function handle_hosted_files_uploaded(
) {
$idaa_slct.archive_content_obj.hosted_file_obj_li = [];
}
// Sync form and snapshot so a successful upload doesn't mark the form as dirty.
archive_content_form.filename = archive_content_obj_update_result?.filename ?? '';
archive_content_form.file_extension = archive_content_obj_update_result?.file_extension ?? '';
archive_content_form.archive_content_type = 'hosted_file';
orig_archive_content_form = JSON.parse(JSON.stringify(archive_content_form));
})
.catch(function (error: any) {
console.log('Something went wrong.');
@@ -358,6 +416,24 @@ async function handle_hosted_files_uploaded(
});
}
// Dirty detection — edit mode only. Create mode Save is always available.
$effect(() => {
if (!$idaa_slct.archive_content_id) return;
const form_changed =
JSON.stringify(archive_content_form) !== JSON.stringify(orig_archive_content_form);
const has_changes =
form_changed ||
description_new_html !== orig_description_html ||
notes_new_html !== orig_notes_html;
if (!obj_changed && has_changes) {
obj_changed = true;
} else if (obj_changed && !has_changes) {
obj_changed = false;
}
});
$effect(() => {
if (
upload_complete_local &&
@@ -414,6 +490,12 @@ $effect(() => {
) {
$idaa_slct.archive_content_obj.hosted_file_obj_li = [];
}
// Sync form and snapshot so selecting an existing file doesn't mark form as dirty.
archive_content_form.filename = archive_content_obj_update_result?.filename ?? '';
archive_content_form.file_extension = archive_content_obj_update_result?.file_extension ?? '';
archive_content_form.archive_content_type = 'hosted_file';
orig_archive_content_form = JSON.parse(JSON.stringify(archive_content_form));
})
.catch(function (error: any) {
console.log('Something went wrong.');
@@ -465,9 +547,7 @@ $effect(() => {
name="name"
required
max="200"
value={$idaa_slct.archive_content_obj?.name
? $idaa_slct.archive_content_obj.name
: ''}
bind:value={archive_content_form.name}
autocomplete="off"
class="
input preset-tonal-surface
@@ -493,43 +573,16 @@ $effect(() => {
<select
id="archive_content_type"
name="archive_content_type"
bind:value={
$idaa_slct.archive_content_obj.archive_content_type
}
bind:value={archive_content_form.archive_content_type}
class="select w-52">
<option value="">-- None --</option>
<option value="hosted_file" selected
>Hosted File in Æ</option>
<option value="hosted_file">Hosted File in Æ</option>
<option value="html">Hosted HTML in Æ</option>
<option value="json">Hosted JSON in Æ</option>
<option value="url">External URL</option>
<option value="other">Other</option>
</select>
</label>
<!-- <fieldset class="">
<legend class="">Public Access with Rotating Access Key/Passcode</legend>
<label for="enable_for_public_no" class="">No, disable public access
<input
type="radio"
class="radio"
id="enable_for_public_no"
name="enable_for_public"
value={false}
bind:group={$idaa_slct.archive_content_obj.enable_for_public}
>
</label>
<label for="enable_for_public_yes" class="">Yes, allow public access
<input
type="radio"
class="radio"
id="enable_for_public_yes"
name="enable_for_public"
value={true}
bind:group={$idaa_slct.archive_content_obj.enable_for_public}
>
</label>
</fieldset> -->
</div>
{#if $idaa_slct.archive_content_id}
@@ -605,11 +658,11 @@ $effect(() => {
.add_to_use_files_method != 'select'}
class="">
<Element_manage_hosted_file_li_wrap
link_to_type={'account'}
link_to_type="account"
link_to_id={$ae_loc?.account_id}
allow_basic={true}
allow_moderator={true}
class_li={''}
class_li=""
bind:slct_hosted_file_kv
bind:slct_hosted_file_id
bind:slct_hosted_file_obj />
@@ -639,7 +692,7 @@ $effect(() => {
fake_delete: false,
log_lvl: log_lvl
})
.then(function (delete_result) {
.then(function () {
// Second - If deleted, then update the archive_content_obj
console.log(
`File removed. Now update the archive_content_obj`
@@ -664,10 +717,7 @@ $effect(() => {
log_lvl: log_lvl
}
)
.then(
function (
archive_content_obj_update_result
) {
.then(function () {
// We need to do all of this since the DB object has changed and the SLCT object does automatically update (yet...??? Svelte 5?).
// $idaa_slct.archive_content_obj = $lq__archive_content_obj;
}
@@ -705,6 +755,11 @@ $effect(() => {
[];
$idaa_slct.archive_content_obj.hosted_file_obj_li =
[];
// Sync form snapshot after file removal
archive_content_form.filename = '';
archive_content_form.file_extension = '';
orig_archive_content_form = JSON.parse(JSON.stringify(archive_content_form));
});
}
}}
@@ -715,35 +770,15 @@ $effect(() => {
<span class="fas fa-trash-alt m-1"></span>
Remove File
{/await}
<!-- <span class="fas fa-trash-alt m-1"></span>
Remove File -->
</button>
<!-- <label for="file_path">File Path
{#if !$ae_loc.administrator_access}
<span class="fas fa-lock" title="Field is locked"></span>
{:else}
<span class="fas fa-unlock" title="Field is unlocked"></span>
{/if}
<input
type="text"
id="file_path"
name="file_path"
value={($idaa_slct.archive_content_obj.file_path ? $idaa_slct.archive_content_obj.file_path : '')}
readonly={!$ae_loc.administrator_access}
class="input w-full"
>
</label> -->
<label for="filename"
>Filename
<input
type="text"
id="filename"
name="filename"
value={$idaa_slct.archive_content_obj.filename
? $idaa_slct.archive_content_obj.filename
: 'unknown'}
bind:value={archive_content_form.filename}
class="input w-full" />
</label>
@@ -761,9 +796,7 @@ $effect(() => {
type="text"
id="file_extension"
name="file_extension"
value={$idaa_slct.archive_content_obj.file_extension
? $idaa_slct.archive_content_obj.file_extension
: 'ext'}
bind:value={archive_content_form.file_extension}
readonly={!$ae_loc.administrator_access}
class="input w-24" />
</label>
@@ -787,24 +820,14 @@ $effect(() => {
type="date"
id="original_datetime_date"
name="original_datetime_date"
value={$idaa_slct.archive_content_obj.original_datetime
? ae_util.iso_datetime_formatter(
$idaa_slct.archive_content_obj.original_datetime,
'date_iso'
)
: ''}
bind:value={archive_content_form.original_datetime_date}
placeholder="YYYY-MM-DD"
class="input w-48" />
<input
type="time"
id="original_datetime_time"
name="original_datetime_time"
value={$idaa_slct.archive_content_obj.original_datetime
? ae_util.iso_datetime_formatter(
$idaa_slct.archive_content_obj.original_datetime,
'time_iso'
)
: ''}
bind:value={archive_content_form.original_datetime_time}
placeholder="HH:MM AM/PM"
class="input w-48" />
</label>
@@ -816,11 +839,7 @@ $effect(() => {
<select
id="original_timezone"
name="original_timezone"
value={$idaa_slct.archive_content_obj
.original_timezone
? $idaa_slct.archive_content_obj
.original_timezone
: $ae_loc.current_timezone}
bind:value={archive_content_form.original_timezone}
class="select w-56"
title="Select the original timezone">
<option value="">-- None --</option>
@@ -834,11 +853,7 @@ $effect(() => {
<input
type="text"
name="timezone"
value={$idaa_slct.archive_content_obj
.original_timezone
? $idaa_slct.archive_content_obj
.original_timezone
: $ae_loc.current_timezone}
bind:value={archive_content_form.original_timezone}
class="input w-56" />
{/if}
</label>
@@ -850,7 +865,7 @@ $effect(() => {
type="text"
id="original_location"
name="original_location"
value={$idaa_slct.archive_content_obj.original_location}
bind:value={archive_content_form.original_location}
class="input w-96" />
</label>
</div>
@@ -895,9 +910,7 @@ $effect(() => {
id="hide_yes"
name="hide"
value={true}
bind:group={
$idaa_slct.archive_content_obj.hide
}
bind:group={archive_content_form.hide}
class="radio form-check-input" />
<label for="hide_yes">Yes</label>
</div>
@@ -907,9 +920,7 @@ $effect(() => {
id="hide_no"
name="hide"
value={false}
bind:group={
$idaa_slct.archive_content_obj.hide
}
bind:group={archive_content_form.hide}
class="radio form-check-input" />
<label for="hide_no">No</label>
</div>
@@ -926,10 +937,7 @@ $effect(() => {
id="priority_yes"
name="priority"
value={true}
bind:group={
$idaa_slct.archive_content_obj
.priority
}
bind:group={archive_content_form.priority}
class="radio form-check-input" />
<label for="priority_yes">Yes</label>
</div>
@@ -939,10 +947,7 @@ $effect(() => {
id="priority_no"
name="priority"
value={false}
bind:group={
$idaa_slct.archive_content_obj
.priority
}
bind:group={archive_content_form.priority}
class="radio form-check-input" />
<label for="priority_no">No</label>
</div>
@@ -960,7 +965,7 @@ $effect(() => {
>Sort <input
type="number"
name="sort"
value={$idaa_slct.archive_content_obj.sort}
bind:value={archive_content_form.sort}
class="input preset-tonal-surface form-control w-24" /></label>
<label
@@ -968,8 +973,7 @@ $effect(() => {
>Group <input
type="text"
name="group"
value={$idaa_slct.archive_content_obj
.group ?? ''}
bind:value={archive_content_form.group}
max="100"
class="input preset-tonal-surface form-control w-40" /></label>
</span>
@@ -988,10 +992,7 @@ $effect(() => {
id="enable_yes"
name="enable"
value={true}
bind:group={
$idaa_slct.archive_content_obj
.enable
}
bind:group={archive_content_form.enable}
class="radio form-check-input" />
<label for="enable_yes">Yes</label>
</div>
@@ -1001,10 +1002,7 @@ $effect(() => {
id="enable_no"
name="enable"
value={false}
bind:group={
$idaa_slct.archive_content_obj
.enable
}
bind:group={archive_content_form.enable}
class="radio form-check-input" />
<label for="enable_no">No</label>
</div>
@@ -1014,7 +1012,7 @@ $effect(() => {
<input
type="hidden"
name="enable"
value={$idaa_slct.archive_content_obj.enable} />
value={archive_content_form.enable} />
{/if}
</span>
@@ -1049,22 +1047,37 @@ $effect(() => {
d-flex align-items-center justify-content-between flex w-full flex-row
items-center justify-between gap-1
">
<button
type="submit"
disabled={disable_submit_btn}
class="
novi_btn
btn btn-md preset-tonal-primary preset-outlined-primary-800-200 hover:preset-filled-primary-200-800
transition
">
{#await prom_api__archive_content_obj}
<span class="fas fa-spinner fa-spin m-1"></span> Saving
{:then}
<span class="fas fa-save m-1"></span> Save Changes
{/await}
<!-- <span class="fas fa-check m-1"></span> -->
<!-- Save Archive Content -->
</button>
{#if $idaa_slct.archive_content_id}
<button
type="submit"
disabled={disable_submit_btn || !obj_changed}
class="
novi_btn
btn btn-md preset-tonal-primary preset-outlined-primary-800-200 hover:preset-filled-primary-200-800
transition
">
{#await prom_api__archive_content_obj}
<span class="fas fa-spinner fa-spin m-1"></span> Saving
{:then}
<span class="fas fa-save m-1"></span> Save Changes
{/await}
</button>
{:else}
<button
type="submit"
disabled={disable_submit_btn}
class="
novi_btn
btn btn-md preset-tonal-warning hover:preset-filled-warning-200-800
transition
">
{#await prom_api__archive_content_obj}
<span class="fas fa-spinner fa-spin m-1"></span> Saving
{:then}
<span class="fas fa-save m-1"></span> Save New Content
{/await}
</button>
{/if}
{#if $idaa_slct.archive_content_id && !$idaa_slct.archive_content_obj?.hosted_file_id}
<button