feat(ui): Page.SettingsActions for settings save bar (#819)

* feat(ui): add Page.SettingsActions for settings save bar

Introduce a sticky bottom save/discard bar on Page and migrate settings
pages to use it instead of header or inline save buttons. Includes
profile, org, customize-lms, notifications, auth, AI tutor, course
settings, certificates, and cohort settings.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(ui): always show Page.SettingsActions, disable when clean

Keep the settings save bar visible at all times and disable Save and
Discard when hasChanges is false instead of hiding the bar entirely.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(ui): add bottom spacing and shadow to Page.SettingsActions

Wrap the actions card in a sticky container with bottom padding so it
does not sit flush against the viewport edge, and use shadow-md on the
card for clearer elevation.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(ui): stop viewport-sticking Page.SettingsActions on scroll

Remove sticky bottom positioning so the save bar stays at the end of the
page content and scrolls away when viewing upper sections.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(ui): use sticky bottom for Page.SettingsActions dock behavior

Replace the IntersectionObserver fixed/dock toggle that caused layout
thrashing and flipping with native position: sticky; bottom: 0. Move
overflow-x-hidden from Page.Root to Page.Body so sticky is not broken.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(ui): keep Page.SettingsActions sticky and above editor content

Page.Root carried md:overflow-x-auto, which makes overflow-y compute to
auto, turning the root into the sticky bar's nearest scrolling ancestor.
Since the root sizes to its content it never scrolls internally, so the
bar rendered at its natural flow position instead of pinning to the
viewport. Drop the overflow so sticky resolves against the viewport;
horizontal containment stays on Page.Body.

Raise the bar to z-50 so it is not painted over by the editor root,
which is relative z-50. Matching rather than exceeding 50 keeps
body-portaled dialogs and sheets above the bar.

Show the shadow only while the bar is pinned away from its flow
position, tracked with an IntersectionObserver on a 1px sentinel
sibling. Unlike the previous observer this toggles a box-shadow only, so
it cannot feed back into layout.

* fix(ui): tighten settings actions spacing and tab visibility

- Remove mt-6 and lower z-index so popovers sit above the bar
- Wrap sentinel in a single root to avoid extra Page.Root flex gap
- Remove Page.Body mb-4 redundant with settings actions spacing
- Show certificate save bar only on the settings tab
- Raise theme color picker z-index via --picker-z-index

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(dashboard): address PR review feedback for settings actions

- Capture profile/org saved baselines only on initial load, not on edits
- Reset course settings errors with explicit field shape in handleDiscard
- Restore VisitOrgSiteButton on org profile settings header
- Derive AI tutor dirty state from stores directly instead of mirrored state
- Use discard-specific French label and document SettingsActions props

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* docs(ui): clarify Page.Root ownership for Page.SettingsActions

"Last child of Page.Root" is a runtime relationship. Several settings
pages get their Page.Root from a +layout.svelte, so the page file holds
only Page.Header, Page.Body, and Page.SettingsActions. Document that this
is correct and list the pages, since adding a nested Page.Root stacks two
min-height containers and shortens the sticky bar's containing block.

Also document that Page.SettingsActions must never be rendered from
inside Page.Body: overflow-x-hidden makes Page.Body a scroll container
that never scrolls, so a sticky bar in it has no travel.

---------
This commit is contained in:
Rotimi Best
2026-08-02 09:11:09 +01:00
committed by GitHub
parent 73f39da54a
commit 06c3a7feba
40 changed files with 751 additions and 240 deletions
@@ -1,15 +1,13 @@
<script lang="ts">
import { onMount } from 'svelte';
import { writable } from 'svelte/store';
import { writable, get } from 'svelte/store';
import { defaultAiTutorSettings, type AiTutorSettings } from '@cio/ai-assistant/tutor-config';
import * as Page from '@cio/ui/base/page';
import { Button } from '@cio/ui/base/button';
import * as Field from '@cio/ui/base/field';
import { Switch } from '@cio/ui/base/switch';
import { Label } from '@cio/ui/base/label';
import { t } from '$lib/utils/functions/translations';
import { aiTutorApi } from '../api/ai-tutor.svelte';
import TutorSettingsForm from '../components/tutor-settings-form.svelte';
@@ -23,6 +21,22 @@
const formStore = writable<AiTutorSettings>({ ...defaultAiTutorSettings });
let inheritFromOrg = $state(true);
let initialized = $state(false);
let savedStateSnapshot = $state('');
const currentStateSnapshot = $derived(
JSON.stringify({
inheritFromOrg,
settings: $formStore
})
);
const hasUnsavedChanges = $derived(
initialized && savedStateSnapshot !== '' && currentStateSnapshot !== savedStateSnapshot
);
function captureSavedSnapshot() {
savedStateSnapshot = currentStateSnapshot;
}
onMount(async () => {
await aiTutorApi.fetchCourseSettings(courseId);
@@ -33,31 +47,42 @@
inheritFromOrg = view.override == null || view.override.inheritFromOrg !== false;
}
captureSavedSnapshot();
initialized = true;
});
async function handleSave() {
if (inheritFromOrg) {
await aiTutorApi.updateCourseSettings(courseId, { inheritFromOrg: true });
return;
} else {
const data = get(formStore);
await aiTutorApi.updateCourseSettings(courseId, { ...data, inheritFromOrg: false });
}
const data = $formStore;
await aiTutorApi.updateCourseSettings(courseId, { ...data, inheritFromOrg: false });
if (aiTutorApi.courseSettings) {
captureSavedSnapshot();
}
}
function handleDiscard() {
if (!savedStateSnapshot) return;
const parsed = JSON.parse(savedStateSnapshot) as {
inheritFromOrg: boolean;
settings: AiTutorSettings;
};
inheritFromOrg = parsed.inheritFromOrg;
formStore.set(parsed.settings);
}
</script>
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
<Page.Header isSticky class="ui:bg-background z-10">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('aiTutor.page.course.title')}</Page.Title>
<Page.Subtitle>{$t('aiTutor.page.course.description')}</Page.Subtitle>
</Page.HeaderContent>
<Page.Action>
<Button loading={aiTutorApi.saving} disabled={aiTutorApi.saving || !initialized} onclick={handleSave}>
{$t('aiTutor.action.save')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
@@ -84,4 +109,15 @@
{/if}
{/snippet}
</Page.Body>
<Page.SettingsActions
hasChanges={hasUnsavedChanges}
loading={aiTutorApi.saving}
disabled={!initialized || aiTutorApi.saving}
statusLabel={$t('common.unsaved_changes.label')}
discardLabel={$t('common.discard')}
saveLabel={$t('common.save_changes')}
onSave={handleSave}
onDiscard={handleDiscard}
/>
</Page.Root>
@@ -1,39 +1,55 @@
<script lang="ts">
import { onMount } from 'svelte';
import { defaultAiTutorSettings } from '@cio/ai-assistant/tutor-config';
import { get } from 'svelte/store';
import * as Page from '@cio/ui/base/page';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import { aiTutorApi } from '../api/ai-tutor.svelte';
import { applyOrgSettings, orgTutorSettingsStore } from '../store/tutor-settings-store';
import TutorSettingsForm from '../components/tutor-settings-form.svelte';
let initialized = $state(false);
let savedSettingsSnapshot = $state('');
const currentSettingsSnapshot = $derived(JSON.stringify($orgTutorSettingsStore));
const hasUnsavedChanges = $derived(
initialized && savedSettingsSnapshot !== '' && currentSettingsSnapshot !== savedSettingsSnapshot
);
function captureSavedSnapshot() {
savedSettingsSnapshot = currentSettingsSnapshot;
}
onMount(async () => {
await aiTutorApi.fetchOrgSettings();
applyOrgSettings(aiTutorApi.orgSettings ?? defaultAiTutorSettings);
captureSavedSnapshot();
initialized = true;
});
async function handleSave() {
await aiTutorApi.updateOrgSettings($orgTutorSettingsStore);
await aiTutorApi.updateOrgSettings(get(orgTutorSettingsStore));
if (aiTutorApi.orgSettings) {
captureSavedSnapshot();
}
}
function handleDiscard() {
if (!savedSettingsSnapshot) return;
orgTutorSettingsStore.set(JSON.parse(savedSettingsSnapshot));
}
</script>
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
<Page.Header isSticky class="ui:bg-background z-10">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('aiTutor.page.org.title')}</Page.Title>
<Page.Subtitle>{$t('aiTutor.page.org.description')}</Page.Subtitle>
</Page.HeaderContent>
<Page.Action>
<Button loading={aiTutorApi.saving} disabled={aiTutorApi.saving || !initialized} onclick={handleSave}>
{$t('aiTutor.action.save')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
@@ -45,4 +61,15 @@
{/if}
{/snippet}
</Page.Body>
<Page.SettingsActions
hasChanges={hasUnsavedChanges}
loading={aiTutorApi.saving}
disabled={!initialized || aiTutorApi.saving}
statusLabel={$t('common.unsaved_changes.label')}
discardLabel={$t('common.discard')}
saveLabel={$t('common.save_changes')}
onSave={handleSave}
onDiscard={handleDiscard}
/>
</Page.Root>
@@ -9,11 +9,10 @@
type Props = {
errors: Record<string, string>;
activeTab?: string;
};
let { errors }: Props = $props();
let activeTab = $state('design');
let { errors, activeTab = $bindable('design') }: Props = $props();
</script>
<UpgradeBanner>{$t('upgrade.certificate')}</UpgradeBanner>
@@ -6,15 +6,16 @@
type Props = {
errors: Record<string, string>;
activeTab?: string;
};
let { errors }: Props = $props();
let { errors, activeTab = $bindable('design') }: Props = $props();
</script>
<IssueCertificateModal />
<RoleBasedSecurity allowedRoles={[1, 2]}>
<CertificateEdit {errors} />
<CertificateEdit {errors} bind:activeTab />
</RoleBasedSecurity>
<RoleBasedSecurity onlyStudent allowedRoles={[3]}>
<StudentCertificate />
@@ -262,6 +262,16 @@
});
}
export function handleDiscard() {
if (!courseApi.course) return;
setDefault(courseApi.course);
selectedTagIds = [...initialTagIds];
avatar = undefined;
errors = { title: undefined, description: undefined };
hasUnsavedChanges = false;
}
function sanitizeCalloutForSave(value: typeof $settings.callout) {
if (!value) return null;
@@ -4,12 +4,17 @@
import { snackbar } from '$features/ui/snackbar/store';
import * as Field from '@cio/ui/base/field';
import { Switch } from '@cio/ui/base/switch';
import { Button } from '@cio/ui/base/button';
import { Textarea } from '@cio/ui/base/textarea';
import LockIcon from '@lucide/svelte/icons/lock';
import { orgApi } from '$features/org/api/org.svelte';
import * as Alert from '@cio/ui/base/alert';
interface Props {
hasUnsavedChanges?: boolean;
}
let { hasUnsavedChanges = $bindable(false) }: Props = $props();
// Auth settings state - synced with store
let disableSignup = $state($currentOrg?.disableSignup ?? false);
let disableSignupMessage = $state($currentOrg?.disableSignupMessage ?? '');
@@ -31,7 +36,22 @@
}
});
async function handleSave() {
let currentAllowPublicSignups = $derived(!$currentOrg?.settings?.signup?.inviteOnly);
let hasChanges = $derived(
disableSignup !== ($currentOrg?.disableSignup ?? false) ||
disableSignupMessage !== ($currentOrg?.disableSignupMessage ?? '') ||
allowPublicSignups !== currentAllowPublicSignups ||
disableEmailPassword !== ($currentOrg?.disableEmailPassword ?? false) ||
disableGoogleAuth !== ($currentOrg?.disableGoogleAuth ?? false) ||
internalEnrollmentOnly !== ($currentOrg?.settings?.internalEnrollmentOnly ?? false)
);
$effect(() => {
hasUnsavedChanges = hasChanges;
});
export async function handleSave() {
if (!$currentOrg) return;
isSaving = true;
@@ -64,15 +84,17 @@
isSaving = false;
}
let currentAllowPublicSignups = $derived(!$currentOrg?.settings?.signup?.inviteOnly);
let hasChanges = $derived(
disableSignup !== ($currentOrg?.disableSignup ?? false) ||
disableSignupMessage !== ($currentOrg?.disableSignupMessage ?? '') ||
allowPublicSignups !== currentAllowPublicSignups ||
disableEmailPassword !== ($currentOrg?.disableEmailPassword ?? false) ||
disableGoogleAuth !== ($currentOrg?.disableGoogleAuth ?? false) ||
internalEnrollmentOnly !== ($currentOrg?.settings?.internalEnrollmentOnly ?? false)
);
export function handleDiscard() {
if (!$currentOrg) return;
disableSignup = $currentOrg.disableSignup ?? false;
disableSignupMessage = $currentOrg.disableSignupMessage ?? '';
allowPublicSignups = !$currentOrg.settings?.signup?.inviteOnly;
disableEmailPassword = $currentOrg.disableEmailPassword ?? false;
disableGoogleAuth = $currentOrg.disableGoogleAuth ?? false;
internalEnrollmentOnly = $currentOrg.settings?.internalEnrollmentOnly ?? false;
hasUnsavedChanges = false;
}
</script>
<Field.Group class="w-full max-w-2xl! px-2">
@@ -158,14 +180,6 @@
</div>
</Field.Field>
</div>
{#if hasChanges}
<div class="mt-6 flex justify-end">
<Button variant="default" onclick={handleSave} loading={isSaving} disabled={isSaving || !$isEnterprisePlan}>
{$t('settings.auth.general.save_button')}
</Button>
</div>
{/if}
</Field.Set>
<Field.Separator />
@@ -2,6 +2,7 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
import { licenseApi } from '$features/license/api/license.svelte';
import { currentOrgPath } from '$lib/utils/store/org';
@@ -11,6 +12,10 @@
import AuthSso from '../components/auth-sso.svelte';
import AuthTokenAuth from '../components/auth-token-auth.svelte';
let authGeneralComponent: AuthGeneral | null = $state(null);
let hasUnsavedChanges = $state(false);
let isSaving = $state(false);
const ssoLicensed = $derived(licenseApi.hasAccess('sso'));
const tokenAuthLicensed = $derived(licenseApi.hasAccess('token-auth'));
const licenseResolved = $derived(licenseApi.isResolved);
@@ -55,6 +60,19 @@
break;
}
}
async function handleSave() {
isSaving = true;
try {
await authGeneralComponent?.handleSave();
} finally {
isSaving = false;
}
}
function handleDiscard() {
authGeneralComponent?.handleDiscard();
}
</script>
<UpgradeBanner>{$t('upgrade.enterprise_required')}</UpgradeBanner>
@@ -77,7 +95,7 @@
</UnderlineTabs.List>
<UnderlineTabs.Content value="general">
<AuthGeneral />
<AuthGeneral bind:this={authGeneralComponent} bind:hasUnsavedChanges />
</UnderlineTabs.Content>
{#if ssoLicensed}
@@ -92,3 +110,16 @@
</UnderlineTabs.Content>
{/if}
</UnderlineTabs.Root>
{#if currentTab === 'general'}
<Page.SettingsActions
hasChanges={hasUnsavedChanges}
loading={isSaving}
disabled={isSaving}
statusLabel={$t('common.unsaved_changes.label')}
discardLabel={$t('common.discard')}
saveLabel={$t('common.save_changes')}
onSave={handleSave}
onDiscard={handleDiscard}
/>
{/if}
@@ -11,7 +11,14 @@
import { UploadWidget } from '$features/ui';
import * as Field from '@cio/ui/base/field';
interface Props {
hasUnsavedChanges?: boolean;
}
let { hasUnsavedChanges = $bindable(false) }: Props = $props();
let widgetKey = $state('');
let savedCustomizationSnapshot = $state('');
function widgetControl(key: string) {
widgetKey = key;
@@ -31,10 +38,40 @@
$currentOrg.customization.auth.backgroundImage = '';
}
function captureCustomizationSnapshot() {
savedCustomizationSnapshot = JSON.stringify($currentOrg.customization);
}
$effect(() => {
if (!$currentOrg?.id) return;
if (!savedCustomizationSnapshot) {
captureCustomizationSnapshot();
}
});
$effect(() => {
if (!$currentOrg?.id || !savedCustomizationSnapshot) return;
hasUnsavedChanges = JSON.stringify($currentOrg.customization) !== savedCustomizationSnapshot;
});
export async function handleSave() {
await orgApi.update($currentOrg.id, {
customization: $currentOrg.customization
});
if (orgApi.success) {
captureCustomizationSnapshot();
hasUnsavedChanges = false;
}
}
export function handleDiscard() {
if (!savedCustomizationSnapshot) return;
$currentOrg.customization = JSON.parse(savedCustomizationSnapshot);
hasUnsavedChanges = false;
}
</script>
@@ -14,7 +14,6 @@
import { orgApi } from '$features/org/api/org.svelte';
import * as Field from '@cio/ui/base/field';
import { Switch } from '@cio/ui/base/switch';
import { Button } from '@cio/ui/base/button';
import BellIcon from '@lucide/svelte/icons/bell';
import Building2Icon from '@lucide/svelte/icons/building-2';
import { onMount } from 'svelte';
@@ -32,6 +31,12 @@
let isLoadingPersonal = $state(false);
let hasHydratedOrgToggles = $state(false);
interface Props {
hasUnsavedChanges?: boolean;
}
let { hasUnsavedChanges = $bindable(false) }: Props = $props();
const tutorOnlyToggleKeys = new Set<string>(TUTOR_ONLY_PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS);
const personalNotificationSections = $derived(
@@ -125,6 +130,26 @@
)
);
$effect(() => {
hasUnsavedChanges = personalHasChanges || orgHasChanges;
});
export async function handleSaveAll() {
if (personalHasChanges) {
await handleSavePersonal();
}
if (orgHasChanges) {
await handleSaveOrg();
}
}
export function handleDiscard() {
applyPersonalToggleState(savedPersonalToggles);
syncOrgTogglesFromCurrentOrg();
hasUnsavedChanges = false;
}
function toStoredPreferences<T extends string>(keys: readonly T[], toggles: Record<T, boolean>) {
const preferences: Partial<Record<T, boolean>> = {};
@@ -216,17 +241,6 @@
</Field.Set>
{/each}
</div>
<div class="mt-6">
<Button
variant="secondary"
loading={isSavingPersonal}
disabled={!personalHasChanges || isSavingPersonal || isLoadingPersonal || !$currentOrg?.id}
onclick={handleSavePersonal}
>
{$t('settings.notifications.personal.save')}
</Button>
</div>
</Field.Set>
{#if $isOrgAdmin}
@@ -269,17 +283,6 @@
</Field.Set>
{/each}
</div>
<div class="mt-6">
<Button
variant="secondary"
loading={isSavingOrg}
disabled={!orgHasChanges || isSavingOrg}
onclick={handleSaveOrg}
>
{$t('settings.notifications.org.save')}
</Button>
</div>
</Field.Set>
{/if}
</Field.Group>
@@ -12,8 +12,15 @@
import { UploadImage, UnsavedChanges } from '$features/ui';
import * as Field from '@cio/ui/base/field';
interface Props {
hasUnsavedChanges?: boolean;
}
let { hasUnsavedChanges = $bindable(false) }: Props = $props();
let avatar = $state<string | File | undefined>();
let hasUnsavedChanges = $state(false);
let savedName = $state('');
let capturedOrgId = $state<string | null>(null);
const themes = {
rose: 'rose',
@@ -57,9 +64,30 @@
if (orgApi.success) {
hasUnsavedChanges = false;
avatar = undefined;
savedName = $currentOrg.name;
}
}
function captureSavedFields() {
savedName = $currentOrg.name;
}
$effect(() => {
if (!$currentOrg?.id) return;
if (capturedOrgId !== $currentOrg.id) {
capturedOrgId = $currentOrg.id;
captureSavedFields();
}
});
export function handleDiscard() {
$currentOrg.name = savedName;
avatar = undefined;
hasUnsavedChanges = false;
orgApi.errors = {};
}
let isCustomTheme = $derived($currentOrg?.theme?.includes('#'));
let hex = $derived($currentOrg.theme?.includes('#') ? $currentOrg.theme : undefined);
</script>
@@ -141,7 +169,7 @@
</button>
<div
style={isCustomTheme ? `border-color: ${$currentOrg.theme};` : ''}
style={isCustomTheme ? `border-color: ${$currentOrg.theme}; --picker-z-index: 50;` : '--picker-z-index: 50;'}
class="group relative h-auto w-fit cursor-pointer rounded-full border-2 {!isCustomTheme
? 'custom-theme-picker--empty'
: ''}"
@@ -11,10 +11,19 @@
import { UploadImage, UnsavedChanges } from '$features/ui';
import * as Field from '@cio/ui/base/field';
interface Props {
hasUnsavedChanges?: boolean;
}
let { hasUnsavedChanges = $bindable(false) }: Props = $props();
let avatar = $state<string | File | undefined>();
let hasLangChanged = $state(false);
let locale = $derived<TLocale | undefined>($profile.locale || undefined);
let hasUnsavedChanges = $state(false);
let savedFullname = $state('');
let savedUsername = $state('');
let savedLocale = $state<TLocale | undefined>(undefined);
let capturedProfileId = $state<string | null>(null);
let email = $derived($profile.email || '');
let isChangingEmail = $state(false);
let emailChangeInitiated = $state(false);
@@ -34,6 +43,7 @@
if (profileApi.success) {
hasUnsavedChanges = false;
avatar = undefined;
captureSavedFields();
}
}
@@ -58,6 +68,31 @@
}
const isVerificationSent = $derived(page.url.searchParams.get('trigger') === 'app');
function captureSavedFields() {
savedFullname = $profile.fullname;
savedUsername = $profile.username;
savedLocale = $profile.locale || undefined;
}
$effect(() => {
if (!$profile.id) return;
if (capturedProfileId !== $profile.id) {
capturedProfileId = $profile.id;
captureSavedFields();
}
});
export function handleDiscard() {
$profile.fullname = savedFullname;
$profile.username = savedUsername;
$profile.locale = savedLocale;
avatar = undefined;
hasLangChanged = false;
hasUnsavedChanges = false;
profileApi.errors = {};
}
</script>
<UnsavedChanges bind:hasUnsavedChanges />
@@ -2,8 +2,11 @@
"common": {
"refresh": "Opdater",
"unsaved_changes": {
"message": "Du har ikke-gemte ændringer. Er du sikker på, at du vil tage afsted?"
}
"message": "Du har ikke-gemte ændringer. Er du sikker på, at du vil tage afsted?",
"label": "Ikke-gemte ændringer"
},
"discard": "Kassér",
"save_changes": "Gem ændringer"
},
"validations": {
"generic": {
@@ -2,8 +2,11 @@
"common": {
"refresh": "Aktualisieren",
"unsaved_changes": {
"message": "Sie haben nicht gespeicherte Änderungen. Sind Sie sicher, dass Sie gehen möchten?"
}
"message": "Sie haben nicht gespeicherte Änderungen. Sind Sie sicher, dass Sie gehen möchten?",
"label": "Nicht gespeicherte Änderungen"
},
"discard": "Verwerfen",
"save_changes": "Änderungen speichern"
},
"validations": {
"generic": {
@@ -289,8 +289,11 @@
},
"common": {
"unsaved_changes": {
"message": "You have unsaved changes. Are you sure you want to leave?"
"message": "You have unsaved changes. Are you sure you want to leave?",
"label": "Unsaved changes"
},
"discard": "Discard",
"save_changes": "Save changes",
"refresh": "Refresh"
},
"compliance": {
@@ -2,8 +2,11 @@
"common": {
"refresh": "Actualizar",
"unsaved_changes": {
"message": "Tienes cambios sin guardar. ¿Estás seguro de que quieres irte?"
}
"message": "Tienes cambios sin guardar. ¿Estás seguro de que quieres irte?",
"label": "Cambios sin guardar"
},
"discard": "Descartar",
"save_changes": "Guardar cambios"
},
"validations": {
"generic": {
@@ -2,8 +2,11 @@
"common": {
"refresh": "Actualiser",
"unsaved_changes": {
"message": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir partir ?"
}
"message": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir partir ?",
"label": "Modifications non enregistrées"
},
"discard": "Abandonner les modifications",
"save_changes": "Enregistrer les modifications"
},
"validations": {
"generic": {
@@ -2,8 +2,11 @@
"common": {
"refresh": "रीफ़्रेश करें",
"unsaved_changes": {
"message": "आपके पास सहेजे नहीं गए परिवर्तन हैं. क्या आपकी वाकई छोड़ने की इच्छा है?"
}
"message": "आपके पास सहेजे नहीं गए परिवर्तन हैं. क्या आपकी वाकई छोड़ने की इच्छा है?",
"label": "सहेजे नहीं गए परिवर्तन"
},
"discard": "त्यागें",
"save_changes": "परिवर्तन सहेजें"
},
"validations": {
"generic": {
@@ -2,8 +2,11 @@
"common": {
"refresh": "Odśwież",
"unsaved_changes": {
"message": "Masz niezapisane zmiany. Czy na pewno chcesz wyjść?"
}
"message": "Masz niezapisane zmiany. Czy na pewno chcesz wyjść?",
"label": "Niezapisane zmiany"
},
"discard": "Odrzuć",
"save_changes": "Zapisz zmiany"
},
"validations": {
"generic": {
@@ -2,8 +2,11 @@
"common": {
"refresh": "Atualizar",
"unsaved_changes": {
"message": "Você tem alterações não salvas. Tem certeza de que deseja sair?"
}
"message": "Você tem alterações não salvas. Tem certeza de que deseja sair?",
"label": "Alterações não salvas"
},
"discard": "Descartar",
"save_changes": "Salvar alterações"
},
"validations": {
"generic": {
@@ -2,8 +2,11 @@
"common": {
"refresh": "Обновить",
"unsaved_changes": {
"message": "У вас есть несохраненные изменения. Вы уверены, что хотите уйти?"
}
"message": "У вас есть несохраненные изменения. Вы уверены, что хотите уйти?",
"label": "Несохраненные изменения"
},
"discard": "Отменить",
"save_changes": "Сохранить изменения"
},
"validations": {
"generic": {
@@ -2,8 +2,11 @@
"common": {
"refresh": "Làm mới",
"unsaved_changes": {
"message": "Bạn có những thay đổi chưa được lưu. Bạn có chắc chắn muốn rời đi không?"
}
"message": "Bạn có những thay đổi chưa được lưu. Bạn có chắc chắn muốn rời đi không?",
"label": "Thay đổi chưa lưu"
},
"discard": "Hủy",
"save_changes": "Lưu thay đổi"
},
"validations": {
"generic": {
@@ -1,11 +1,11 @@
<script lang="ts">
import * as Page from '@cio/ui/base/page';
import { Button } from '@cio/ui/base/button';
import { Input } from '@cio/ui/base/input';
import { Textarea } from '@cio/ui/base/textarea';
import { Label } from '@cio/ui/base/label';
import * as Select from '@cio/ui/base/select';
import { Separator } from '@cio/ui/base/separator';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import { cohortApi } from '$features/cohort/api';
import { goto } from '$app/navigation';
@@ -70,6 +70,15 @@
}
}
function handleDiscard() {
const currentCohort = cohortApi.cohort;
if (!currentCohort) return;
name = currentCohort.name;
description = currentCohort.description ?? '';
status = (currentCohort.status as 'ACTIVE' | 'INACTIVE' | 'ARCHIVED') ?? 'ACTIVE';
}
async function handleDelete() {
if (
!confirm(
@@ -84,15 +93,10 @@
</script>
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
<Page.Header class="sticky top-13 z-10 bg-white">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('cohorts.sidebar.settings') || 'Settings'}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button variant="secondary" loading={isSaving} disabled={isSaving || !hasUnsavedChanges} onclick={handleSave}>
{$t('cohorts.settings.save') || 'Save Changes'}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
@@ -149,4 +153,14 @@
</div>
{/snippet}
</Page.Body>
<Page.SettingsActions
hasChanges={hasUnsavedChanges}
loading={isSaving}
disabled={isSaving}
statusLabel={$t('common.unsaved_changes.label')}
discardLabel={$t('common.discard')}
saveLabel={$t('common.save_changes')}
onSave={handleSave}
onDiscard={handleDiscard}
/>
</Page.Root>
@@ -7,8 +7,6 @@
import { parseCertificateThemeId } from '$features/course/utils/certificate-utils';
import { isFreePlan, isOrgAdmin } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { Button } from '@cio/ui/base/button';
import ZapIcon from '@lucide/svelte/icons/zap';
import { RefreshPageData, UnsavedChanges } from '$features/ui';
import { ZCourseUpdate } from '@cio/utils/validation/course/course';
import { validateWithTranslation } from '$lib/utils/validation';
@@ -16,6 +14,7 @@
import { openUpgradeModal } from '$lib/utils/functions/org';
let errors = $state<Record<string, string>>({});
let certificateActiveTab = $state('design');
let hasUnsavedChanges = $state(false);
let savedCertificateState = $state<string | null>(null);
let savedCertificateStateCourseId = $state<string | null>(null);
@@ -122,6 +121,19 @@
snackbar.success('snackbar.course_settings.success.saved');
}
}
function handleDiscard() {
if (!savedCertificateState || !courseApi.course) return;
const snapshot = JSON.parse(savedCertificateState) as ReturnType<typeof getCertificateFormState>;
if (!snapshot) return;
courseApi.course.description = snapshot.description;
courseApi.course.certificate = { ...snapshot.certificate };
hasUnsavedChanges = false;
errors = {};
}
</script>
<svelte:head>
@@ -143,26 +155,24 @@
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<div class="flex w-full justify-end gap-2">
{#if canEditCertificates}
<Button
onclick={saveCertificate}
loading={courseApi.isLoading}
disabled={courseApi.isLoading || !hasUnsavedChanges}
>
{#if $isFreePlan}
<ZapIcon size={16} class="filled" />
{/if}
{$t('course.navItem.certificates.save')}
</Button>
{/if}
<RefreshPageData />
</div>
<RefreshPageData />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<CertificatesPage {errors} />
<CertificatesPage {errors} bind:activeTab={certificateActiveTab} />
{/snippet}
</Page.Body>
{#if canEditCertificates && certificateActiveTab === 'settings'}
<Page.SettingsActions
hasChanges={hasUnsavedChanges}
loading={courseApi.isLoading}
disabled={courseApi.isLoading}
statusLabel={$t('common.unsaved_changes.label')}
discardLabel={$t('common.discard')}
saveLabel={$t('common.save_changes')}
onSave={saveCertificate}
onDiscard={handleDiscard}
/>
{/if}
</Page.Root>
@@ -2,7 +2,6 @@
import { CourseSettingsPage } from '$features/course/pages';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button';
import { courseApi } from '$features/course/api';
import { profile } from '$lib/utils/store/user';
import { RefreshPageData } from '$features/ui';
@@ -20,22 +19,21 @@
isSaving = false;
}
}
function handleDiscard() {
settingsComponent?.handleDiscard();
}
</script>
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
<Page.Header isSticky class="ui:bg-background z-10">
<Page.Header>
<Page.HeaderContent>
<Page.Title>
{$t('course.navItem.settings.heading')}
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<div class="flex items-center gap-2">
<Button variant="secondary" loading={isSaving} disabled={isSaving || !hasUnsavedChanges} onclick={handleSave}>
{$t('course.navItem.settings.save')}
</Button>
<RefreshPageData disabled={isSaving} onRefresh={() => courseApi.refreshCourse(data.courseId, $profile.id)} />
</div>
<RefreshPageData disabled={isSaving} onRefresh={() => courseApi.refreshCourse(data.courseId, $profile.id)} />
</Page.Action>
</Page.Header>
<Page.Body>
@@ -43,4 +41,14 @@
<CourseSettingsPage bind:this={settingsComponent} bind:hasUnsavedChanges />
{/snippet}
</Page.Body>
<Page.SettingsActions
hasChanges={hasUnsavedChanges}
loading={isSaving}
disabled={isSaving}
statusLabel={$t('common.unsaved_changes.label')}
discardLabel={$t('common.discard')}
saveLabel={$t('common.save_changes')}
onSave={handleSave}
onDiscard={handleDiscard}
/>
</Page.Root>
@@ -1,11 +1,11 @@
<script lang="ts">
import { ProfilePage } from '$features/settings/pages';
import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button';
import * as Page from '@cio/ui/base/page';
let profileComponent: ProfilePage | null = $state(null);
let isLoading = $state(false);
let hasUnsavedChanges = $state(false);
async function handleUpdate() {
isLoading = true;
@@ -15,24 +15,32 @@
isLoading = false;
}
}
function handleDiscard() {
profileComponent?.handleDiscard();
}
</script>
<svelte:head>
<title>Settings - ClassroomIO</title>
</svelte:head>
<Page.Header isSticky>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('settings.profile.heading')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button variant="secondary" loading={isLoading} onclick={handleUpdate}>
{$t('settings.profile.update_profile')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<ProfilePage bind:this={profileComponent} />
<ProfilePage bind:this={profileComponent} bind:hasUnsavedChanges />
{/snippet}
</Page.Body>
<Page.SettingsActions
hasChanges={hasUnsavedChanges}
loading={isLoading}
statusLabel={$t('common.unsaved_changes.label')}
discardLabel={$t('common.discard')}
saveLabel={$t('common.save_changes')}
onSave={handleUpdate}
onDiscard={handleDiscard}
/>
@@ -2,6 +2,23 @@
import { NotificationsPage } from '$features/settings/pages';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
let notificationsComponent: NotificationsPage | null = $state(null);
let hasUnsavedChanges = $state(false);
let isSaving = $state(false);
async function handleSave() {
isSaving = true;
try {
await notificationsComponent?.handleSaveAll();
} finally {
isSaving = false;
}
}
function handleDiscard() {
notificationsComponent?.handleDiscard();
}
</script>
<svelte:head>
@@ -11,11 +28,21 @@
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('settings.notifications.heading')}</Page.Title>
<Page.Subtitle>{$t('settings.notifications.personal_page_subtitle')}</Page.Subtitle>
<Page.Subtitle>{$t('settings.notifications.page_subtitle')}</Page.Subtitle>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<NotificationsPage />
<NotificationsPage bind:this={notificationsComponent} bind:hasUnsavedChanges />
{/snippet}
</Page.Body>
<Page.SettingsActions
hasChanges={hasUnsavedChanges}
loading={isSaving}
disabled={isSaving}
statusLabel={$t('common.unsaved_changes.label')}
discardLabel={$t('common.discard')}
saveLabel={$t('common.save_changes')}
onSave={handleSave}
onDiscard={handleDiscard}
/>
@@ -1,13 +1,11 @@
<script lang="ts">
import { page } from '$app/state';
import { Button } from '@cio/ui/base/button';
interface Props {
children?: import('svelte').Snippet;
}
import * as Page from '@cio/ui/base/page';
import { OrgSettingsInlineTabs } from '$features/settings/components';
import { settingsHeaderAction } from '$features/settings/utils/store';
import { t } from '$lib/utils/functions/translations';
let { children }: Props = $props();
@@ -27,16 +25,6 @@
<Page.Title>{$t('settings.organization.organization_profile.heading')}</Page.Title>
<Page.Subtitle>{$t('settings.organization.page_subtitle')}</Page.Subtitle>
</Page.HeaderContent>
<Page.Action>
<Button
variant="secondary"
loading={$settingsHeaderAction.loading}
disabled={$settingsHeaderAction.disabled || !$settingsHeaderAction.onClick}
onclick={() => $settingsHeaderAction.onClick?.()}
>
{$settingsHeaderAction.label}
</Button>
</Page.Action>
</Page.Header>
<OrgSettingsInlineTabs />
@@ -2,11 +2,11 @@
import { ProfilePage } from '$features/settings/pages';
import { VisitOrgSiteButton } from '$features/ui';
import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button';
import * as Page from '@cio/ui/base/page';
let profileComponent: ProfilePage | null = $state(null);
let isLoading = $state(false);
let hasUnsavedChanges = $state(false);
async function handleUpdate() {
isLoading = true;
@@ -16,27 +16,36 @@
isLoading = false;
}
}
function handleDiscard() {
profileComponent?.handleDiscard();
}
</script>
<svelte:head>
<title>Settings - ClassroomIO</title>
</svelte:head>
<Page.Header isSticky>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('settings.profile.heading')}</Page.Title>
<Page.Subtitle>{$t('settings.profile.page_subtitle')}</Page.Subtitle>
</Page.HeaderContent>
<Page.Action>
<Button variant="secondary" loading={isLoading} onclick={handleUpdate}>
{$t('settings.profile.update_profile')}
</Button>
<VisitOrgSiteButton />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<ProfilePage bind:this={profileComponent} />
<ProfilePage bind:this={profileComponent} bind:hasUnsavedChanges />
{/snippet}
</Page.Body>
<Page.SettingsActions
hasChanges={hasUnsavedChanges}
loading={isLoading}
statusLabel={$t('common.unsaved_changes.label')}
discardLabel={$t('common.discard')}
saveLabel={$t('common.save_changes')}
onSave={handleUpdate}
onDiscard={handleDiscard}
/>
@@ -1,10 +1,11 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { CustomizeLmsPage } from '$features/settings/pages';
import { settingsHeaderAction } from '$features/settings/utils/store';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
let customizeLmsComponent: CustomizeLmsPage | null = $state(null);
let isSaving = $state(false);
let hasUnsavedChanges = $state(false);
async function handleSave() {
isSaving = true;
@@ -15,27 +16,22 @@
}
}
$effect(() => {
settingsHeaderAction.set({
label: 'Save',
disabled: isSaving,
loading: isSaving,
onClick: handleSave
});
});
onDestroy(() => {
settingsHeaderAction.set({
label: 'Save',
disabled: true,
loading: false,
onClick: null
});
});
function handleDiscard() {
customizeLmsComponent?.handleDiscard();
}
</script>
<svelte:head>
<title>Customize LMS - ClassroomIO</title>
</svelte:head>
<CustomizeLmsPage bind:this={customizeLmsComponent} />
<CustomizeLmsPage bind:this={customizeLmsComponent} bind:hasUnsavedChanges />
<Page.SettingsActions
hasChanges={hasUnsavedChanges}
loading={isSaving}
statusLabel={$t('common.unsaved_changes.label')}
discardLabel={$t('common.discard')}
saveLabel={$t('common.save_changes')}
onSave={handleSave}
onDiscard={handleDiscard}
/>
@@ -1,25 +1,5 @@
<script>
import { onDestroy } from 'svelte';
import { DomainsPage } from '$features/settings/pages';
import { settingsHeaderAction } from '$features/settings/utils/store';
$effect(() => {
settingsHeaderAction.set({
label: 'Save',
disabled: true,
loading: false,
onClick: null
});
});
onDestroy(() => {
settingsHeaderAction.set({
label: 'Save',
disabled: true,
loading: false,
onClick: null
});
});
</script>
<svelte:head>
@@ -2,6 +2,23 @@
import { NotificationsPage } from '$features/settings/pages';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
let notificationsComponent: NotificationsPage | null = $state(null);
let hasUnsavedChanges = $state(false);
let isSaving = $state(false);
async function handleSave() {
isSaving = true;
try {
await notificationsComponent?.handleSaveAll();
} finally {
isSaving = false;
}
}
function handleDiscard() {
notificationsComponent?.handleDiscard();
}
</script>
<svelte:head>
@@ -16,6 +33,16 @@
</Page.Header>
<Page.Body>
{#snippet child()}
<NotificationsPage />
<NotificationsPage bind:this={notificationsComponent} bind:hasUnsavedChanges />
{/snippet}
</Page.Body>
<Page.SettingsActions
hasChanges={hasUnsavedChanges}
loading={isSaving}
disabled={isSaving}
statusLabel={$t('common.unsaved_changes.label')}
discardLabel={$t('common.discard')}
saveLabel={$t('common.save_changes')}
onSave={handleSave}
onDiscard={handleDiscard}
/>
@@ -1,10 +1,11 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { OrgPage } from '$features/settings/pages';
import { settingsHeaderAction } from '$features/settings/utils/store';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
let orgComponent: OrgPage | null = $state(null);
let loading = $state(false);
let hasUnsavedChanges = $state(false);
async function handleUpdate() {
loading = true;
@@ -15,27 +16,22 @@
}
}
$effect(() => {
settingsHeaderAction.set({
label: 'Save',
disabled: loading,
loading,
onClick: handleUpdate
});
});
onDestroy(() => {
settingsHeaderAction.set({
label: 'Save',
disabled: true,
loading: false,
onClick: null
});
});
function handleDiscard() {
orgComponent?.handleDiscard();
}
</script>
<svelte:head>
<title>Organization Settings - ClassroomIO</title>
</svelte:head>
<OrgPage bind:this={orgComponent} />
<OrgPage bind:this={orgComponent} bind:hasUnsavedChanges />
<Page.SettingsActions
hasChanges={hasUnsavedChanges}
{loading}
statusLabel={$t('common.unsaved_changes.label')}
discardLabel={$t('common.discard')}
saveLabel={$t('common.save_changes')}
onSave={handleUpdate}
onDiscard={handleDiscard}
/>
@@ -1,25 +1,5 @@
<script>
import { onDestroy } from 'svelte';
import { TeamsPage } from '$features/settings/pages';
import { settingsHeaderAction } from '$features/settings/utils/store';
$effect(() => {
settingsHeaderAction.set({
label: 'Save',
disabled: true,
loading: false,
onClick: null
});
});
onDestroy(() => {
settingsHeaderAction.set({
label: 'Save',
disabled: true,
loading: false,
onClick: null
});
});
</script>
<svelte:head>