Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(webui): group bundles by platform and add environment icons #71

Merged
merged 3 commits into from
Aug 29, 2024
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
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