Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/30665-clear-saved-beneath-env-pin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/console': patch
'@object-ui/i18n': patch
---

feat(console): a settings field an environment variable pins offers to clear the value saved beneath the pin

Clause-②: yes (widening)
Metadata-protocol: none

- **`@object-ui/console`:** on a Setup settings page, a field an `OS_*` environment variable pins still shows the lock badge and stays read-only. When the namespace read lists a value stored beneath the pin at the scope the page writes (a secret's as the read mask), the row also says that a value is saved there and that the environment value stays in force, and offers "Clear the saved value". The control asks first, then writes `null` for that one key, the one write the server lets through a pin; the page's unsaved edits are left as they are. After the clear the row still reads the environment value, and the saved value no longer comes back when the variable is removed. A pinned field with nothing stored at that scope, a field no variable pins, and the first-run checklist show no control.
- **`@object-ui/i18n`:** four new `console.settingsView` keys in all eleven packs: `savedBeneathPin`, `clearSaved`, `clearSavedConfirm` and `savedCleared`.
80 changes: 78 additions & 2 deletions ui/apps/console/src/pages/settings/SettingsField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,63 @@ export interface SettingsFieldProps {
* not (the first-run checklist) keeps the badges a lower scope would see.
*/
writeScope?: SpecifierScope;
/**
* Clears the value stored beneath an env pin (objectstack#30665): the page
* writes `null` for this key, the one write the server lets through a pin
* (ADR-0007 D6). Offered only where {@link storedBeneathPin} holds, so a
* host that does not pass it, or a row with nothing stored, shows nothing.
*/
onClearSaved?: () => void;
}

/**
* Whether a value is stored beneath the env pin at the scope this row writes,
* the one a clear would null (objectstack#30665). Read off the server's
* `cascadeChain` alone: the namespace read lists the stored cascade beneath an
* in-force `env` entry, a secret's entries as the read mask or `null`. Nothing
* stored, or stored only at another scope, is no control: a clear here could
* not remove it.
*/
export function storedBeneathPin(
resolved: ResolvedSettingValue | undefined,
writeScope: SpecifierScope | undefined,
): boolean {
if (!resolved?.locked || resolved.source !== 'env' || !writeScope) return false;
return (resolved.cascadeChain ?? []).some(
(entry) => entry.scope === writeScope && entry.value !== null && entry.value !== undefined,
);
}

/**
* The line and control under a pinned row that has a value stored beneath the
* pin (objectstack#30665). The wording clears the saved value, never deletes
* it, and says the environment value stays in force; the clear asks first,
* because it writes at once rather than through the page's draft.
*/
function ClearSavedBeneathPin({ onClear, disabled }: { onClear: () => void; disabled?: boolean }) {
const { t } = useObjectTranslation();
return (
<div className="flex items-center justify-between gap-3 mt-1" data-testid="settings-saved-beneath-pin">
<p className="text-xs text-muted-foreground">{t('console.settingsView.savedBeneathPin')}</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button type="button" variant="outline" size="sm" className="h-7 shrink-0 text-xs" disabled={disabled}>
{t('console.settingsView.clearSaved')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('console.settingsView.clearSaved')}</AlertDialogTitle>
<AlertDialogDescription>{t('console.settingsView.clearSavedConfirm')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('actionConfirm.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={onClear}>{t('console.settingsView.clearSaved')}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

type SourceBadge =
Expand Down Expand Up @@ -519,8 +576,20 @@ function ToggleSwitch({
}

export function SettingsField(props: SettingsFieldProps) {
const { spec, resolved, value, onChange, onAction, locked, saving, labels, error, note, writeScope } =
props;
const {
spec,
resolved,
value,
onChange,
onAction,
locked,
saving,
labels,
error,
note,
writeScope,
onClearSaved,
} = props;
const id = useId();
const { t, language } = useObjectTranslation();
const disabled = Boolean(locked || saving);
Expand Down Expand Up @@ -691,6 +760,11 @@ export function SettingsField(props: SettingsFieldProps) {
// control is a group that names itself from `labelId` instead.
// A value already cleared in the draft has nothing left to reset.
const onReset = value === null ? undefined : () => onChange(null);
// The clear beneath an env pin, where a value is stored at this row's scope.
const clearSaved =
onClearSaved && storedBeneathPin(resolved, writeScope) ? (
<ClearSavedBeneathPin onClear={onClearSaved} disabled={saving} />
) : null;
// `data-settings-key` lets the page find this row to focus it when a save is
// blocked on it (objectui#13239).
const wrapper = (children: React.ReactNode, labelFor: string | undefined = id) => (
Expand Down Expand Up @@ -723,6 +797,7 @@ export function SettingsField(props: SettingsFieldProps) {
{note}
</p>
) : null}
{clearSaved}
</div>
);

Expand Down Expand Up @@ -832,6 +907,7 @@ export function SettingsField(props: SettingsFieldProps) {
) : (
<FieldDescription description={fieldHelp} />
)}
{clearSaved}
</div>
<ToggleSwitch
id={id}
Expand Down
28 changes: 27 additions & 1 deletion ui/apps/console/src/pages/settings/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
* Fetches manifest+values from `/api/settings/:namespace`, lays them
* out via <SettingsField>, tracks dirty state, and saves through PUT.
* A successful save or action reads the namespace again, manifest included.
* Env-locked fields are read-only and show <EnvLockBadge>.
* Env-locked fields are read-only and show <EnvLockBadge>; one with a value
* stored beneath the pin offers to clear that saved value.
*/

import { useEffect, useMemo, useRef, useState } from 'react';
Expand Down Expand Up @@ -596,6 +597,30 @@ export function SettingsView() {
}
};

/**
* Clear the value stored beneath an env pin (objectstack#30665): `null` for
* this one key, the write the server lets through a pin (ADR-0007 D6). It
* writes at once, after the row's own confirm, and leaves the draft alone: the
* row cannot be edited, so the clear is not one of the page's edits. The pin
* still answers the key, so the row reads the env value before and after.
*/
const onClearSaved = async (key: string) => {
setSaving(true);
try {
const res = await saveSettingsNamespace(namespace, { [key]: null });
setPayload({ ...payload, values: { ...values, ...res.values } });
toast.success(t('console.settingsView.savedCleared'));
void reread(namespace);
} catch (err: any) {
// The pin itself never refuses a clear, so a refusal here is something
// else (a lock a stored row carries, a 403): the server's sentence says
// which, and `lockedByEnv` would misname it.
toast.error(err?.message ?? t('console.settingsView.saveFailed'));
} finally {
setSaving(false);
}
};

const onAction = async (actionId: string) => {
setSaving(true);
try {
Expand Down Expand Up @@ -734,6 +759,7 @@ export function SettingsView() {
// The scope the server writes this key at: the specifier's
// own, else the manifest's, else the spec's default `tenant`.
writeScope={spec.scope ?? manifest.scope ?? 'tenant'}
onClearSaved={key ? () => onClearSaved(key) : undefined}
/>
);
})}
Expand Down
Loading
Loading