Skip to content

Commit

Permalink
refactor(webui): group bundles by platform and add environment icons (#…
Browse files Browse the repository at this point in the history
…71)

* refactor(webui): drop legacy bundle sorting from api route

* refactor(webui): group bundle selection by platform and introduce environment icons

* feature(webui): add explanation of bundle platform and environment
  • Loading branch information
byCedric authored Aug 29, 2024
1 parent f3d4b1f commit b33852a
Show file tree
Hide file tree
Showing 10 changed files with 403 additions and 137 deletions.
2 changes: 1 addition & 1 deletion src/data/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export type AtlasBundle = {
/** The unique reference or ID to this entry */
id: string;
/** The platform for which the bundle was created */
platform: 'android' | 'ios' | 'web' | 'server' | 'unknown';
platform: 'android' | 'ios' | 'web' | 'unknown';
/** The environment this bundle is compiled for */
environment: 'client' | 'node' | 'react-server';
/** The absolute path to the root of the project */
Expand Down
9 changes: 1 addition & 8 deletions webui/src/app/--/bundles/index+api.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { getSource } from '~/utils/atlas';
import type { PartialAtlasBundle } from '~core/data/types';

export async function GET() {
try {
const bundles = (await getSource().listBundles()).sort(sortBundlesByPlatform);
const bundles = await getSource().listBundles();
const bundlesWithRelativeEntry = bundles.map((bundle) => ({
...bundle,
// TODO(cedric): this is a temporary workaround to make entry points look better on Windows
Expand All @@ -19,9 +18,3 @@ export async function GET() {
return Response.json({ error: error.message }, { status: 406 });
}
}

function sortBundlesByPlatform(a: PartialAtlasBundle, b: PartialAtlasBundle) {
if (a.platform === 'server') return 1;
if (b.platform === 'server') return -1;
return 0;
}
15 changes: 9 additions & 6 deletions webui/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { HmrProvider } from '~/providers/hmr';
import { QueryProvider } from '~/providers/query';
import { ThemeProvider } from '~/providers/theme';
import { ToastProvider } from '~/ui/Toast';
import { TooltipProvider } from '~/ui/Tooltip';

// Import the Expo-required radix styles
import '@radix-ui/colors/green.css';
Expand Down Expand Up @@ -34,12 +35,14 @@ export default function RootLayout() {
return (
<QueryProvider>
<ThemeProvider>
<BundleProvider>
<ToastProvider />
<HmrProvider>
<Slot />
</HmrProvider>
</BundleProvider>
<TooltipProvider delayDuration={200}>
<BundleProvider>
<ToastProvider />
<HmrProvider>
<Slot />
</HmrProvider>
</BundleProvider>
</TooltipProvider>
</ThemeProvider>
</QueryProvider>
);
Expand Down
127 changes: 76 additions & 51 deletions webui/src/components/BundleSelectForm.tsx
Original file line number Diff line number Diff line change
@@ -1,63 +1,88 @@
import * as Select from '@radix-ui/react-select';
import { cx } from 'class-variance-authority';
import { useRouter } from 'expo-router';
// @ts-expect-error
import ChevronDownIcon from 'lucide-react/dist/esm/icons/chevron-down';
import { useMemo } from 'react';

import { BundleTag } from '~/components/BundleTag';
import { EnvironmentIcon } from '~/components/EnvironmentIcon';
import { PlatformName } from '~/components/PlatformName';
import { useBundle } from '~/providers/bundle';
import { Button } from '~/ui/Button';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '~/ui/Select';
import { relativeBundlePath } from '~/utils/bundle';
import type { PartialAtlasBundle } from '~core/data/types';

export function BundleSelectForm() {
const router = useRouter();
const { bundle, bundles } = useBundle();
const bundlesByPlatform = useMemo(() => groupBundlesByPlatform(bundles), [bundles]);

return (
<Select.Root value={bundle.id} onValueChange={(bundle) => router.setParams({ bundle })}>
<Select.Trigger asChild>
<Button variant="quaternary" size="sm">
<BundleTag
className="mr-2"
size="xs"
platform={bundle.platform}
environment={bundle.environment}
/>
<Select.Value placeholder="Select bundle to inspect" />
<Select.Icon className="text-icon-default">
<ChevronDownIcon size={16} className="m-1 mr-0 align-middle" />
</Select.Icon>
</Button>
</Select.Trigger>
<Select.Portal>
<Select.Content
side="bottom"
collisionPadding={{ left: 16, right: 16 }}
className={cx(
'flex min-w-[220px] flex-col gap-0.5 rounded-md border border-default bg-default p-1 shadow-md',
'transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-200 data-[state=open]:duration-300',
'data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-1/3 data-[state=open]:fade-in data-[state=open]:slide-in-from-top-1/3'
)}
>
<Select.Viewport className="py-2">
{bundles.map((item) => (
<div key={item.id}>
<Select.Item value={item.id} asChild>
<Button variant="quaternary" size="sm" className="w-full !justify-start my-0.5">
<BundleTag
className="mr-2"
size="xs"
platform={item.platform}
environment={item.environment}
/>
<Select.ItemText>{relativeBundlePath(item, item.entryPoint)}</Select.ItemText>
</Button>
</Select.Item>
</div>
))}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
<Select value={bundle.id} onValueChange={(bundle) => router.setParams({ bundle })}>
<SelectTrigger className="!w-auto">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent side="bottom" collisionPadding={{ left: 16, right: 16 }}>
{bundlesByPlatform.map(([platform, bundles]) => {
// Hide empty `unknown` platforms. If there are unknown platforms, render them.
if (platform === 'unknown' && bundles.length === 0) {
return null;
}

return (
<SelectGroup key={platform}>
<SelectLabel className="m-0.5 capitalize">
<PlatformName platform={platform} />
</SelectLabel>
{bundles.length === 0 ? (
<SelectItem disabled value="none" className="italic mb-1">
No bundle available for this platform
</SelectItem>
) : (
bundles.map((item) => (
<SelectItem key={item.id} value={item.id}>
<span className="inline-flex items-center select-none mb-0.5">
<PlatformName platform={item.platform}>
<EnvironmentIcon environment={item.environment} size={16} />
</PlatformName>
<span className="ml-2 mr-1">{relativeBundlePath(item, item.entryPoint)}</span>
</span>
</SelectItem>
))
)}
</SelectGroup>
);
})}
</SelectContent>
</Select>
);
}

function groupBundlesByPlatform(bundles: PartialAtlasBundle[]) {
const groups: Record<PartialAtlasBundle['platform'], PartialAtlasBundle[]> = {
android: [],
ios: [],
web: [],
unknown: [],
};

for (const bundle of bundles) {
if (groups[bundle.platform]) {
groups[bundle.platform]!.push(bundle);
}
}

return Object.entries(groups).map(
([platform, bundles]) =>
[platform as PartialAtlasBundle['platform'], bundles.sort(sortBundlesByEnvironment)] as const
);
}

/** Sort all bundles by environment, in alphabetical order "client -> node -> react-server" */
function sortBundlesByEnvironment(a: PartialAtlasBundle, b: PartialAtlasBundle) {
return a.environment.localeCompare(b.environment);
}
104 changes: 60 additions & 44 deletions webui/src/components/BundleTag.tsx
Original file line number Diff line number Diff line change
@@ -1,72 +1,88 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { type ComponentProps } from 'react';

import { EnvironmentIcon } from '~/components/EnvironmentIcon';
import { EnvironmentName } from '~/components/EnvironmentName';
import { PlatformName } from '~/components/PlatformName';
import { Tag } from '~/ui/Tag';
import { Tooltip, TooltipContent, TooltipTrigger } from '~/ui/Tooltip';
import type { AtlasBundle } from '~core/data/types';

const bundleTagVariants = cva('', {
variants: {
platform: {
android: 'bg-palette-green3 text-palette-green11',
ios: 'bg-palette-blue3 text-palette-blue11',
web: 'bg-palette-orange3 text-palette-orange11',
server: 'bg-palette-orange3 text-palette-orange11',
android: 'bg-palette-green3',
ios: 'bg-palette-blue3',
web: 'bg-palette-orange3',
unknown: '',
},
} satisfies Record<AtlasBundle['platform'], string>,
environment: {
client: '',
node: 'bg-palette-orange3 text-palette-orange11',
'react-server': 'bg-palette-green3 text-palette-green11',
},
node: 'bg-palette-orange3',
'react-server': 'bg-palette-orange3',
} satisfies Record<AtlasBundle['environment'], string>,
},
defaultVariants: {
platform: 'unknown', // Default platform value, see MetroGraphSource
environment: 'client', // Default environment value, see MetroGraphSource
},
});

const platformChildren: Record<AtlasBundle['platform'], string> = {
android: 'Android',
ios: 'iOS',
server: 'Server',
web: 'Web',
unknown: '???',
};

const environmentChildren: Record<AtlasBundle['environment'], string> = {
client: 'Client',
node: 'SSR',
'react-server': 'RSC',
};

type BundelTagProps = Omit<
ComponentProps<typeof Tag> & VariantProps<typeof bundleTagVariants>,
'children'
>;

export function BundleTag({ className, platform, environment, ...props }: BundelTagProps) {
return (
<Tag
className={bundleTagVariants({ platform, environment, className })}
variant="none"
{...props}
>
{getBundelTagChildren({ platform, environment })}
</Tag>
<Tooltip>
<TooltipTrigger>
<Tag
className={bundleTagVariants({ platform, environment, className })}
variant="none"
{...props}
>
<PlatformName platform={platform!} className="inline-flex items-center gap-1.5">
<PlatformName platform={platform!} />
<span>×</span>
<EnvironmentName environment={environment!} />
<EnvironmentIcon environment={environment!} size={14} />
</PlatformName>
</Tag>
</TooltipTrigger>
<TooltipContent>
<p>
Expo creates bundles for every platform containing only{' '}
<a
className="text-link hover:underline"
href="https://reactnative.dev/docs/platform-specific-code"
target="_blank"
>
platform-specific code
</a>
, like Android, iOS, and Web. Some platforms can also run in multiple environments.
</p>
<p className="my-2">
Atlas marks every bundle with both the platform and target environment for which the
bundle is built.
</p>
<p className="mt-2">
<ul className="list-disc">
<li className="inline-flex items-center gap-1">
<EnvironmentIcon environment="client" size={14} /> Client — Bundles that run on
device.
</li>
<li className="inline-flex items-center gap-1">
<EnvironmentIcon environment="node" size={14} /> SSR — Bundles that only run on
server.
</li>
<li className="inline-flex items-center gap-1">
<EnvironmentIcon environment="react-server" size={14} /> RSC — React server component
bundles.
</li>
</ul>
</p>
</TooltipContent>
</Tooltip>
);
}

function getBundelTagChildren(props: BundelTagProps) {
const children: string[] = [];

if (props.platform) {
children.push(platformChildren[props.platform]);
}

// Only add the environment specifier if it's not bundled for the client
if (props.environment && props.environment !== 'client') {
children.push(environmentChildren[props.environment]);
}

return children.join(' × ');
}
21 changes: 21 additions & 0 deletions webui/src/components/EnvironmentIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { LucideProps } from 'lucide-react';

import type { AtlasBundle } from '~core/data/types';

type EnvironmentIconProps = Omit<
LucideProps & {
environment: AtlasBundle['environment'];
},
'children'
>;

const iconsByEnvironment: Record<AtlasBundle['environment'], any> = {
client: require('lucide-react/dist/esm/icons/tablet-smartphone').default,
node: require('lucide-react/dist/esm/icons/hexagon').default,
'react-server': require('lucide-react/dist/esm/icons/server').default,
};

export function EnvironmentIcon({ className, environment, ...props }: EnvironmentIconProps) {
const Icon = iconsByEnvironment[environment];
return <Icon {...props} />;
}
18 changes: 18 additions & 0 deletions webui/src/components/EnvironmentName.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { PropsWithChildren } from 'react';

import type { AtlasBundle } from '~core/data/types';

type EnvironmentNameProps = PropsWithChildren<{
environment: AtlasBundle['environment'];
className?: string;
}>;

export const environmentNames: Record<AtlasBundle['environment'], string> = {
client: 'Client',
node: 'SSR',
'react-server': 'RSC',
};

export function EnvironmentName({ children, environment, ...props }: EnvironmentNameProps) {
return <span {...props}>{children || environmentNames[environment]}</span>;
}
Loading

0 comments on commit b33852a

Please sign in to comment.