Fix profile server selection activation state
This commit is contained in:
@@ -199,11 +199,16 @@ export function createSubscriptionService(dependencies: SubscriptionServiceDepen
|
||||
if (!profile.servers.some((server) => server.id === serverId)) {
|
||||
throw new HarborError('SERVER_NOT_FOUND');
|
||||
}
|
||||
if (profile.desiredServerId === serverId) return mutationResult(profile);
|
||||
const selected = { ...profile, desiredServerId: serverId };
|
||||
if (profile.desiredServerId === serverId && state.desiredProfileId === profile.id) {
|
||||
return mutationResult(profile);
|
||||
}
|
||||
const selected = profile.desiredServerId === serverId
|
||||
? profile
|
||||
: { ...profile, desiredServerId: serverId };
|
||||
dependencies.state.update((current) => ({
|
||||
...current,
|
||||
profiles: replaceProfile(current, selected),
|
||||
desiredProfileId: profile.id,
|
||||
}));
|
||||
return mutationResult(selected);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const HARBOR_VERSIONS = Object.freeze({
|
||||
macClient: '0.23.11',
|
||||
gatewayClient: '0.24.11',
|
||||
gatewayBackend: '0.24.0',
|
||||
macClient: '0.24.0',
|
||||
gatewayClient: '0.25.0',
|
||||
gatewayBackend: '0.25.0',
|
||||
});
|
||||
|
||||
export interface ParsedVersion {
|
||||
|
||||
@@ -255,13 +255,6 @@ export function App() {
|
||||
`${profileId}:${serverId}`,
|
||||
profileId,
|
||||
)}
|
||||
onActivateProfile={(profileId: string) => run(
|
||||
'profileActivate',
|
||||
() => api.profiles.activate(profileId, revisionRef.current),
|
||||
'connection',
|
||||
profileId,
|
||||
profileId,
|
||||
)}
|
||||
onRefreshProfile={(profileId: string) => run(
|
||||
'profileRefresh',
|
||||
() => api.profiles.refresh(profileId, revisionRef.current),
|
||||
|
||||
@@ -105,7 +105,6 @@ interface ClientOverviewPageProps {
|
||||
error: UiError | null;
|
||||
onAddProfile: (label: string, url: string) => Promise<unknown>;
|
||||
onSelectProfileServer: (profileId: string, serverId: string) => Promise<unknown>;
|
||||
onActivateProfile: (profileId: string) => Promise<unknown>;
|
||||
onRefreshProfile: (profileId: string) => Promise<unknown>;
|
||||
onForgetProfile: (profileId: string, mode: 'delete' | 'stop-and-delete') => Promise<unknown>;
|
||||
onApply: (profileId: string, serverId: string) => Promise<unknown>;
|
||||
@@ -411,7 +410,6 @@ export function ClientOverviewPage({
|
||||
error,
|
||||
onAddProfile,
|
||||
onSelectProfileServer,
|
||||
onActivateProfile,
|
||||
onRefreshProfile,
|
||||
onForgetProfile,
|
||||
onApply,
|
||||
@@ -494,7 +492,6 @@ export function ClientOverviewPage({
|
||||
onAdd: onAddProfile,
|
||||
onRefresh: onRefreshProfile,
|
||||
onForget: onForgetProfile,
|
||||
onActivate: onActivateProfile,
|
||||
onDismissError,
|
||||
});
|
||||
const routingFeature = useRoutingFeature({
|
||||
@@ -553,7 +550,7 @@ export function ClientOverviewPage({
|
||||
}, []);
|
||||
|
||||
function selectServer(profile: ProfileSnapshot, serverId: string) {
|
||||
if (connected && !gatewayDirect && state.selection.appliedProfileId === profile.id) {
|
||||
if (connected && !gatewayDirect) {
|
||||
onApply(profile.id, serverId);
|
||||
return;
|
||||
}
|
||||
@@ -723,9 +720,9 @@ export function ClientOverviewPage({
|
||||
profileId={profile.id}
|
||||
pingServers={actions.pingServers}
|
||||
servers={profile.servers}
|
||||
selectedServerId={profile.desiredServerId}
|
||||
selectedServerId={pickerState.selectedServerId}
|
||||
disabled={serverApplyBlocked || pickerState.disabled}
|
||||
prompt={!profile.desiredServerId}
|
||||
prompt={!pickerState.selectedServerId}
|
||||
leaving={pickerState.leaving}
|
||||
revealVersion={pickerState.revealVersion}
|
||||
anchorServerId={pickerState.anchorServerId}
|
||||
|
||||
@@ -32,6 +32,7 @@ interface ServerPickerRenderState {
|
||||
leaving: boolean;
|
||||
revealVersion: number;
|
||||
anchorServerId: string;
|
||||
selectedServerId: string;
|
||||
}
|
||||
|
||||
interface SubscriptionFeatureOptions {
|
||||
@@ -45,7 +46,6 @@ interface SubscriptionFeatureOptions {
|
||||
onAdd: (label: string, url: string) => Promise<unknown>;
|
||||
onRefresh: (profileId: string) => Promise<unknown>;
|
||||
onForget: (profileId: string, mode: 'delete' | 'stop-and-delete') => Promise<unknown>;
|
||||
onActivate: (profileId: string) => Promise<unknown>;
|
||||
onDismissError: () => void;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,6 @@ export function useSubscriptionFeature({
|
||||
onAdd,
|
||||
onRefresh,
|
||||
onForget,
|
||||
onActivate,
|
||||
onDismissError,
|
||||
}: SubscriptionFeatureOptions) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -281,7 +280,6 @@ export function useSubscriptionFeature({
|
||||
addBlocked: operationBlocked(operations, 'profileAdd'),
|
||||
refreshBlocked: operationBlocked(operations, 'profileRefresh'),
|
||||
deleteBlocked: operationBlocked(operations, 'profileDelete'),
|
||||
activateBlocked: operationBlocked(operations, 'profileActivate'),
|
||||
toggle: () => setOpen((current) => !current),
|
||||
close: () => setOpen(false),
|
||||
showAdd,
|
||||
@@ -296,7 +294,6 @@ export function useSubscriptionFeature({
|
||||
cancelDelete: () => setDeleteId(''),
|
||||
confirmDelete,
|
||||
refresh,
|
||||
onActivate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -394,8 +391,9 @@ function ProfileGroup({
|
||||
const refreshing = feature.refreshingIds.includes(profile.id)
|
||||
|| feature.operations.profileRefresh?.target === profile.id;
|
||||
const controlsBlocked = operationBlocked(feature.operations, 'profileSelect');
|
||||
const selectedServer = profileServer(profile, profile.desiredServerId);
|
||||
const visibleServerId = applied ? feature.selection.appliedServerId : profile.desiredServerId;
|
||||
const visibleServerId = applied
|
||||
? feature.selection.appliedServerId
|
||||
: desired ? profile.desiredServerId : '';
|
||||
const visibleServer = profileServer(profile, visibleServerId)
|
||||
|| (applied && feature.selection.appliedServerSnapshot?.id === visibleServerId
|
||||
? feature.selection.appliedServerSnapshot
|
||||
@@ -403,7 +401,6 @@ function ProfileGroup({
|
||||
const visibleServerLocation = [visibleServer?.city, visibleServer?.country]
|
||||
.filter((value): value is string => typeof value === 'string' && Boolean(value))
|
||||
.join(' · ');
|
||||
const canActivate = !applied && feature.selection.desiredProfileId !== profile.id;
|
||||
const localStatus = profileError?.message || (profile.subscription.status === 'stale'
|
||||
? `Последнее обновление не удалось. Данные от ${profile.subscription.fetchedAt
|
||||
? new Date(profile.subscription.fetchedAt).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' })
|
||||
@@ -431,12 +428,7 @@ function ProfileGroup({
|
||||
<small>{profileDetails}</small>
|
||||
</span>
|
||||
</button>
|
||||
{canActivate ? <button
|
||||
className="client-profile-activate"
|
||||
type="button"
|
||||
disabled={feature.activateBlocked || !selectedServer}
|
||||
onClick={() => feature.onActivate(profile.id)}
|
||||
>Сделать активной</button> : <ProfileUsage profile={profile} />}
|
||||
{(applied || desired) && <ProfileUsage profile={profile} />}
|
||||
<button
|
||||
className={`client-profile-refresh${refreshing ? ' is-refreshing' : ''}`}
|
||||
type="button"
|
||||
@@ -483,12 +475,13 @@ function ProfileGroup({
|
||||
inert={!expanded ? true : undefined}
|
||||
>
|
||||
<div className="client-profile-body-inner">
|
||||
{!profile.desiredServerId && <p className="client-profile-server-hint">Выберите сервер этой подписки</p>}
|
||||
{!visibleServerId && <p className="client-profile-server-hint">Выберите сервер этой подписки</p>}
|
||||
{renderServerPicker(profile, {
|
||||
disabled: controlsBlocked,
|
||||
leaving: false,
|
||||
revealVersion: feature.revealVersions[profile.id] || 0,
|
||||
anchorServerId: visibleServer?.id || '',
|
||||
selectedServerId: visibleServerId,
|
||||
})}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
@@ -149,6 +149,8 @@
|
||||
}
|
||||
|
||||
.client-profile-usage {
|
||||
grid-column: 2;
|
||||
justify-self: end;
|
||||
max-width: 126px;
|
||||
overflow: hidden;
|
||||
color: var(--client-muted);
|
||||
@@ -188,6 +190,14 @@
|
||||
transition: transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-profile-refresh {
|
||||
grid-column: 3;
|
||||
}
|
||||
|
||||
.client-profile-delete {
|
||||
grid-column: 4;
|
||||
}
|
||||
|
||||
.client-profile-delete-lid {
|
||||
transform-origin: center 7px;
|
||||
transition: transform 260ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
@@ -327,7 +337,6 @@
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.client-profile-activate,
|
||||
.client-profile-add-trigger {
|
||||
min-height: 36px;
|
||||
padding: 0;
|
||||
@@ -365,18 +374,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.client-profile-activate {
|
||||
font: var(--type-micro);
|
||||
letter-spacing: var(--type-micro-tracking);
|
||||
text-transform: var(--type-micro-transform);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-profile-activate:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.client-profile-add-trigger {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
|
||||
@@ -487,6 +487,7 @@ setInterval(() => {}, 60_000);
|
||||
selected.state.profiles.find(({ id }) => id === workProfileId).desiredServerId,
|
||||
testServerId,
|
||||
);
|
||||
assert.equal(selected.state.selection.desiredProfileId, workProfileId);
|
||||
assert.equal(selected.state.profiles.find(({ id }) => id === primaryProfileId).desiredServerId, testServerId);
|
||||
|
||||
const applied = await mutation('/api/apply', 'POST', {
|
||||
|
||||
@@ -179,6 +179,13 @@ test('rename and server selection are profile-scoped and reject stale or duplica
|
||||
await scoped.service.selectProfileServer('work', 'next', state.revision);
|
||||
assert.equal(scoped.snapshot().state.profiles.find(({ id }) => id === 'work').desiredServerId, 'next');
|
||||
assert.equal(scoped.snapshot().state.profiles.find(({ id }) => id === 'personal').desiredServerId, 'shared');
|
||||
assert.equal(scoped.snapshot().state.desiredProfileId, 'work');
|
||||
|
||||
const sameServer = scoped.snapshot().state;
|
||||
sameServer.desiredProfileId = 'personal';
|
||||
const reactivated = createHarness({ state: sameServer });
|
||||
await reactivated.service.selectProfileServer('work', 'next', sameServer.revision);
|
||||
assert.equal(reactivated.snapshot().state.desiredProfileId, 'work');
|
||||
});
|
||||
|
||||
test('refreshing an inactive profile does not mutate config, runtime, or the applied pair', async () => {
|
||||
|
||||
@@ -27,7 +27,8 @@ test('server picker has one public feature owner without legacy shims', () => {
|
||||
assert.equal((overview.match(/<ServerPicker/g) || []).length, 1);
|
||||
assert.doesNotMatch(picker, /from ['"][^'"]*\/api\/|\bapi\./);
|
||||
assert.match(overview, /const selectedServerId = desiredProfile\?\.desiredServerId \|\| ''/);
|
||||
assert.match(overview, /function selectServer\(profile: ProfileSnapshot, serverId: string\)[\s\S]*onApply\(profile\.id, serverId\)[\s\S]*onSelectProfileServer\(profile\.id, serverId\)/);
|
||||
assert.match(overview, /function selectServer\(profile: ProfileSnapshot, serverId: string\)[\s\S]*if \(connected && !gatewayDirect\)[\s\S]*onApply\(profile\.id, serverId\)[\s\S]*onSelectProfileServer\(profile\.id, serverId\)/);
|
||||
assert.match(overview, /selectedServerId=\{pickerState\.selectedServerId\}/);
|
||||
});
|
||||
|
||||
const fixtures = (count) => Array.from({ length: count }, (_, index) => ({
|
||||
|
||||
@@ -37,26 +37,26 @@ const expectedImports = [
|
||||
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
||||
const acceptedLedger = {
|
||||
counts: {
|
||||
cascadeEdges: 752,
|
||||
cascadeEdges: 749,
|
||||
customProperties: 103,
|
||||
declarations: 3202,
|
||||
declarations: 3200,
|
||||
important: 0,
|
||||
keyframes: 56,
|
||||
media: 13,
|
||||
rules: 917,
|
||||
variableReferences: 794,
|
||||
variableReferences: 791,
|
||||
},
|
||||
hashes: {
|
||||
cascadeEdges: '5c09bfa1ab715bdd522969d017f4ff974e2a8fc391305e68943e39612245f815',
|
||||
cascadeEdges: 'a47dbf5212045c865de7a41d420e78df8b992a6ec1b06fb18a8d459cd480f0ae',
|
||||
customProperties: 'fc8401b40b8d2cc8a1fc1716360ba8e5baccb694cdabde68427d3c92c04a84d4',
|
||||
declarations: '0cacb55296aad8af6790036c75831f7a87429ec055481d9eb71d564e4d8a828c',
|
||||
declarations: '198ccda82654a42a5ba3f452fed7a6e9a0ef1c0ff13d243eceb2c1965b4a91cd',
|
||||
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
||||
duplicateSelectors: '419962e669cbfdf5c8186318cb26688477fc0199cae98df8e1cd9bf5cddd76c2',
|
||||
duplicateSelectors: '257eff4727dab9ce25f4e1f9320e89bf2270eb29feae921b96601f103ad7f036',
|
||||
keyframes: 'fb859c4d0d1bfd2f5a18904e30a5f79c6ce6931d74fe88cbd506fb28c334b7ac',
|
||||
ruleDeclarationSequences: 'b2b02e78df98c48405a5243bb07e0cd6dfcfac83f7fea7847e82041741a56e14',
|
||||
selectors: 'e4c3dc48d8998e9fc76a0a3eb147fe6366ebfc93e22ba9ebe77f08f1fe84de2f',
|
||||
variableReferences: '252702ab07ce1c790d77a60f8e674cb3b55f7ffe6e52329a50b69e5306442f1f',
|
||||
witnesses: 'f51f070cc2c7dba0f8d08518cdeee49eaf176aac82e3571cdd8498315ba8db83',
|
||||
ruleDeclarationSequences: 'e4d989c091659ad82e79de7281c2b94b7b76d5f032f2a814e630275b7f7b7c4b',
|
||||
selectors: 'd1948bb0fb3899264af80cd1f86b9c8e9cdcf1a4473ef77a0dec4ffad10d1f8d',
|
||||
variableReferences: '00551e699e5530d624d364db9e546c0ffc05d7c79d04abc42930a98be874b3bf',
|
||||
witnesses: '07f462edf94d90909902283f5af130f290c957c3f10ddaa5a2b2339eb64d468a',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -209,7 +209,7 @@ test('client typography uses the shared semantic scale outside the token owner',
|
||||
|
||||
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
|
||||
const witnesses = readStyleWitnesses(root);
|
||||
assert.equal(witnesses.length, 730);
|
||||
assert.equal(witnesses.length, 729);
|
||||
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
|
||||
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
||||
assert.deepEqual(ledger.counts, acceptedLedger.counts);
|
||||
@@ -405,8 +405,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
|
||||
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
|
||||
|
||||
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
|
||||
assert.deepEqual(assets, ['index-dA_YmbDB.css']);
|
||||
assert.deepEqual(assets, ['index-SYp2glgN.css']);
|
||||
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
|
||||
assert.equal(built.byteLength, 123538);
|
||||
assert.equal(sha256(built), '7c506bdd5d073515239a4d779f6f5365a07ef245c057772ba6a343b6d90838e7');
|
||||
assert.equal(built.byteLength, 123402);
|
||||
assert.equal(sha256(built), '8050f9e31292529b97d716d38140da72364c26368af92dd5b3a8d56c9fe752ad');
|
||||
});
|
||||
|
||||
@@ -111,7 +111,10 @@ test('profiles render as flat accordion groups with scoped controls', () => {
|
||||
assert.match(feature, /feature\.operations\.profileRefresh\?\.target === profile\.id/);
|
||||
assert.match(feature, /\{visibleServer && <button[\s\S]*client-profile-selected-server[\s\S]*aria-expanded=\{expanded\}/);
|
||||
assert.match(feature, /anchorServerId: visibleServer\?\.id \|\| ''/);
|
||||
assert.match(feature, /const visibleServerId = applied \? feature\.selection\.appliedServerId : profile\.desiredServerId/);
|
||||
assert.match(feature, /const visibleServerId = applied[\s\S]*feature\.selection\.appliedServerId[\s\S]*desired \? profile\.desiredServerId : ''/);
|
||||
assert.match(feature, /selectedServerId: visibleServerId/);
|
||||
assert.doesNotMatch(feature, /Сделать активной|client-profile-activate|onActivate/);
|
||||
assert.doesNotMatch(page, /onActivateProfile/);
|
||||
assert.match(feature, /const desired = !feature\.connected[\s\S]*&& !feature\.gatewayDirect/);
|
||||
assert.match(feature, /const profileDomain = subscriptionDomain\(profile\.subscription\.host\)/);
|
||||
assert.match(feature, /const profileDetails = `Серверов: \$\{profile\.servers\.length\}/);
|
||||
@@ -122,6 +125,7 @@ test('profiles render as flat accordion groups with scoped controls', () => {
|
||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-profile-body \*/);
|
||||
assert.match(styles, /\.client-profile-refresh\.is-refreshing/);
|
||||
assert.match(styles, /\.client-profile-refresh,[\s\S]*\.client-profile-delete\s*\{[\s\S]*width:\s*36px;[\s\S]*height:\s*36px;[\s\S]*display:\s*grid;[\s\S]*place-items:\s*center/);
|
||||
assert.match(styles, /\.client-profile-activate\s*\{[\s\S]*font:\s*var\(--type-micro\)/);
|
||||
assert.match(styles, /\.client-profile-refresh\s*\{[\s\S]*grid-column:\s*3/);
|
||||
assert.match(styles, /\.client-profile-delete\s*\{[\s\S]*grid-column:\s*4/);
|
||||
assert.doesNotMatch(feature, /client-subscription-card|role="tab"/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user