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

feat: add org-list component #11

Merged
merged 6 commits into from
May 15, 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
4 changes: 2 additions & 2 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"^@/shared/core",
"^@/shared/utils",
"^@/shared/atoms",
"^@/shared/api",
"^@/shared/data",
"^@/shared/hooks",
"^@/shared/components",
"^@/shared/providers"
Expand All @@ -43,7 +43,7 @@
"^(@)(/.*/core|$)",
"^(@)(/.*/utils|$)",
"^(@)(/.*/atoms|$)",
"^(@)(/.*/api|$)",
"^(@)(/.*/data|$)",
"^(@)(/.*/hooks|$)",
"^(@)(/.*/components|$)",
"^(@)(/.*/providers|$)"
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@nextui-org/button": "^2.0.31",
"@nextui-org/link": "^2.0.29",
"@nextui-org/skeleton": "^2.0.27",
"@nextui-org/spinner": "^2.0.28",
"@nextui-org/system": "0.0.0-canary-20240504162810",
"@nextui-org/theme": "^2.2.3",
"@tanstack/react-query": "^5.36.0",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

59 changes: 59 additions & 0 deletions src/orgs/components/init-org-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use client';

import { usePathname } from 'next/navigation';

import { Spinner } from '@nextui-org/spinner';
import { useAtomValue } from 'jotai';

import { HREFS } from '@/shared/core/constants';

import { OrgListItem } from '@/orgs/core/schemas';
import { getFundingRoundsData } from '@/orgs/utils/get-funding-rounds-data';
import { initOrgAtom } from '@/orgs/core/atoms';
import { initPathAtom } from '@/shared/core/atoms';

import { OrgCard } from './org-card';

interface Props {
filterParamsString: string;
}

export const InitOrgCard = ({ filterParamsString }: Props) => {
const pathname = usePathname();

const initPath = useAtomValue(initPathAtom);
const initOrg = useAtomValue(initOrgAtom);

// Do not render if initially on list page
if (initPath === HREFS.ORGS_PAGE) return null;

// Do not render if on list page and no initOrg (mobile)
if (!initOrg && pathname === HREFS.ORGS_PAGE) return null;

// Render initOrg if set
if (initOrg) {
const { lastFundingAmount, lastFundingDate } = getFundingRoundsData(
initOrg.fundingRounds,
);

const orgItem: OrgListItem = {
...initOrg,
url: initOrg.website!,
jobCount: initOrg.jobs.length,
projectCount: initOrg.projects.length,
lastFundingAmount,
lastFundingDate,
community: [], // TODO: require mw to return community in org details
};
return (
<OrgCard
isInit
orgItem={orgItem}
filterParamsString={filterParamsString}
/>
);
}

// Defaults to a skeleton
return <Spinner size="sm" color="white" />;
};
73 changes: 73 additions & 0 deletions src/orgs/components/org-list/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Spinner } from '@nextui-org/spinner';

import { cn } from '@/shared/utils/cn';
import { reloadPage } from '@/shared/utils/reload-page';
import { InternalErrorResult } from '@/shared/components/internal-error-result';
import { VirtualWrapper } from '@/shared/components/virtual-wrapper';

import { InitOrgCard } from '@/orgs/components/init-org-card';
import { OrgCard } from '@/orgs/components/org-card';
import { useFiltersContext } from '@/filters/providers/filters-provider/context';

import { useOrgList } from './use-org-list';

export const OrgList = () => {
const {
orgs,
error,
inViewRef,
hasNextPage,
isSuccess,
isPending: isPendingOrgs,
} = useOrgList();

const hasOrgs = orgs.length > 0;

const { isPendingFilters, filterSearchParams } = useFiltersContext();

const isPending = isPendingFilters || isPendingOrgs;

const filterParamsString =
filterSearchParams.size > 0 ? `?${filterSearchParams}` : '';

if (error) {
return <InternalErrorResult onReset={reloadPage} />;
}

if (isPending) {
return <Spinner size="sm" color="white" />;
}

if (isSuccess) {
if (hasOrgs) {
return (
<>
<div>
<InitOrgCard filterParamsString={filterParamsString} />
</div>
<VirtualWrapper count={orgs.length}>
{(index) => (
<div className={cn({ 'pt-8': index > 0 })}>
<OrgCard
orgItem={orgs[index]}
filterParamsString={filterParamsString}
/>
</div>
)}
</VirtualWrapper>
{hasNextPage ? (
<div ref={inViewRef}>
<Spinner size="sm" color="white" />
</div>
) : (
<p>No more organizations available.</p>
)}
</>
);
} else {
return <p>No organizations found.</p>;
}
}

return null;
};
79 changes: 79 additions & 0 deletions src/orgs/components/org-list/use-org-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { useEffect } from 'react';
import { useInView } from 'react-intersection-observer';

import { useAtom } from 'jotai';

import { HREFS } from '@/shared/core/constants';
import { getQueryClient } from '@/shared/utils/get-query-client';

import { orgQueryKeys } from '@/orgs/core/query-keys';
import { initOrgAtom, orgTotalCountAtom } from '@/orgs/core/atoms';
import { initPathAtom } from '@/shared/core/atoms';
import { getOrgDetails } from '@/orgs/data/get-org-details';

import { useOrgListQuery } from './use-org-list-query';

export const useOrgList = () => {
const queryClient = getQueryClient();

const {
data,
error,
fetchNextPage,
hasNextPage,
isSuccess,
isPending,
isFetching,
} = useOrgListQuery();

// Sync total count
const [totalCount, setTotalCount] = useAtom(orgTotalCountAtom);
useEffect(() => {
const currentTotal = data?.pages[0].total ?? 0;
if (data && currentTotal !== totalCount) {
setTotalCount(currentTotal);
}
}, [data, setTotalCount, totalCount]);

// Prefetch org details
useEffect(() => {
if (isSuccess && data) {
const items = data.pages.flatMap((d) => d.data);
for (const item of items) {
const { orgId } = item;
queryClient.prefetchQuery({
queryKey: orgQueryKeys.details(orgId),
queryFn: () => getOrgDetails(orgId),
});
}
}
}, [data, isSuccess, queryClient]);

// Next page fetch on scroll
const { ref: inViewRef } = useInView({
threshold: 1,
onChange: (inView) => {
if (inView && !error && !isFetching) fetchNextPage();
},
});

const [initPath] = useAtom(initPathAtom);
const isOrgListSSR = initPath === HREFS.ORGS_PAGE;

const [initOrg] = useAtom(initOrgAtom);
const allOrgs = data?.pages.flatMap((d) => d.data) ?? [];

// Dedupe init-card if not list-page ssr
const orgs = !isOrgListSSR
? allOrgs.filter((d) => d.orgId !== initOrg?.orgId)
: allOrgs;

return {
orgs,
error,
inViewRef,
hasNextPage,
isSuccess,
isPending,
};
};
4 changes: 4 additions & 0 deletions src/orgs/core/atoms.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { atom } from 'jotai';

import { OrgDetails } from './schemas';

export const activeOrgIdAtom = atom<string | null>(null);
export const initOrgAtom = atom<OrgDetails | null>(null);
export const orgTotalCountAtom = atom<number | null>(null);
17 changes: 17 additions & 0 deletions src/orgs/data/get-org-details.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { MW_URL } from '@/shared/core/envs';
import { mwGET } from '@/shared/utils/mw-get';

import { orgDetailsSchema } from '@/orgs/core/schemas';

const label = 'getOrgDetails';

export const getOrgDetails = async (orgId: string) => {
const url = `${MW_URL}/organizations/details/${orgId}`;

return mwGET({
url,
label,
responseSchema: orgDetailsSchema,
options: { next: { revalidate: 60 * 60 } },
});
};
17 changes: 17 additions & 0 deletions src/orgs/utils/get-funding-rounds-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { FundingRound } from '@/shared/core/schemas';

export const getFundingRoundsData = (fundingRounds: FundingRound[]) => {
let lastFundingAmount = 0;
let lastFundingDate = 0;

if (fundingRounds.length > 0) {
for (const fundingRound of fundingRounds) {
if (fundingRound.date && fundingRound.date > (lastFundingDate ?? 0)) {
lastFundingDate = fundingRound.date;
lastFundingAmount = fundingRound.raisedAmount ?? 0;
}
}
}

return { lastFundingAmount, lastFundingDate };
};
43 changes: 43 additions & 0 deletions src/shared/components/internal-error-result.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { ErrorBoundaryProps } from '@/shared/core/types';

import { ErrorAction } from './error-action';
import { ErrorActionButton } from './error-action-button';

const IMG_SRC = '/vortex.png';
const IMAGE_CLASSNAME = 'animate-spin-slow';
const HEADING_TEXT = 'Serious Error';
const MESSAGE_TEXT =
'All shortcuts have disappeared. Screen. Mind. Both are blank';

interface Props extends Partial<ErrorBoundaryProps> {
onReset?: () => void;
}

export const InternalErrorResult = ({ onReset, reset }: Props) => {
const text = {
heading: HEADING_TEXT,
message: MESSAGE_TEXT,
};

const img = {
src: IMG_SRC,
};

const classNames = {
image: IMAGE_CLASSNAME,
};

const onClick = () => {
if (reset) reset();
if (onReset) onReset();
};

return (
<ErrorAction
textContent={text}
imageProps={img}
classNames={classNames}
action={<ErrorActionButton onClick={onClick} />}
/>
);
};
59 changes: 59 additions & 0 deletions src/shared/components/virtual-wrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useRef } from 'react';

import { useVirtualizer } from '@tanstack/react-virtual';

const OVERSCAN = 5;
const ESTIMATE_SIZE = 500;

export const useVirtualWrapper = (count: number) => {
const parentRef = useRef<HTMLDivElement | null>(null);
const virtualizer = useVirtualizer({
count,
getScrollElement: () => parentRef.current,
estimateSize: () => ESTIMATE_SIZE,
overscan: OVERSCAN,
scrollMargin: parentRef.current?.offsetTop ?? 0,
initialRect: {
width: ESTIMATE_SIZE,
height: ESTIMATE_SIZE * 20,
},
});
const items = virtualizer.getVirtualItems();

return { parentRef, virtualizer, items };
};

interface Props {
count: number;
children: (index: number) => JSX.Element;
}

export const VirtualWrapper = ({ count, children }: Props) => {
const { parentRef, virtualizer, items } = useVirtualWrapper(count);

const getTotalSize = virtualizer.getTotalSize();
const translateY = items[0]
? items[0].start - virtualizer.options.scrollMargin
: 0;

return (
<div ref={parentRef}>
<div className="relative w-full" style={{ height: getTotalSize }}>
<div
className="absolute left-0 top-0 w-full"
style={{ transform: `translateY(${translateY}px)` }}
>
{items.map((item) => (
<div
key={item.key}
data-index={item.index}
ref={virtualizer.measureElement}
>
{children(item.index)}
</div>
))}
</div>
</div>
</div>
);
};
Loading
Loading