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

[hackathon] Refactor the apps page #120

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
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
6,598 changes: 3,446 additions & 3,152 deletions src/api/api.generated.d.ts

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export const api = {
pauseApp: endpoint('post', '/v1/apps/{id}/pause'),
resumeApp: endpoint('post', '/v1/apps/{id}/resume'),
deleteApp: endpoint('delete', '/v1/apps/{id}'),
listAppsDetails: endpoint('get', '/v1/apps-details'),

// services
listServices: endpoint('get', '/v1/services'),
Expand Down Expand Up @@ -140,6 +141,9 @@ export const api = {
listApiCredentials: endpoint('get', '/v1/credentials'),
createApiCredential: endpoint('post', '/v1/credentials'),
deleteApiCredential: endpoint('delete', '/v1/credentials/{id}'),

// summary
getServicesSummary: endpoint('get', '/v1/summary/services'),
};

export const apiStreams = {
Expand Down
20 changes: 20 additions & 0 deletions src/api/hooks/summary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';

import { mapServicesSummary } from '../mappers/summary';
import { useApiQueryFn } from '../use-api';

export function useServicesSummaryQuery() {
return useQuery({
...useApiQueryFn('getServicesSummary', {
query: {
by_type: true,
by_status: true,
},
}),
select: mapServicesSummary,
});
}

export function useServicesSummary() {
return useServicesSummaryQuery().data;
}
31 changes: 31 additions & 0 deletions src/api/mappers/apps_details.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { ApiEndpointResult } from '../api';
import { AppDetails, AppStatus, DomainType, ServiceStatus, ServiceType } from '../model';

import { transformDeployment } from './deployment';

export function mapAppsDetails({ items }: ApiEndpointResult<'listAppsDetails'>): AppDetails[] {
return items!.map((item) => ({
id: item.id!,
name: item.name!,
status: item.status!.toLowerCase() as AppStatus,
domains: item.domains!.map((domain) => ({
id: domain.id!,
name: domain.name!,
type: domain.type!.toLowerCase() as DomainType,
})),
services: item.services?.map((service) => ({
id: service.id!,
appId: service.app_id!,
latestDeploymentId: service.latest_deployment_id!,
activeDeploymentId: service.active_deployment_id!,
type: service.type!.toLowerCase() as ServiceType,
name: service.name!,
status: service.status!.toLowerCase() as ServiceStatus,
upcomingDeploymentIds: [],
createdAt: service.created_at!,

latestDeployment:
service.latest_deployment !== undefined ? transformDeployment(service.latest_deployment) : undefined,
})),
}));
}
2 changes: 1 addition & 1 deletion src/api/mappers/deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function mapInstances({ instances }: ApiEndpointResult<'listInstances'>):
}));
}

function transformDeployment(deployment: ApiDeployment): Deployment {
export function transformDeployment(deployment: ApiDeployment): Deployment {
if (deployment.definition!.type! === 'DATABASE') {
return transformDatabaseDeployment(deployment);
}
Expand Down
24 changes: 24 additions & 0 deletions src/api/mappers/summary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { toObject } from 'src/utils/object';

import { ApiEndpointResult } from '../api';
import { ServicesSummary } from '../model';

export function mapServicesSummary({
total,
by_type,
by_status,
}: ApiEndpointResult<'getServicesSummary'>): ServicesSummary {
return {
total: parseInt(total!),
byType: toObject(
Object.entries(by_type!),
([key]) => key,
([, value]) => Number(value),
),
byStatus: toObject(
Object.entries(by_status!),
([key]) => key,
([, value]) => Number(value),
),
};
}
22 changes: 21 additions & 1 deletion src/api/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,10 +452,12 @@ export type AppStatus =
| 'paused'
| 'resuming';

export type DomainType = 'autoassigned' | 'custom';

export type AppDomain = {
id: string;
name: string;
type: 'autoassigned' | 'custom';
type: DomainType;
};

export type ExampleApp = {
Expand Down Expand Up @@ -493,6 +495,16 @@ export type ServiceStatus =
| 'paused'
| 'resuming';

// apps details

export type ServiceDetails = Service & {
latestDeployment?: Deployment;
};

export type AppDetails = App & {
services?: ServiceDetails[];
};

// session

export type OnboardingStep =
Expand Down Expand Up @@ -655,3 +667,11 @@ export type VolumeSnapshotStatus =
| 'deleted';

export type VolumeSnapshotType = 'invalid' | 'local' | 'remote';

// summary

export type ServicesSummary = {
total: number;
byStatus?: Record<ServiceStatus, number>;
byType?: Record<ServiceType, number>;
};
29 changes: 2 additions & 27 deletions src/pages/activity/activity.page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useInfiniteQuery, UseInfiniteQueryResult, useQueryClient } from '@tanstack/react-query';
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import clsx from 'clsx';
import { useCallback, useMemo, useState } from 'react';

import { Spinner } from '@koyeb/design-system';
import { api } from 'src/api/api';
Expand All @@ -14,10 +13,10 @@ import { Loading } from 'src/components/loading';
import { QueryError } from 'src/components/query-error';
import { TextSkeleton } from 'src/components/skeleton';
import { Title } from 'src/components/title';
import { useIntersectionObserver } from 'src/hooks/intersection-observer';
import { useMount } from 'src/hooks/lifecycle';
import { Translate } from 'src/intl/translate';
import { createArray } from 'src/utils/arrays';
import { useInfiniteScroll } from 'src/utils/pagination';

const T = Translate.prefix('pages.activity');

Expand Down Expand Up @@ -84,30 +83,6 @@ export function ActivityPage() {
);
}

function useInfiniteScroll(query: UseInfiniteQueryResult) {
const { error, hasNextPage, isFetchingNextPage, fetchNextPage } = query;
const [elementRef, setElementRef] = useState<HTMLElement | null>(null);

useIntersectionObserver(
elementRef,
useMemo(() => ({ root: null }), []),
useCallback(
(entry) => {
if (entry.intersectionRatio === 0) {
return;
}

if (!error && hasNextPage && !isFetchingNextPage) {
void fetchNextPage();
}
},
[error, hasNextPage, isFetchingNextPage, fetchNextPage],
),
);

return setElementRef;
}

function ActivitySkeleton() {
return (
<>
Expand Down
111 changes: 76 additions & 35 deletions src/pages/home/apps/apps.tsx
Original file line number Diff line number Diff line change
@@ -1,59 +1,100 @@
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';

import { Select } from '@koyeb/design-system';
import { useApps, useServices } from 'src/api/hooks/service';
import { Service, ServiceType } from 'src/api/model';
import { Spinner, Select } from '@koyeb/design-system';
import { api } from 'src/api/api';
import { useServicesSummary } from 'src/api/hooks/summary';
import { mapAppsDetails } from 'src/api/mappers/apps_details';
import { AppDetails, ServicesSummary, ServiceType } from 'src/api/model';
import { useToken } from 'src/application/token';
import { QueryError } from 'src/components/query-error';
import { useMount } from 'src/hooks/lifecycle';
import { Translate } from 'src/intl/translate';
import { defined } from 'src/utils/assert';
import { identity } from 'src/utils/generic';
import { hasProperty } from 'src/utils/object';
import { useInfiniteScroll } from 'src/utils/pagination';

import { AppItem } from './app-item';

const T = Translate.prefix('pages.home');

const pageSize = 100;

export function Apps({ showFilters = false }: { showFilters?: boolean }) {
const [serviceType, setServiceType] = useState<ServiceType | 'all'>('all');
const { token } = useToken();
const queryClient = useQueryClient();

const servicesSummary = useServicesSummary();

const query = useInfiniteQuery({
queryKey: ['listAppsDetails', { token }],
async queryFn({ pageParam }) {
return api
.listAppsDetails({
token,
query: {
offset: String(pageParam * pageSize),
limit: String(pageSize),
order: 'desc',
service_limit: 10,
},
})
.then(mapAppsDetails);
},
initialPageParam: 0,
getNextPageParam: (lastPage: AppDetails[], pages, lastPageParam) => {
if (lastPage.length === pageSize) {
return lastPageParam + 1;
}

return null;
},
});

const setInfiniteScrollElementRef = useInfiniteScroll(query);

const apps = defined(useApps());
const services = defined(useServices());
useMount(() => {
return () => queryClient.removeQueries({ queryKey: ['listAppsDetails'] });
});

if (query.isError) {
return <QueryError error={query.error} />;
}

return (
// https://css-tricks.com/flexbox-truncated-text/
<div className="col min-w-0 flex-1 gap-6">
<Header
showFilters={showFilters}
services={services}
serviceType={serviceType}
setServiceType={setServiceType}
/>

{apps.map((app) => {
const appServices = services
.filter(hasProperty('appId', app.id))
.filter((service) => serviceType === 'all' || service.type === serviceType);

if (appServices.length === 0 && serviceType !== 'all') {
return null;
}

return <AppItem key={app.id} app={app} services={appServices} />;
})}
</div>
<>
<div className="col min-w-0 flex-1 gap-6">
<Header
showFilters={showFilters}
servicesSummary={servicesSummary}
serviceType={serviceType}
setServiceType={setServiceType}
/>

{query.data?.pages.flat().map((app) => {
return <AppItem key={app.id} app={app} services={app.services || []} />;
})}
</div>

<div ref={setInfiniteScrollElementRef} className="row justify-center py-4">
{query.isFetchingNextPage && <Spinner className="size-4" />}
</div>
</>
);
}

type HeaderProps = {
showFilters: boolean;
services: Service[];
servicesSummary?: ServicesSummary;
serviceType: ServiceType | 'all';
setServiceType: (type: ServiceType | 'all') => void;
};

function Header({ showFilters, services, serviceType, setServiceType }: HeaderProps) {
const webServices = services.filter(hasProperty('type', 'web'));
const workerServices = services.filter(hasProperty('type', 'worker'));
const databaseServices = services.filter(hasProperty('type', 'database'));
function Header({ showFilters, servicesSummary, serviceType, setServiceType }: HeaderProps) {
const webServices = servicesSummary!.byType!.web ?? 0;
const workerServices = servicesSummary!.byType!.worker ?? 0;
const databaseServices = servicesSummary!.byType!.database ?? 0;

return (
<header className="col sm:row gap-2 sm:items-center sm:gap-4">
Expand All @@ -65,9 +106,9 @@ function Header({ showFilters, services, serviceType, setServiceType }: HeaderPr
<T
id="servicesSummary"
values={{
web: webServices.length,
worker: workerServices.length,
database: databaseServices.length,
web: webServices,
worker: workerServices,
database: databaseServices,
}}
/>
</span>
Expand Down
28 changes: 28 additions & 0 deletions src/utils/pagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { UseInfiniteQueryResult } from '@tanstack/react-query';
import { useCallback, useMemo, useState } from 'react';

import { useIntersectionObserver } from 'src/hooks/intersection-observer';

export function useInfiniteScroll(query: UseInfiniteQueryResult) {
const { error, hasNextPage, isFetchingNextPage, fetchNextPage } = query;
const [elementRef, setElementRef] = useState<HTMLElement | null>(null);

useIntersectionObserver(
elementRef,
useMemo(() => ({ root: null }), []),
useCallback(
(entry) => {
if (entry.intersectionRatio === 0) {
return;
}

if (!error && hasNextPage && !isFetchingNextPage) {
void fetchNextPage();
}
},
[error, hasNextPage, isFetchingNextPage, fetchNextPage],
),
);

return setElementRef;
}