Skip to content

Commit c185846

Browse files
committed
✨ feat(context): say where a package reaches, above what is in it
The two tabs each hold half of the question a reader arrives with. Components says what exists, Distribution says where it stands, and the join happened in the reader's head: nothing on the first tab admitted that the package goes anywhere at all. So one line above the list, saying the reach and the health, and handing over to the tab that owns the detail. It is deliberately a sentence and not a panel. The design this comes from put the destinations in a column beside the components, then dropped that column in its own adaptation to a larger package and replaced it with exactly this line. A column that does not survive the scale it was drawn for is not the load-bearing part. The destinations moved out of the Distribution tab into a hook both readers call, because a reach counted twice from two sets of rules is a pair of numbers that disagree in front of the user. React Query answers the second caller from the first one's cache, so it costs a recomputation and no request. Two things the browser decided rather than the editor. `11 destinations, 11 need a hand` makes a reader check whether the same number twice is a mistake, so the whole is a word: `none up to date`. And the mark was orange whatever was under it, which reads a failure as ordinary drift; it now takes the worst state present, which moved `STATE_TONE` next to the states it colours. The line holds when there is nothing to say, too: a package that stands nowhere says so, on the surface where its components are written.
1 parent 5f9a8bd commit c185846

6 files changed

Lines changed: 248 additions & 39 deletions

File tree

apps/frontend/src/domain/deployments/components/context/ContextDestinationList.tsx

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { ContextChip } from './ContextChip';
2323
import { ContextPickBox } from './ContextPickBox';
2424
import { ContextSearchField } from './ContextSearchField';
2525
import {
26+
STATE_TONE,
2627
filterPackageDestinations,
2728
needsAHand,
2829
packageDestinationSummary,
@@ -653,14 +654,6 @@ function textIndentPx(hasSelection: boolean): number {
653654
);
654655
}
655656

656-
/** The colour of each state, in one place, so a dot and a band agree. */
657-
const STATE_TONE: Record<PackageDestinationState, string> = {
658-
failed: 'red.300',
659-
waiting: 'blue.300',
660-
behind: 'orange.500',
661-
aligned: 'green.500',
662-
};
663-
664657
/**
665658
* How this destination stands, as a mark rather than a word.
666659
*

apps/frontend/src/domain/deployments/components/context/ContextPackageDistribution.tsx

Lines changed: 6 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,17 @@ import {
1414
import type { GitProviderId, PackageResponse } from '@packmind/types';
1515
import { useAuthContext } from '../../../accounts/hooks/useAuthContext';
1616
import { useGetGitProvidersQuery } from '../../../git/api/queries/GitProviderQueries';
17-
import { usePackageMarketplacePublications } from '@packmind/proprietary/frontend/domain/marketplaces/components/usePackageMarketplacePublications';
1817
import { useMarketplaceBatchDistribution } from '@packmind/proprietary/frontend/domain/marketplaces/components/redesign/useMarketplaceBatchDistribution';
19-
import { useSpaceMarketplaces } from '@packmind/proprietary/frontend/domain/spaces/components/overview/useSpaceMarketplaces';
2018
import { PackageDistributionList } from '../PackageDistributionList';
2119
import {
2220
SyncSurface,
2321
type SyncScope,
2422
} from '../redesign/components/SyncSurface';
25-
import { installDriftEntries } from '../redesign/selectors/installDriftEntries';
2623
import { providersWithTokenSet } from '../redesign/selectors/providerAuth';
2724
import type { PackageDrift } from '../redesign/types';
2825
import { ContextDestinationList } from './ContextDestinationList';
29-
import { buildPackageDestinations } from './buildPackageDestinations';
3026
import { buildPackageSyncScope } from './buildPackageSyncScope';
31-
import { toPackagePublications } from './toPackagePublications';
27+
import { usePackageDestinations } from './usePackageDestinations';
3228

3329
/**
3430
* Where a package has got to, as one list.
@@ -113,23 +109,11 @@ export function ContextPackageDistribution({
113109
);
114110
const [isHistoryOpen, setHistoryOpen] = useState(false);
115111

116-
/*
117-
* An empty organization id disables the query rather than asking about
118-
* nobody's marketplaces, which is what lets this be called unconditionally in
119-
* a pane that can render without an organization. It used to live inside a
120-
* chip for want of that guard, and the chip then had to report its count
121-
* upwards for the tab to know what to open on.
122-
*/
123-
const { publications, isLoading: isPublicationsLoading } =
124-
usePackageMarketplacePublications(organization?.id ?? '', pkg.id);
125-
/*
126-
* Membership and staleness are two different questions here, and only the
127-
* first has an answer scoped to this package: the publications say which
128-
* marketplaces carry it, and the space's drift says which of the copies have
129-
* been overtaken. Both hooks fan out over the same distribution queries, so
130-
* React Query answers the second one from the cache of the first.
131-
*/
132-
const { marketplaces } = useSpaceMarketplaces();
112+
const {
113+
destinations,
114+
marketplaces,
115+
isLoading: isPublicationsLoading,
116+
} = usePackageDestinations(pkg.id, drift);
133117
/*
134118
* Withheld rather than passed with no organization, which is what the
135119
* Distribution rail does with it: the confirmation hides its whole
@@ -141,15 +125,6 @@ export function ContextPackageDistribution({
141125
organization?.id ?? null,
142126
);
143127

144-
const destinations = useMemo(
145-
() =>
146-
buildPackageDestinations({
147-
installs: drift ? installDriftEntries(drift) : [],
148-
publications: toPackagePublications(publications, marketplaces, pkg.id),
149-
}),
150-
[drift, publications, marketplaces, pkg.id],
151-
);
152-
153128
/*
154129
* The redistribute flow takes over the pane and leaves the rail alone: the
155130
* package it is about is named in the header just above, and cancelling has to

apps/frontend/src/domain/deployments/components/context/ContextPackagePane.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ import {
7474
COMPONENT_TYPE_ICONS,
7575
ContextComponentList,
7676
} from './ContextComponentList';
77+
import { PackageReachStrip } from './PackageReachStrip';
78+
import { usePackageDestinations } from './usePackageDestinations';
7779
import { ContextChip } from './ContextChip';
7880
import { ContextSearchField } from './ContextSearchField';
7981
import { filterPackageGroups } from './filterPackageGroups';
@@ -544,6 +546,14 @@ export function ContextPackagePane({
544546
isError,
545547
} = usePackageDrift(pkg.id);
546548

549+
/*
550+
* Read here for the line above the component list. The same call inside the
551+
* Distribution tab is answered from the same cache, so the reach stated here
552+
* and the list found there cannot come out of two different counts.
553+
*/
554+
const { destinations, isLoading: areDestinationsLoading } =
555+
usePackageDestinations(pkg.id, drift);
556+
547557
/*
548558
* Read here for the header's own push. React Query answers this and the
549559
* identical call inside the Distribution tab from one request, so the two
@@ -1004,6 +1014,16 @@ export function ContextPackagePane({
10041014
/>
10051015
) : (
10061016
<PMVStack gap={5} align="stretch">
1017+
{/*
1018+
Where this package reaches, above the list of what is in it. The
1019+
two tabs each hold half of the question a reader arrives with,
1020+
and this is the half the other tab owns, said in one line.
1021+
*/}
1022+
<PackageReachStrip
1023+
destinations={destinations}
1024+
isLoading={isLoading || areDestinationsLoading}
1025+
onOpenDistribution={() => showTab(DISTRIBUTION_TAB)}
1026+
/>
10071027
{/*
10081028
The filter row, above everything the list does. A package can hold
10091029
a hundred components, and until this existed the only way to reach
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { PMBox, PMHStack, PMLink, PMText } from '@packmind/ui';
2+
import {
3+
STATE_TONE,
4+
packageDestinationSummary,
5+
worstState,
6+
type PackageDestination,
7+
} from './buildPackageDestinations';
8+
9+
/**
10+
* How far this package reaches, as one line above its components.
11+
*
12+
* The two tabs each hold half of what a reader wants and neither says so: the
13+
* components say what exists, the destinations say where it stands, and the
14+
* join happened in the reader's head. This is the smallest honest version of
15+
* that join, and it is deliberately a sentence rather than a panel.
16+
*
17+
* The design this comes from put the destinations in a column beside the
18+
* components, and then dropped that column in its own adaptation to a larger
19+
* package, replacing it with exactly this line. A column that cannot survive
20+
* the scale it was drawn for is not the load-bearing part; the line is, and it
21+
* reads the same at five destinations and at a thousand.
22+
*
23+
* No counts of its own. It states what the Distribution tab would state, in the
24+
* words that tab uses, and hands the reader over to it.
25+
*/
26+
export function PackageReachStrip({
27+
destinations,
28+
isLoading,
29+
onOpenDistribution,
30+
}: Readonly<{
31+
destinations: readonly PackageDestination[];
32+
/**
33+
* Either half of the answer is still coming.
34+
*
35+
* Held rather than counted around, because the number would be wrong and
36+
* then move: a package published to a marketplace and installed nowhere
37+
* reads as reaching nothing until the publications land.
38+
*/
39+
isLoading: boolean;
40+
onOpenDistribution: () => void;
41+
}>) {
42+
const summary = packageDestinationSummary(destinations);
43+
44+
return (
45+
<PMHStack
46+
justify="space-between"
47+
align="center"
48+
gap={3}
49+
paddingX={3}
50+
paddingY={2}
51+
borderWidth="1px"
52+
borderColor="border.tertiary"
53+
borderRadius="sm"
54+
bg="background.secondary"
55+
>
56+
<PMHStack gap={2} align="center" minW={0}>
57+
{isLoading ? (
58+
<PMText fontSize="xs" color="faded">
59+
Counting destinations…
60+
</PMText>
61+
) : summary.all === 0 ? (
62+
/*
63+
Said rather than left blank. A package that stands nowhere is a
64+
state worth naming on the surface where its components are edited:
65+
what is written here reaches no agent yet.
66+
*/
67+
<PMText fontSize="xs" color="faded">
68+
Not distributed anywhere yet.
69+
</PMText>
70+
) : (
71+
<>
72+
<PMText fontSize="xs" color="secondary">
73+
Reaches {summary.all} destination{summary.all === 1 ? '' : 's'}
74+
</PMText>
75+
{summary.needsAHand === 0 ? (
76+
<PMText fontSize="xs" color="faded">
77+
· all up to date
78+
</PMText>
79+
) : (
80+
<PMHStack gap={2} align="center">
81+
{/*
82+
Coloured by the worst of them, not by the fact that something
83+
is wrong: one mark standing for eleven has to show the one the
84+
reader would open first, and a failure under an orange dot
85+
reads as ordinary drift.
86+
*/}
87+
<PMBox
88+
width="6px"
89+
height="6px"
90+
borderRadius="full"
91+
bg={STATE_TONE[worstState(destinations)]}
92+
flexShrink={0}
93+
aria-hidden
94+
/>
95+
{/*
96+
Counted, unless the count is all of them. "11 destinations,
97+
11 need a hand" makes a reader check whether the same number
98+
twice is a mistake; the whole is a word, not a number.
99+
*/}
100+
<PMText fontSize="xs" color="secondary">
101+
{summary.needsAHand === summary.all
102+
? 'none up to date'
103+
: `${summary.needsAHand} need${summary.needsAHand === 1 ? 's' : ''} a hand`}
104+
</PMText>
105+
</PMHStack>
106+
)}
107+
</>
108+
)}
109+
</PMHStack>
110+
<PMLink as="button" fontSize="xs" onClick={onOpenDistribution}>
111+
Distribution
112+
</PMLink>
113+
</PMHStack>
114+
);
115+
}

apps/frontend/src/domain/deployments/components/context/buildPackageDestinations.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,34 @@ const STATE_RANK: Record<PackageDestinationState, number> = {
4646
aligned: 3,
4747
};
4848

49+
/**
50+
* The colour of each state, beside the states themselves so a dot, a band and
51+
* a summary line cannot drift apart on what orange means.
52+
*/
53+
export const STATE_TONE: Record<PackageDestinationState, string> = {
54+
failed: 'red.300',
55+
waiting: 'blue.300',
56+
behind: 'orange.500',
57+
aligned: 'green.500',
58+
};
59+
60+
/**
61+
* The worst state in a set, which is what a single mark standing for many of
62+
* them has to show. Ranked exactly as the rows are sorted, so the mark agrees
63+
* with whatever is at the top of the list it summarises.
64+
*/
65+
export function worstState(
66+
destinations: readonly PackageDestination[],
67+
): PackageDestinationState {
68+
return destinations.reduce<PackageDestinationState>(
69+
(worst, destination) =>
70+
STATE_RANK[destination.state] < STATE_RANK[worst]
71+
? destination.state
72+
: worst,
73+
'aligned',
74+
);
75+
}
76+
4977
/** The states that put a row in the band at the top. */
5078
export function needsAHand(state: PackageDestinationState): boolean {
5179
return state !== 'aligned';
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { useMemo } from 'react';
2+
import type { PackageId } from '@packmind/types';
3+
import { useAuthContext } from '../../../accounts/hooks/useAuthContext';
4+
import { usePackageMarketplacePublications } from '@packmind/proprietary/frontend/domain/marketplaces/components/usePackageMarketplacePublications';
5+
import { useSpaceMarketplaces } from '@packmind/proprietary/frontend/domain/spaces/components/overview/useSpaceMarketplaces';
6+
import { installDriftEntries } from '../redesign/selectors/installDriftEntries';
7+
import type { MarketplaceDrift, PackageDrift } from '../redesign/types';
8+
import {
9+
buildPackageDestinations,
10+
type PackageDestination,
11+
} from './buildPackageDestinations';
12+
import { toPackagePublications } from './toPackagePublications';
13+
14+
/**
15+
* Every place a package stands, for whoever is asking.
16+
*
17+
* Lifted out of the Distribution tab the day a second reader wanted the same
18+
* answer: the Components tab states the package's reach in a line above its
19+
* list, and a reach counted twice from two different sets of rules is a pair of
20+
* numbers that disagree in front of the user. There is one definition of what a
21+
* destination is, and it lives here.
22+
*
23+
* Called from both places rather than lifted into the pane and passed down. The
24+
* queries underneath are shared by React Query, so the second caller costs a
25+
* recomputation and no request, and the pane is spared holding a fact neither
26+
* of its two halves would own.
27+
*/
28+
export function usePackageDestinations(
29+
packageId: PackageId,
30+
drift: PackageDrift | null,
31+
): {
32+
destinations: PackageDestination[];
33+
/** The space's marketplaces, which a caller needs to address one of them. */
34+
marketplaces: MarketplaceDrift[];
35+
/**
36+
* The marketplace half has not answered yet.
37+
*
38+
* Worth waiting on rather than printing what the git side alone says: a
39+
* package published to a marketplace and installed nowhere would otherwise
40+
* be announced as standing nowhere, and a count that climbs from 11 to 13
41+
* once the fan-out lands is a number the reader has to watch settle.
42+
*/
43+
isLoading: boolean;
44+
} {
45+
const { organization } = useAuthContext();
46+
/*
47+
* An empty organization id disables the query rather than asking about
48+
* nobody's marketplaces, which is what lets this be called unconditionally
49+
* from a pane that can render without an organization.
50+
*/
51+
const { publications, isLoading } = usePackageMarketplacePublications(
52+
organization?.id ?? '',
53+
packageId,
54+
);
55+
/*
56+
* Membership and staleness are two different questions, and only the first
57+
* has an answer scoped to this package: the publications say which
58+
* marketplaces carry it, the space's drift says which copies have been
59+
* overtaken. Both fan out over the same distribution queries, so React Query
60+
* answers the second from the cache of the first.
61+
*/
62+
const { marketplaces } = useSpaceMarketplaces();
63+
64+
const destinations = useMemo(
65+
() =>
66+
buildPackageDestinations({
67+
installs: drift ? installDriftEntries(drift) : [],
68+
publications: toPackagePublications(
69+
publications,
70+
marketplaces,
71+
packageId,
72+
),
73+
}),
74+
[drift, publications, marketplaces, packageId],
75+
);
76+
77+
return { destinations, marketplaces, isLoading };
78+
}

0 commit comments

Comments
 (0)