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: move filters from context to url #25

Merged
merged 1 commit into from
Mar 31, 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
File renamed without changes.
2 changes: 1 addition & 1 deletion webui/metro.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ config.cacheStores = [
// Initialize the Expo Atlas global data source in development
if (process.env.NODE_ENV === 'development') {
const { StatsFileSource } = require('../build/src/data/StatsFileSource');
const statsFile = path.resolve(__dirname, './fixture/stats-tabs-50.jsonl');
const statsFile = path.resolve(__dirname, './fixture/atlas-tabs-50.jsonl');

global.EXPO_ATLAS_SOURCE = new StatsFileSource(statsFile);
}
Expand Down
5 changes: 1 addition & 4 deletions webui/src/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Slot } from 'expo-router';

import { ModuleFilterProvider } from '~/providers/modules';
import { QueryProvider } from '~/providers/query';
import { StatsEntryProvider } from '~/providers/stats';
import { ThemeProvider } from '~/providers/theme';
Expand Down Expand Up @@ -34,9 +33,7 @@ export default function RootLayout() {
<QueryProvider>
<ThemeProvider>
<StatsEntryProvider>
<ModuleFilterProvider>
<Slot />
</ModuleFilterProvider>
<Slot />
</StatsEntryProvider>
</ThemeProvider>
</QueryProvider>
Expand Down
8 changes: 4 additions & 4 deletions webui/src/app/api/stats/[entry]/modules/index+api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { filtersFromUrlParams } from '~/providers/modules';
import { statsModuleFiltersFromUrlParams } from '~/components/forms/StatsModuleFilter';
import { getSource } from '~/utils/atlas';
import { type StatsEntry, type StatsModule } from '~core/data/types';
import { fuzzyFilterModules } from '~core/utils/search';
Expand Down Expand Up @@ -55,14 +55,14 @@ export async function GET(request: Request, params: Record<'entry', string>) {
* - `exclude=<glob>` to only exclude specific glob patterns
*/
function filterModules(request: Request, stats: StatsEntry): ModuleMetadata[] {
const { types, ...patterns } = filtersFromUrlParams(new URL(request.url).searchParams);
const filters = statsModuleFiltersFromUrlParams(new URL(request.url).searchParams);
let modules = Array.from(stats.modules.values());

if (!types.includes('node_modules')) {
if (!filters.modules.includes('node_modules')) {
modules = modules.filter((module) => !module.package);
}

return fuzzyFilterModules(modules, patterns).map((module) => ({
return fuzzyFilterModules(modules, filters).map((module) => ({
...module,
source: undefined,
output: undefined,
Expand Down
16 changes: 8 additions & 8 deletions webui/src/app/stats/[entry]/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@ import { useQuery } from '@tanstack/react-query';

import type { EntryGraphData } from '~/app/api/stats/[entry]/modules/index+api';
import { Page, PageHeader, PageTitle } from '~/components/Page';
import { StatsModuleFilter } from '~/components/forms/StatsModuleFilter';
import { TreemapGraph } from '~/components/graphs/TreemapGraph';
import {
type ModuleFilters,
useModuleFilterContext,
filtersToUrlParams,
} from '~/providers/modules';
StatsModuleFilter,
statsModuleFiltersToUrlParams,
useStatsModuleFilters,
} from '~/components/forms/StatsModuleFilter';
import { TreemapGraph } from '~/components/graphs/TreemapGraph';
import { useStatsEntry } from '~/providers/stats';
import { Tag } from '~/ui/Tag';
import { fetchApi } from '~/utils/api';
import { formatFileSize } from '~/utils/formatString';

export default function StatsScreen() {
const { entry } = useStatsEntry();
const { filters } = useModuleFilterContext();
const filters = useStatsModuleFilters();

const graph = useBundleGraphData(entry.id, filters);

Expand Down Expand Up @@ -58,13 +58,13 @@ function BundleSummary({ data }: { data: EntryGraphData }) {
}

/** Load the bundle graph data from API, with default or custom filters */
function useBundleGraphData(entryId: string, filters?: ModuleFilters) {
function useBundleGraphData(entryId: string, filters: ModuleFilters) {
return useQuery<EntryGraphData>({
queryKey: [`bundle-graph`, entryId, filters],
queryFn: ({ queryKey }) => {
const [_key, entry, filters] = queryKey as [string, string, ModuleFilters | undefined];
const url = filters
? `/api/stats/${entry}/modules?${filtersToUrlParams(filters)}`
? `/api/stats/${entry}/modules?${statsModuleFiltersToUrlParams(filters)}`
: `/api/stats/${entry}/modules`;

return fetchApi(url)
Expand Down
113 changes: 70 additions & 43 deletions webui/src/components/forms/StatsModuleFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type FormEvent, type KeyboardEvent, useState } from 'react';
import { useGlobalSearchParams, useRouter } from 'expo-router';
import { type FormEvent, type KeyboardEvent, useState, useCallback } from 'react';

import { useModuleFilterContext, useModuleFilterReducer } from '~/providers/modules';
import { Button } from '~/ui/Button';
import { Checkbox } from '~/ui/Checkbox';
import { Input } from '~/ui/Input';
Expand All @@ -13,40 +13,67 @@ import {
SheetTitle,
SheetTrigger,
} from '~/ui/Sheet';
import { debounce } from '~/utils/debounce';

export type ModuleFilters = typeof DEFAULT_FILTERS;

const DEFAULT_FILTERS = {
modules: 'project,node_modules',
include: '',
exclude: '',
};

export function useStatsModuleFilters(): ModuleFilters {
const filters = useGlobalSearchParams<Partial<ModuleFilters>>();
return {
modules: filters.modules || DEFAULT_FILTERS.modules,
include: filters.include || DEFAULT_FILTERS.include,
exclude: filters.exclude || DEFAULT_FILTERS.exclude,
};
}

export function StatsModuleFilter() {
const { filters, setFilters } = useModuleFilterContext();
// NOTE(cedric): keep a duplicate data store to avoid spamming the API with every change
const [formData, setFormData] = useModuleFilterReducer(filters);
const router = useRouter();
const filters = useStatsModuleFilters();

// NOTE(cedric): we want to programmatically close the dialog when the form is submitted, so make it controlled
const [dialogOpen, setDialogOpen] = useState(false);

function onFormSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
event.stopPropagation();

setDialogOpen(false);
setFilters(formData);
}

function onInputEnter(
event: KeyboardEvent<HTMLInputElement>,
data: (data: typeof formData) => typeof formData
) {
function onInputEnter(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') {
event.preventDefault();
setDialogOpen(false);
setFilters(data(formData));
}
}

function onDialogChange(isOpen: boolean) {
setDialogOpen(isOpen);
if (!isOpen) setFormData(filters);
}
const onModuleChange = useCallback((includeNodeModules: boolean) => {
router.setParams({
modules: includeNodeModules ? undefined : 'project',
});
}, []);

const onIncludeChange = useCallback(
debounce((value: string) => {
router.setParams({ include: value || undefined });
}, 300),
[]
);

const onExcludeChange = useCallback(
debounce((value: string) => {
router.setParams({ include: value || undefined });
}, 300),
[]
);

return (
<Sheet open={dialogOpen} onOpenChange={onDialogChange}>
<Sheet open={dialogOpen} onOpenChange={setDialogOpen}>
<SheetTrigger asChild>
<Button variant="secondary">Filter</Button>
</SheetTrigger>
Expand All @@ -65,13 +92,9 @@ export function StatsModuleFilter() {
</Label>
<Checkbox
id="filter-node_modules"
checked={formData.types.includes('node_modules')}
name=""
onCheckedChange={(isChecked) => {
setFormData(
isChecked ? { types: ['project', 'node_modules'] } : { types: ['project'] }
);
}}
defaultChecked={filters.modules.includes('node_modules')}
name="filterNodeModules"
onCheckedChange={onModuleChange}
/>
</fieldset>

Expand All @@ -85,11 +108,9 @@ export function StatsModuleFilter() {
type="text"
className="mt-2"
placeholder="e.g. app/**/*.{ts}"
value={formData.include}
onChange={(event) => setFormData({ include: event.currentTarget.value })}
onKeyDown={(event) =>
onInputEnter(event, (data) => ({ ...data, include: event.currentTarget.value }))
}
defaultValue={filters.include}
onChange={(event) => onIncludeChange(event.currentTarget.value)}
onKeyDown={onInputEnter}
/>
</fieldset>

Expand All @@ -103,24 +124,30 @@ export function StatsModuleFilter() {
type="text"
className="mt-2"
placeholder="e.g. react-native/**"
value={formData.exclude}
onChange={(event) => setFormData({ exclude: event.currentTarget.value })}
onKeyDown={(event) =>
onInputEnter(event, (data) => ({ ...data, exclude: event.currentTarget.value }))
}
defaultValue={filters.exclude}
onChange={(event) => onExcludeChange(event.currentTarget.value)}
onKeyDown={onInputEnter}
/>
</fieldset>

<div className="mt-[25px] flex justify-between">
<Button variant="quaternary" onClick={() => onDialogChange(false)}>
Cancel
</Button>
<Button variant="primary" type="submit">
Apply filters
</Button>
</div>
</form>
</SheetContent>
</Sheet>
);
}

export function statsModuleFiltersToUrlParams(filters: ModuleFilters) {
const params = new URLSearchParams({ modules: filters.modules });

if (filters.include) params.set('include', filters.include);
if (filters.exclude) params.set('exclude', filters.exclude);

return params.toString();
}

export function statsModuleFiltersFromUrlParams(params: URLSearchParams): ModuleFilters {
return {
modules: params.get('modules') || DEFAULT_FILTERS.modules,
include: params.get('include') || DEFAULT_FILTERS.include,
exclude: params.get('exclude') || DEFAULT_FILTERS.exclude,
};
}
79 changes: 0 additions & 79 deletions webui/src/providers/modules.tsx

This file was deleted.

31 changes: 31 additions & 0 deletions webui/src/utils/debounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
type Debounced<T extends (...args: any) => any> = {
/** The debounced function, without any return value */
(...args: Parameters<T>): void;
/** Cancel the debounced timer and prevent the function being called */
cancel(): void;
};

/**
* Create a function that which is called after some delay.
* The delay timer is reset every time the function is called.
* It's possible to fully cancel the function by using `debounce(...).cancel()`.
*/
export function debounce<T extends (...args: any) => any>(action: T, delay = 500): Debounced<T> {
let timerId: ReturnType<typeof setTimeout> | null = null;

function cancel() {
if (timerId) {
clearTimeout(timerId);
timerId = null;
}
}

const debounced = (...args: any[]) => {
if (timerId) clearTimeout(timerId);
timerId = setTimeout(() => action(...args), delay);
};

debounced.cancel = cancel;

return debounced;
}