mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 15:46:43 +00:00
feat(settings): add Stacks section for stack workflow preferences (#1366)
Move the browser-local Deploy progress, Progress style, and Diff preview before save controls out of Appearance into a new Stacks section under the Infrastructure group. These are stack lifecycle and editor workflow preferences, not visual style, so Settings now groups them where operators expect to find them. Add a browser-local masthead scope so these localStorage-backed sections read SCOPE browser instead of the misleading global, and apply it to both Appearance and Stacks. Control behavior, storage keys, and backing hooks are unchanged; this is an information-architecture move only.
This commit is contained in:
@@ -1,13 +1,9 @@
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||
import { TogglePill } from '@/components/ui/toggle-pill';
|
||||
import { useDensity } from '@/hooks/use-density';
|
||||
import type { Density } from '@/hooks/use-density';
|
||||
import { useDeployFeedbackEnabled } from '@/hooks/use-deploy-feedback-enabled';
|
||||
import { useDeployFeedbackStyle, type DeployFeedbackStyle } from '@/hooks/use-deploy-feedback-style';
|
||||
import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
|
||||
import { useTopNavLabels } from '@/hooks/use-top-nav-labels';
|
||||
import { useTopNavAlign, type TopNavAlign } from '@/hooks/use-top-nav-align';
|
||||
import { useTheme, THEME_MODE_OPTIONS, ACCENTS, CONTRAST, BORDER_BOOST, GLOW, TYPE_SCALE } from '@/hooks/use-theme';
|
||||
@@ -29,11 +25,6 @@ const DENSITY_DESCRIPTIONS: Record<Density, string> = {
|
||||
compact: 'Tighter rows and tiles. Fits more on screen for dense dashboards.',
|
||||
};
|
||||
|
||||
const DEPLOY_STYLE_OPTIONS: { value: DeployFeedbackStyle; label: string }[] = [
|
||||
{ value: 'modal', label: 'Modal' },
|
||||
{ value: 'inline', label: 'Inline' },
|
||||
];
|
||||
|
||||
const TOP_NAV_ALIGN_OPTIONS: { value: TopNavAlign; label: string }[] = [
|
||||
{ value: 'left', label: 'Left' },
|
||||
{ value: 'center', label: 'Center' },
|
||||
@@ -43,9 +34,6 @@ const fmtSigned = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(2)}`;
|
||||
|
||||
export function AppearanceSection() {
|
||||
const [density, setDensity] = useDensity();
|
||||
const [isEnabled, setEnabled] = useDeployFeedbackEnabled();
|
||||
const [feedbackStyle, setFeedbackStyle] = useDeployFeedbackStyle();
|
||||
const [diffPreviewEnabled, setDiffPreviewEnabled] = useComposeDiffPreviewEnabled();
|
||||
const [topNavLabels, setTopNavLabels] = useTopNavLabels();
|
||||
const [topNavAlign, setTopNavAlign] = useTopNavAlign();
|
||||
const {
|
||||
@@ -223,58 +211,6 @@ export function AppearanceSection() {
|
||||
/>
|
||||
</SettingsField>
|
||||
)}
|
||||
|
||||
<SettingsField
|
||||
label="Deploy progress"
|
||||
helper="Stream live output for deploy, restart, update, install, and Git operations, with a warning when an operation goes quiet. On by default; turn it off to run operations without it."
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="deploy-feedback"
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(v) => setEnabled(v === true)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="deploy-feedback"
|
||||
className="text-sm text-stat-value cursor-pointer select-none"
|
||||
>
|
||||
{isEnabled ? 'Enabled' : 'Disabled'}
|
||||
</label>
|
||||
</div>
|
||||
</SettingsField>
|
||||
|
||||
{isEnabled && (
|
||||
<SettingsField
|
||||
label="Progress style"
|
||||
helper="Modal opens a centered overlay. Inline shows a quiet status on the stack detail with the full log a click away under View output."
|
||||
>
|
||||
<SegmentedControl
|
||||
value={feedbackStyle}
|
||||
options={DEPLOY_STYLE_OPTIONS}
|
||||
onChange={setFeedbackStyle}
|
||||
ariaLabel="Deploy progress style"
|
||||
/>
|
||||
</SettingsField>
|
||||
)}
|
||||
|
||||
<SettingsField
|
||||
label="Diff preview before save"
|
||||
helper="Show a side-by-side diff of compose and env edits before they reach disk."
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="compose-diff-preview"
|
||||
checked={diffPreviewEnabled}
|
||||
onCheckedChange={(v) => setDiffPreviewEnabled(v === true)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="compose-diff-preview"
|
||||
className="text-sm text-stat-value cursor-pointer select-none"
|
||||
>
|
||||
{diffPreviewEnabled ? 'Enabled' : 'Disabled'}
|
||||
</label>
|
||||
</div>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<p className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle/70">
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getSettingsGroup,
|
||||
isItemVisible,
|
||||
isItemLocked,
|
||||
scopeLabel,
|
||||
} from './index';
|
||||
import type { SectionId, SettingsItemMeta, VisibilityContext } from './index';
|
||||
import { SettingsSidebar } from './SettingsSidebar';
|
||||
@@ -228,14 +229,6 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
|
||||
);
|
||||
}
|
||||
|
||||
function scopeLabel(item: SettingsItemMeta): string {
|
||||
// Personal sections (account, appearance) apply to the signed-in operator or
|
||||
// this browser. Access sections (license, users, sso, api-tokens) are
|
||||
// instance-global, so they read as global like every other non-node group.
|
||||
if (item.group === 'personal') return 'operator';
|
||||
return 'global';
|
||||
}
|
||||
|
||||
function SettingsCommandItem({
|
||||
item,
|
||||
glyph,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
DeveloperSection,
|
||||
DataRetentionSection,
|
||||
AppStoreSection,
|
||||
StacksSection,
|
||||
SupportSection,
|
||||
AboutSection,
|
||||
RecoverySection,
|
||||
@@ -90,6 +91,7 @@ function renderSection(
|
||||
case 'data-retention': return <DataRetentionSection onDirtyChange={(d) => onDirtyChange('data-retention', d)} />;
|
||||
case 'nodes': return <NodeManager />;
|
||||
case 'app-store': return <AppStoreSection />;
|
||||
case 'stacks': return <StacksSection />;
|
||||
case 'recovery': return <RecoverySection />;
|
||||
case 'support': return <SupportSection />;
|
||||
case 'about': return <AboutSection />;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||
import { useDeployFeedbackEnabled } from '@/hooks/use-deploy-feedback-enabled';
|
||||
import { useDeployFeedbackStyle, type DeployFeedbackStyle } from '@/hooks/use-deploy-feedback-style';
|
||||
import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { SettingsField } from './SettingsField';
|
||||
|
||||
const DEPLOY_STYLE_OPTIONS: { value: DeployFeedbackStyle; label: string }[] = [
|
||||
{ value: 'modal', label: 'Modal' },
|
||||
{ value: 'inline', label: 'Inline' },
|
||||
];
|
||||
|
||||
export function StacksSection() {
|
||||
const [isEnabled, setEnabled] = useDeployFeedbackEnabled();
|
||||
const [feedbackStyle, setFeedbackStyle] = useDeployFeedbackStyle();
|
||||
const [diffPreviewEnabled, setDiffPreviewEnabled] = useComposeDiffPreviewEnabled();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<SettingsSection title="Workflow" kicker="this browser">
|
||||
<SettingsField
|
||||
label="Deploy progress"
|
||||
helper="Stream live output for deploy, restart, update, install, and Git operations, with a warning when an operation goes quiet. On by default; turn it off to run operations without it."
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="deploy-feedback"
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(v) => setEnabled(v === true)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="deploy-feedback"
|
||||
className="text-sm text-stat-value cursor-pointer select-none"
|
||||
>
|
||||
{isEnabled ? 'Enabled' : 'Disabled'}
|
||||
</label>
|
||||
</div>
|
||||
</SettingsField>
|
||||
|
||||
{isEnabled && (
|
||||
<SettingsField
|
||||
label="Progress style"
|
||||
helper="Modal opens a centered overlay. Inline shows a quiet status on the stack detail with the full log a click away under View output."
|
||||
>
|
||||
<SegmentedControl
|
||||
value={feedbackStyle}
|
||||
options={DEPLOY_STYLE_OPTIONS}
|
||||
onChange={setFeedbackStyle}
|
||||
ariaLabel="Deploy progress style"
|
||||
/>
|
||||
</SettingsField>
|
||||
)}
|
||||
|
||||
<SettingsField
|
||||
label="Diff preview before save"
|
||||
helper="Show a side-by-side diff of compose and env edits before they reach disk."
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="compose-diff-preview"
|
||||
checked={diffPreviewEnabled}
|
||||
onCheckedChange={(v) => setDiffPreviewEnabled(v === true)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="compose-diff-preview"
|
||||
className="text-sm text-stat-value cursor-pointer select-none"
|
||||
>
|
||||
{diffPreviewEnabled ? 'Enabled' : 'Disabled'}
|
||||
</label>
|
||||
</div>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<p className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle/70">
|
||||
ⓘ saved to this browser only · every device remembers its own choice
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Guards the move of the stack-workflow controls out of Appearance into Stacks.
|
||||
*
|
||||
* The three controls (Deploy progress, Progress style, Diff preview before save)
|
||||
* are browser-local localStorage preferences. Moving the JSX must not change the
|
||||
* storage keys they write, so the deploy/editor consumers keep reading the same
|
||||
* values. These tests assert the controls render in Stacks, still flip the same
|
||||
* keys, and no longer render in Appearance.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { StacksSection } from '../StacksSection';
|
||||
import { AppearanceSection } from '../AppearanceSection';
|
||||
import { DEPLOY_FEEDBACK_KEY } from '@/hooks/use-deploy-feedback-enabled';
|
||||
import { DEPLOY_FEEDBACK_STYLE_KEY } from '@/hooks/use-deploy-feedback-style';
|
||||
import { COMPOSE_DIFF_PREVIEW_KEY } from '@/hooks/use-compose-diff-preview-enabled';
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe('StacksSection', () => {
|
||||
it('renders the three workflow controls (Progress style while deploy progress is on)', () => {
|
||||
render(<StacksSection />);
|
||||
expect(screen.getByText('Deploy progress')).toBeInTheDocument();
|
||||
// Deploy progress defaults on, so Progress style is visible.
|
||||
expect(screen.getByText('Progress style')).toBeInTheDocument();
|
||||
expect(screen.getByText('Diff preview before save')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('flips the deploy-progress key and hides Progress style when disabled', () => {
|
||||
const { container } = render(<StacksSection />);
|
||||
const toggle = container.querySelector('#deploy-feedback') as HTMLElement;
|
||||
// Default on => no stored value yet.
|
||||
expect(window.localStorage.getItem(DEPLOY_FEEDBACK_KEY)).toBeNull();
|
||||
fireEvent.click(toggle);
|
||||
expect(window.localStorage.getItem(DEPLOY_FEEDBACK_KEY)).toBe('false');
|
||||
// Progress style is gated on the enabled state and drops out.
|
||||
expect(screen.queryByText('Progress style')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('round-trips the progress-style key between Inline and Modal', () => {
|
||||
render(<StacksSection />);
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Inline' }));
|
||||
expect(window.localStorage.getItem(DEPLOY_FEEDBACK_STYLE_KEY)).toBe('inline');
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Modal' }));
|
||||
expect(window.localStorage.getItem(DEPLOY_FEEDBACK_STYLE_KEY)).toBe('modal');
|
||||
});
|
||||
|
||||
it('hydrates each control from its stored value (read path survives the move)', () => {
|
||||
window.localStorage.setItem(DEPLOY_FEEDBACK_KEY, 'false');
|
||||
window.localStorage.setItem(COMPOSE_DIFF_PREVIEW_KEY, 'true');
|
||||
const { container } = render(<StacksSection />);
|
||||
// Deploy progress reads its stored 'false': checkbox unchecked, Progress style hidden.
|
||||
expect(container.querySelector('#deploy-feedback')?.getAttribute('aria-checked')).toBe('false');
|
||||
expect(screen.queryByText('Progress style')).not.toBeInTheDocument();
|
||||
// Diff preview reads its stored 'true': checkbox checked.
|
||||
expect(container.querySelector('#compose-diff-preview')?.getAttribute('aria-checked')).toBe('true');
|
||||
});
|
||||
|
||||
it('flips the diff-preview key when enabled', () => {
|
||||
const { container } = render(<StacksSection />);
|
||||
const toggle = container.querySelector('#compose-diff-preview') as HTMLElement;
|
||||
expect(window.localStorage.getItem(COMPOSE_DIFF_PREVIEW_KEY)).toBeNull();
|
||||
fireEvent.click(toggle);
|
||||
expect(window.localStorage.getItem(COMPOSE_DIFF_PREVIEW_KEY)).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AppearanceSection no longer owns stack-workflow controls', () => {
|
||||
it('does not render Deploy progress, Progress style, or Diff preview before save', () => {
|
||||
render(<AppearanceSection />);
|
||||
expect(screen.queryByText('Deploy progress')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Progress style')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Diff preview before save')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,8 @@
|
||||
* Data Retention, and the renamed labels are applied.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SETTINGS_GROUPS, SETTINGS_ITEMS } from '../registry';
|
||||
import { SETTINGS_GROUPS, SETTINGS_ITEMS, scopeLabel } from '../registry';
|
||||
import type { SettingsItemMeta } from '../registry';
|
||||
|
||||
describe('settings registry', () => {
|
||||
it('points every item at a defined group', () => {
|
||||
@@ -74,4 +75,38 @@ describe('settings registry', () => {
|
||||
expect(registries?.tier).toBeNull();
|
||||
expect(registries?.adminOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('registers the Stacks section under Infrastructure with searchable workflow keywords', () => {
|
||||
const stacks = SETTINGS_ITEMS.find(i => i.id === 'stacks');
|
||||
expect(stacks?.group).toBe('infrastructure');
|
||||
expect(stacks?.tier).toBeNull();
|
||||
for (const term of ['stack', 'deploy', 'progress', 'diff', 'save']) {
|
||||
expect(stacks?.keywords, `keyword ${term}`).toContain(term);
|
||||
}
|
||||
});
|
||||
|
||||
it('scopes the browser-local sections (Appearance, Stacks) to the browser', () => {
|
||||
expect(SETTINGS_ITEMS.find(i => i.id === 'appearance')?.scope).toBe('browser');
|
||||
expect(SETTINGS_ITEMS.find(i => i.id === 'stacks')?.scope).toBe('browser');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeLabel', () => {
|
||||
const item = (over: Partial<SettingsItemMeta>): SettingsItemMeta => ({
|
||||
id: 'stacks', group: 'infrastructure', label: 'X', description: '',
|
||||
keywords: [], tier: null, scope: 'global', ...over,
|
||||
});
|
||||
|
||||
it('reads browser for browser-scoped sections regardless of their group', () => {
|
||||
expect(scopeLabel(item({ scope: 'browser', group: 'personal' }))).toBe('browser');
|
||||
expect(scopeLabel(item({ scope: 'browser', group: 'infrastructure' }))).toBe('browser');
|
||||
});
|
||||
|
||||
it('reads operator for the signed-in Account (personal group)', () => {
|
||||
expect(scopeLabel(item({ scope: 'global', group: 'personal' }))).toBe('operator');
|
||||
});
|
||||
|
||||
it('reads global for other non-node, non-browser groups', () => {
|
||||
expect(scopeLabel(item({ scope: 'global', group: 'access' }))).toBe('global');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ export { NotificationsSection } from './NotificationsSection';
|
||||
export { DeveloperSection } from './DeveloperSection';
|
||||
export { DataRetentionSection } from './DataRetentionSection';
|
||||
export { AppStoreSection } from './AppStoreSection';
|
||||
export { StacksSection } from './StacksSection';
|
||||
export { SupportSection } from './SupportSection';
|
||||
export { AboutSection } from './AboutSection';
|
||||
export { RecoverySection } from './RecoverySection';
|
||||
@@ -30,6 +31,7 @@ export {
|
||||
getSettingsGroup,
|
||||
isItemVisible,
|
||||
isItemLocked,
|
||||
scopeLabel,
|
||||
} from './registry';
|
||||
export type {
|
||||
SettingsGroupId,
|
||||
|
||||
@@ -31,7 +31,7 @@ export const SETTINGS_GROUPS: readonly SettingsGroupMeta[] = [
|
||||
];
|
||||
|
||||
export type TierGate = 'paid' | null;
|
||||
export type Scope = 'global' | 'node';
|
||||
export type Scope = 'global' | 'node' | 'browser';
|
||||
|
||||
export interface SettingsItemMeta {
|
||||
id: SectionId;
|
||||
@@ -64,7 +64,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
description: 'Theme, accent, density, and display preferences saved to this browser.',
|
||||
keywords: ['theme', 'dim', 'oled', 'light', 'dark', 'accent', 'color', 'glow', 'border', 'contrast', 'density', 'comfortable', 'compact', 'spacing', 'display'],
|
||||
tier: null,
|
||||
scope: 'global',
|
||||
scope: 'browser',
|
||||
},
|
||||
// Access
|
||||
{
|
||||
@@ -162,6 +162,15 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
tier: null,
|
||||
scope: 'node',
|
||||
},
|
||||
{
|
||||
id: 'stacks',
|
||||
group: 'infrastructure',
|
||||
label: 'Stacks',
|
||||
description: 'Stack editor and lifecycle workflow preferences saved to this browser.',
|
||||
keywords: ['stack', 'compose', 'deploy', 'progress', 'modal', 'inline', 'diff', 'preview', 'save', 'editor', 'workflow'],
|
||||
tier: null,
|
||||
scope: 'browser',
|
||||
},
|
||||
// Monitoring
|
||||
{
|
||||
id: 'host-alerts',
|
||||
@@ -297,3 +306,17 @@ export function isItemVisible(item: SettingsItemMeta, ctx: VisibilityContext): b
|
||||
export function isItemLocked(item: SettingsItemMeta, ctx: VisibilityContext): boolean {
|
||||
return item.tier === 'paid' ? !ctx.isPaid : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The masthead SCOPE value for a non-node section. Browser-local sections
|
||||
* (Appearance, Stacks) persist to this browser's localStorage and read as
|
||||
* browser regardless of their group; the signed-in Account is operator-scoped;
|
||||
* Access sections (license, users, sso, api-tokens) are instance-global, so
|
||||
* they read as global like every other non-node group. Node-scoped sections
|
||||
* render a NODE pill instead and never reach here.
|
||||
*/
|
||||
export function scopeLabel(item: SettingsItemMeta): string {
|
||||
if (item.scope === 'browser') return 'browser';
|
||||
if (item.group === 'personal') return 'operator';
|
||||
return 'global';
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ export type SectionId =
|
||||
| 'data-retention'
|
||||
| 'nodes'
|
||||
| 'app-store'
|
||||
| 'stacks'
|
||||
| 'notification-routing'
|
||||
| 'recovery'
|
||||
| 'support'
|
||||
|
||||
Reference in New Issue
Block a user