because I got bored of customising my CV for every job
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(client): add infinite scroll and CRUD operations for job experience

+288
+112
apps/client/src/features/job-experience/components/inputs/BaseInfiniteSelect.tsx
··· 1 + import { TextInput } from "@cv/ui"; 2 + import { useMemo, useState } from "react"; 3 + 4 + interface BaseInfiniteSelectProps { 5 + label: string; 6 + placeholder: string; 7 + value: string; 8 + onChange: (value: string) => void; 9 + error?: string; 10 + required?: boolean; 11 + data: Array<{ id: string; name: string }>; 12 + fetchNextPage: () => void; 13 + hasNextPage: boolean; 14 + isFetchingNextPage: boolean; 15 + } 16 + 17 + export const BaseInfiniteSelect = ({ 18 + label, 19 + placeholder, 20 + value, 21 + onChange, 22 + error, 23 + required = false, 24 + data, 25 + fetchNextPage, 26 + hasNextPage, 27 + isFetchingNextPage, 28 + }: BaseInfiniteSelectProps) => { 29 + const [isOpen, setIsOpen] = useState(false); 30 + const [searchTerm, setSearchTerm] = useState(""); 31 + 32 + const filteredData = useMemo(() => { 33 + if (!searchTerm) { 34 + return data; 35 + } 36 + return data.filter((item) => 37 + item.name.toLowerCase().includes(searchTerm.toLowerCase()), 38 + ); 39 + }, [data, searchTerm]); 40 + 41 + const handleSelect = (item: { id: string; name: string }) => { 42 + onChange(item.id); 43 + setIsOpen(false); 44 + setSearchTerm(""); 45 + }; 46 + 47 + const handleInputChange = (value: string) => { 48 + setSearchTerm(value); 49 + if (!isOpen) { 50 + setIsOpen(true); 51 + } 52 + }; 53 + 54 + const handleInputFocus = () => { 55 + setIsOpen(true); 56 + }; 57 + 58 + const handleInputBlur = () => { 59 + // Delay closing to allow for clicks on options 60 + setTimeout(() => setIsOpen(false), 150); 61 + }; 62 + 63 + const selectedItem = data.find((item) => item.id === value); 64 + 65 + return ( 66 + <div className="relative"> 67 + <TextInput 68 + label={label} 69 + placeholder={placeholder} 70 + value={isOpen ? searchTerm : selectedItem?.name || ""} 71 + onChange={handleInputChange} 72 + onFocus={handleInputFocus} 73 + onBlur={handleInputBlur} 74 + error={error} 75 + required={required} 76 + readOnly={!isOpen} 77 + /> 78 + 79 + {isOpen && ( 80 + <div className="absolute z-50 w-full mt-1 bg-ctp-surface0 border border-ctp-surface1 rounded-md shadow-lg max-h-60 overflow-auto"> 81 + {filteredData.length === 0 ? ( 82 + <div className="px-3 py-2 text-ctp-subtext0 text-sm"> 83 + No options found 84 + </div> 85 + ) : ( 86 + filteredData.map((item) => ( 87 + <button 88 + key={item.id} 89 + type="button" 90 + className="w-full px-3 py-2 text-left text-ctp-text hover:bg-ctp-surface1 focus:bg-ctp-surface1 focus:outline-none" 91 + onClick={() => handleSelect(item)} 92 + > 93 + {item.name} 94 + </button> 95 + )) 96 + )} 97 + 98 + {hasNextPage && ( 99 + <button 100 + type="button" 101 + className="w-full px-3 py-2 text-left text-ctp-blue hover:bg-ctp-surface1 focus:bg-ctp-surface1 focus:outline-none border-t border-ctp-surface1" 102 + onClick={fetchNextPage} 103 + disabled={isFetchingNextPage} 104 + > 105 + {isFetchingNextPage ? "Loading..." : "Load more..."} 106 + </button> 107 + )} 108 + </div> 109 + )} 110 + </div> 111 + ); 112 + };
+59
apps/client/src/features/job-experience/components/inputs/CompanySelect.tsx
··· 1 + import { useInfiniteCompaniesConnectionQuery } from "@/generated/graphql"; 2 + import { BaseInfiniteSelect } from "./BaseInfiniteSelect"; 3 + import { useInfiniteConnectionQuery } from "./useInfiniteConnectionQuery"; 4 + 5 + interface CompanySelectProps { 6 + value: string; 7 + onChange: (value: string) => void; 8 + error?: string; 9 + } 10 + 11 + export const CompanySelect = ({ 12 + value, 13 + onChange, 14 + error, 15 + }: CompanySelectProps) => { 16 + const { 17 + data: companiesData, 18 + fetchNextPage: fetchNextCompanies, 19 + hasNextPage: hasNextCompaniesPage, 20 + isFetchingNextPage: isLoadingMoreCompanies, 21 + } = useInfiniteCompaniesConnectionQuery( 22 + {}, 23 + { 24 + initialPageParam: { after: null }, 25 + getNextPageParam: (lastPage) => 26 + lastPage.companies.pageInfo.hasNextPage 27 + ? { after: lastPage.companies.pageInfo.endCursor } 28 + : undefined, 29 + }, 30 + ); 31 + 32 + const companies = 33 + companiesData?.pages.flatMap((page) => 34 + page.companies.edges.map(({ node }) => node), 35 + ) || []; 36 + 37 + const { items, fetchNextPage, hasNextPage, isFetchingNextPage } = 38 + useInfiniteConnectionQuery({ 39 + data: companies, 40 + fetchNextPage: fetchNextCompanies, 41 + hasNextPage: hasNextCompaniesPage, 42 + isFetchingNextPage: isLoadingMoreCompanies, 43 + }); 44 + 45 + return ( 46 + <BaseInfiniteSelect 47 + label="Company" 48 + placeholder="Select a company" 49 + value={value} 50 + onChange={onChange} 51 + error={error} 52 + required 53 + data={items} 54 + fetchNextPage={fetchNextPage} 55 + hasNextPage={hasNextPage} 56 + isFetchingNextPage={isFetchingNextPage} 57 + /> 58 + ); 59 + };
+24
apps/client/src/features/job-experience/components/inputs/useInfiniteConnectionQuery.ts
··· 1 + import { useMemo } from "react"; 2 + 3 + interface UseInfiniteConnectionQueryProps { 4 + data: Array<{ id: string; name: string }>; 5 + fetchNextPage: () => void; 6 + hasNextPage: boolean; 7 + isFetchingNextPage: boolean; 8 + } 9 + 10 + export const useInfiniteConnectionQuery = ({ 11 + data, 12 + fetchNextPage, 13 + hasNextPage, 14 + isFetchingNextPage, 15 + }: UseInfiniteConnectionQueryProps) => { 16 + const items = useMemo(() => data, [data]); 17 + 18 + return { 19 + items, 20 + fetchNextPage, 21 + hasNextPage, 22 + isFetchingNextPage, 23 + }; 24 + };
+22
apps/client/src/features/job-experience/queries/companies-connection.graphql
··· 1 + query CompaniesConnection($searchTerm: String) { 2 + companies(searchTerm: $searchTerm) { 3 + edges { 4 + cursor 5 + node { 6 + id 7 + name 8 + description 9 + website 10 + createdAt 11 + updatedAt 12 + } 13 + } 14 + pageInfo { 15 + hasNextPage 16 + hasPreviousPage 17 + startCursor 18 + endCursor 19 + } 20 + totalCount 21 + } 22 + }
+8
apps/client/src/features/job-experience/queries/create-company.graphql
··· 1 + mutation CreateCompany($name: String!, $description: String, $website: String) { 2 + createCompany(name: $name, description: $description, website: $website) { 3 + id 4 + name 5 + description 6 + website 7 + } 8 + }
+7
apps/client/src/features/job-experience/queries/create-level.graphql
··· 1 + mutation CreateLevel($name: String!, $description: String) { 2 + createLevel(name: $name, description: $description) { 3 + id 4 + name 5 + description 6 + } 7 + }
+7
apps/client/src/features/job-experience/queries/create-role.graphql
··· 1 + mutation CreateRole($name: String!, $description: String) { 2 + createRole(name: $name, description: $description) { 3 + id 4 + name 5 + description 6 + } 7 + }
+7
apps/client/src/features/job-experience/queries/create-skill.graphql
··· 1 + mutation CreateSkill($name: String!, $description: String) { 2 + createSkill(name: $name, description: $description) { 3 + id 4 + name 5 + description 6 + } 7 + }
+21
apps/client/src/features/job-experience/queries/levels-connection.graphql
··· 1 + query LevelsConnection($searchTerm: String) { 2 + levels(searchTerm: $searchTerm) { 3 + edges { 4 + cursor 5 + node { 6 + id 7 + name 8 + description 9 + createdAt 10 + updatedAt 11 + } 12 + } 13 + pageInfo { 14 + hasNextPage 15 + hasPreviousPage 16 + startCursor 17 + endCursor 18 + } 19 + totalCount 20 + } 21 + }
+21
apps/client/src/features/job-experience/queries/roles-connection.graphql
··· 1 + query RolesConnection($searchTerm: String) { 2 + roles(searchTerm: $searchTerm) { 3 + edges { 4 + cursor 5 + node { 6 + id 7 + name 8 + description 9 + createdAt 10 + updatedAt 11 + } 12 + } 13 + pageInfo { 14 + hasNextPage 15 + hasPreviousPage 16 + startCursor 17 + endCursor 18 + } 19 + totalCount 20 + } 21 + }