mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-25 15:52:21 +10:00
Add application tracker (#3220)
* feat(applications): job application tracker with AI copilot Add an Applications module at /dashboard/applications: pipeline board (dnd-kit), table view with bulk actions, Insights (fit tiles, funnel, sources, shareable funnel-flow SVG), campaigns, tags, CSV import, and Add/Edit/Detail slide-overs. Each application links a live Reactive Resume. AI "Application Copilot" (applications.ai.*): job-posting autofill, resume↔job match score (fit ring), resume tailoring, and cover-letter / follow-up drafting — via the user's configured provider. Board cards + table rows get context menus (edit / move / archive / delete). Charts are CSS/SVG (no new chart dep); adds a UI Checkbox. Also includes local TanStack devtools setup and toolchain bumps. Claude-Session: https://claude.ai/code/session_01TEeRHnEayw2MFCShFRyL5f * feat(applications): close follow-up gaps + squash migrations Finish the deferred/open items on the applications tracker: - Cover-letter upload re-enabled. Fix the storage blocker by deriving the key extension from content type (buildFileKey/EXTENSION_BY_CONTENT_TYPE) instead of hardcoding .jpeg, so PDFs serve correctly and non-JPEG image avatars keep working under FLAG_DISABLE_IMAGE_PROCESSING. Add coverLetterUrl/coverLetterName columns + Documents-section upload/remove. - Contacts editor in the detail sheet (add/edit/remove, keyed per app). - Board caps rendered cards per column (COLUMN_PAGE_SIZE=50 + "Show more"). - Extract new Lingui messages across locales. - Guard coverLetterUrl to http(s)/relative at the API boundary. Squash the five branch-only application-table migrations (create -> +tags -> +cover-letter -> drop -> re-add) into a single clean CREATE TABLE via drizzle-kit generate. Claude-Session: https://claude.ai/code/session_01TEeRHnEayw2MFCShFRyL5f * chore: update dependencies * fix(web): address React Doctor findings — compiler, purity, query, component structure prefer-module-scope-pure-function: hoist buildSubtitle, getDecimalPlaces, handleLocaleChange, onLocaleChange, stop, listContent/groupedListContent to module scope so they aren't rebuilt on every render. react-compiler-todo (??=): rewrite draft.metadata.styleRules ??= [] to the non-assignment form to unblock auto-memoization. set-state-in-effect: derive updatedAtLabel at render time instead of syncing it through useState + useEffect. query-destructure-result: destructure useQuery results at call site in resume-analysis and resume-thumbnail to follow TanStack Query v5 convention. only-export-components: extract non-component exports to sibling .ts files so Fast Refresh can preserve component state: - getNextWeights → typography/get-next-weights.ts - detectJsonImportType + ImportType → dialogs/resume/import.utils.ts - getLocaleOptions → features/locale/locale-options.tsx - preview helpers + DEFAULT_PDF_PAGE_SIZE → preview.shared.utils.ts - resolveHighlightToolbarState + defaultHighlightColor → rich-input.utils.ts - computeDelta + getSparklinePoints → statistics.utils.ts no-multi-comp: split multi-component files into focused companions: - ResumePane + ToolbarButton → routes/agent/-components/resume-pane.tsx - DesktopBuilderShell → builder/$resumeId/-components/desktop-builder-shell.tsx - MobileBuilderShell + helpers → builder/$resumeId/-components/mobile-builder-shell.tsx - setBuilderLayout/getBuilderLayout moved to -store/sidebar.ts fix(tests): add Resume type import to section-builder mocks and cast partial mock data as unknown as Resume to satisfy stricter type checking; fix noExplicitAny Biome errors in the same mocks. * feat(applications): improve performance * chore: fix knip issues * perf(builder): halve per-keystroke render cost Section-form fields called `form.handleSubmit()` on every keystroke, which re-validated the whole form and toggled submit state — firing the render cascade twice per character (~6809 renders/keystroke, FPS dropping to 9). Persist via a form-level `listeners.onChange` instead and drop the per-field `handleSubmit()` (basics, custom-fields, design). Narrow header/dock resume subscriptions to metadata slices so they no longer re-render on content edits. Cuts renders 6809 -> 3403 per keystroke (50%), 0 frame drops. Save, preview, and design controls verified working; 449/449 web tests pass. * perf(home): eliminate hero CLS from unreserved video box The hero <section> is `flex items-center` (shrink-to-fit), so the video wrapper's width depended on the video's intrinsic size, which only resolves after the media loads. aspect-ratio couldn't reserve height without a definite width, so the video grew from ~190px to ~563px after first paint and shoved the centered hero text down ~373px (CLS ~0.095). Give the wrapper a definite width (w-full + mx-auto on the CometCard) and set an explicit aspect ratio + width/height on the video so its box is reserved before load. CLS 0.095 -> 0; hero stays visually centered at max-w-4xl. * docs: add application tracker guides * chore(db): squash application migrations * fix(email): import React in auth template for server-side rendering compatibility * chore(release): v5.2.1 * Refactor resume rendering and builder workflows * fix: address application tracker review findings
This commit is contained in:
@@ -3,13 +3,12 @@ import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function AwardsSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.awards;
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.awards);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof awardItemSchema>[]) => {
|
||||
|
||||
@@ -35,6 +35,14 @@ function BasicsSectionForm() {
|
||||
const form = useAppForm({
|
||||
defaultValues: basics,
|
||||
validators: { onChange: formSchema },
|
||||
// Persist on every field change via a form-level listener. Previously each field called
|
||||
// `form.handleSubmit()` on change, which re-validated the whole form AND toggled submit state —
|
||||
// firing the render cascade twice per keystroke. A listener persists once, without the submit churn.
|
||||
listeners: {
|
||||
onChange: ({ formApi }) => {
|
||||
persist(formApi.state.values);
|
||||
},
|
||||
},
|
||||
onSubmit: ({ value }) => {
|
||||
persist(value);
|
||||
},
|
||||
@@ -64,7 +72,6 @@ function BasicsSectionForm() {
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -88,7 +95,6 @@ function BasicsSectionForm() {
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -113,7 +119,6 @@ function BasicsSectionForm() {
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -137,7 +142,6 @@ function BasicsSectionForm() {
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -161,7 +165,6 @@ function BasicsSectionForm() {
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -182,7 +185,6 @@ function BasicsSectionForm() {
|
||||
value={field.state.value}
|
||||
onChange={(value) => {
|
||||
field.handleChange(value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
|
||||
@@ -3,13 +3,12 @@ import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function CertificationsSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.certifications;
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.certifications);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof certificationItemSchema>[]) => {
|
||||
|
||||
@@ -37,7 +37,6 @@ export const CustomFieldsSection = withForm({
|
||||
values={customFieldsField.state.value}
|
||||
onReorder={(fields) => {
|
||||
customFieldsField.setValue(fields);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
{customFieldsField.state.value.map((field: CustomField, index: number) => (
|
||||
@@ -53,7 +52,6 @@ export const CustomFieldsSection = withForm({
|
||||
className="rounded-r-none! border-e-0!"
|
||||
onChange={(icon) => {
|
||||
iconField.handleChange(icon);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -73,7 +71,6 @@ export const CustomFieldsSection = withForm({
|
||||
className="rounded-l-none!"
|
||||
onChange={(e) => {
|
||||
textField.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -109,7 +106,6 @@ export const CustomFieldsSection = withForm({
|
||||
})}
|
||||
onChange={(e) => {
|
||||
linkField.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -124,7 +120,6 @@ export const CustomFieldsSection = withForm({
|
||||
aria-label={t`Remove custom field`}
|
||||
onClick={() => {
|
||||
customFieldsField.removeValue(index);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
@@ -136,7 +131,6 @@ export const CustomFieldsSection = withForm({
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
customFieldsField.pushValue({ id: generateId(), icon: "acorn", text: "", link: "" });
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<ListPlusIcon />
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
import { stripHtml } from "@reactive-resume/utils/string";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { getSectionTitle } from "@/libs/resume/section";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
@@ -122,8 +122,7 @@ function getItemSubtitle(type: CustomSectionType, item: CustomSectionItemType):
|
||||
}
|
||||
|
||||
export function CustomSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const customSections = resume.data.customSections;
|
||||
const customSections = useCurrentBuilderResumeSelector((resume) => resume.data.customSections);
|
||||
|
||||
return (
|
||||
<SectionBase type="custom" className={cn("space-y-4", customSections.length === 0 && "border-dashed")}>
|
||||
|
||||
+9
-7
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { Resume } from "@/features/resume/builder/draft";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
@@ -75,14 +76,15 @@ type SectionItemProps = {
|
||||
};
|
||||
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useCurrentResume: () => ({
|
||||
data: {
|
||||
sections: {
|
||||
education: { title: "Education", columns: 1, hidden: false, items: educationItems },
|
||||
experience: { title: "Experience", columns: 1, hidden: false, items: experienceItems },
|
||||
useCurrentBuilderResumeSelector: (selector: (resume: Resume) => unknown) =>
|
||||
selector({
|
||||
data: {
|
||||
sections: {
|
||||
education: { title: "Education", columns: 1, hidden: false, items: educationItems },
|
||||
experience: { title: "Experience", columns: 1, hidden: false, items: experienceItems },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as unknown as Resume),
|
||||
useUpdateResumeData: () => vi.fn(),
|
||||
}));
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
|
||||
@@ -3,13 +3,12 @@ import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function EducationSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.education;
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.education);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof educationItemSchema>[]) => {
|
||||
|
||||
@@ -4,13 +4,12 @@ import { plural } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function ExperienceSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.experience;
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.experience);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof experienceItemSchema>[]) => {
|
||||
|
||||
@@ -3,13 +3,12 @@ import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function InterestsSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.interests;
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.interests);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof interestItemSchema>[]) => {
|
||||
|
||||
@@ -3,13 +3,12 @@ import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function LanguagesSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.languages;
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.languages);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof languageItemSchema>[]) => {
|
||||
|
||||
+14
-12
@@ -5,6 +5,7 @@
|
||||
// shape: render a SectionItem per data row with title/subtitle mapped to specific
|
||||
// fields, plus an "Add a new X" button. Test them together to amortize the mock setup.
|
||||
|
||||
import type { Resume } from "@/features/resume/builder/draft";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
@@ -87,19 +88,20 @@ type SectionItemProps = {
|
||||
};
|
||||
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useCurrentResume: () => ({
|
||||
data: {
|
||||
sections: {
|
||||
awards: { title: "Awards", columns: 1, hidden: false, items: sections.awards },
|
||||
certifications: { title: "Certifications", columns: 1, hidden: false, items: sections.certifications },
|
||||
interests: { title: "Interests", columns: 1, hidden: false, items: sections.interests },
|
||||
languages: { title: "Languages", columns: 1, hidden: false, items: sections.languages },
|
||||
publications: { title: "Publications", columns: 1, hidden: false, items: sections.publications },
|
||||
references: { title: "References", columns: 1, hidden: false, items: sections.references },
|
||||
volunteer: { title: "Volunteer", columns: 1, hidden: false, items: sections.volunteer },
|
||||
useCurrentBuilderResumeSelector: (selector: (resume: Resume) => unknown) =>
|
||||
selector({
|
||||
data: {
|
||||
sections: {
|
||||
awards: { title: "Awards", columns: 1, hidden: false, items: sections.awards },
|
||||
certifications: { title: "Certifications", columns: 1, hidden: false, items: sections.certifications },
|
||||
interests: { title: "Interests", columns: 1, hidden: false, items: sections.interests },
|
||||
languages: { title: "Languages", columns: 1, hidden: false, items: sections.languages },
|
||||
publications: { title: "Publications", columns: 1, hidden: false, items: sections.publications },
|
||||
references: { title: "References", columns: 1, hidden: false, items: sections.references },
|
||||
volunteer: { title: "Volunteer", columns: 1, hidden: false, items: sections.volunteer },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as unknown as Resume),
|
||||
useUpdateResumeData: () => vi.fn(),
|
||||
}));
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
import { Slider } from "@reactive-resume/ui/components/slider";
|
||||
import "react-easy-crop/react-easy-crop.css";
|
||||
import { ColorPicker } from "@/components/input/color-picker";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useSyncFormValues } from "@/hooks/use-sync-form-values";
|
||||
import { getReadableErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
@@ -447,8 +447,7 @@ function PictureSectionForm() {
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||
|
||||
const resume = useCurrentResume();
|
||||
const picture = resume.data.picture;
|
||||
const picture = useCurrentBuilderResumeSelector((resume) => resume.data.picture);
|
||||
const normalizedPictureUrl = normalizePictureUrl(picture.url, appOrigin);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { Resume } from "@/features/resume/builder/draft";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
@@ -41,11 +42,12 @@ type SectionItemProps = {
|
||||
};
|
||||
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useCurrentResume: () => ({
|
||||
data: {
|
||||
sections: { profiles: { title: "Profiles", columns: 1, hidden: false, items: sectionItems } },
|
||||
},
|
||||
}),
|
||||
useCurrentBuilderResumeSelector: (selector: (resume: Resume) => unknown) =>
|
||||
selector({
|
||||
data: {
|
||||
sections: { profiles: { title: "Profiles", columns: 1, hidden: false, items: sectionItems } },
|
||||
},
|
||||
} as unknown as Resume),
|
||||
useUpdateResumeData: () => vi.fn(),
|
||||
}));
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
|
||||
@@ -3,13 +3,12 @@ import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function ProfilesSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.profiles;
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.profiles);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof profileItemSchema>[]) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { Resume } from "@/features/resume/builder/draft";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
@@ -49,9 +50,10 @@ type SectionItemProps = {
|
||||
};
|
||||
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useCurrentResume: () => ({
|
||||
data: { sections: { projects: { title: "Projects", columns: 1, hidden: false, items } } },
|
||||
}),
|
||||
useCurrentBuilderResumeSelector: (selector: (resume: Resume) => unknown) =>
|
||||
selector({
|
||||
data: { sections: { projects: { title: "Projects", columns: 1, hidden: false, items } } },
|
||||
} as unknown as Resume),
|
||||
useUpdateResumeData: () => vi.fn(),
|
||||
}));
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
|
||||
@@ -3,13 +3,17 @@ import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
const buildSubtitle = (item: z.infer<typeof projectItemSchema>) => {
|
||||
const parts = [item.period, item.website.label].filter((part) => part && part.trim().length > 0);
|
||||
return parts.length > 0 ? parts.join(" • ") : undefined;
|
||||
};
|
||||
|
||||
export function ProjectsSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.projects;
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.projects);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof projectItemSchema>[]) => {
|
||||
@@ -18,11 +22,6 @@ export function ProjectsSectionBuilder() {
|
||||
});
|
||||
};
|
||||
|
||||
const buildSubtitle = (item: z.infer<typeof projectItemSchema>) => {
|
||||
const parts = [item.period, item.website.label].filter((part) => part && part.trim().length > 0);
|
||||
return parts.length > 0 ? parts.join(" • ") : undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="projects" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
|
||||
@@ -3,13 +3,12 @@ import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function PublicationsSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.publications;
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.publications);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof publicationItemSchema>[]) => {
|
||||
|
||||
@@ -3,13 +3,12 @@ import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function ReferencesSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.references;
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.references);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof referenceItemSchema>[]) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { Resume } from "@/features/resume/builder/draft";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
@@ -25,9 +26,10 @@ type SectionItemProps = {
|
||||
};
|
||||
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useCurrentResume: () => ({
|
||||
data: { sections: { skills: { title: "Skills", columns: 1, hidden: false, items: sectionItems } } },
|
||||
}),
|
||||
useCurrentBuilderResumeSelector: (selector: (resume: Resume) => unknown) =>
|
||||
selector({
|
||||
data: { sections: { skills: { title: "Skills", columns: 1, hidden: false, items: sectionItems } } },
|
||||
} as unknown as Resume),
|
||||
useUpdateResumeData: () => vi.fn(),
|
||||
}));
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
|
||||
@@ -3,13 +3,12 @@ import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function SkillsSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.skills;
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.skills);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof skillItemSchema>[]) => {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function SummarySectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.summary;
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.summary);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const onChange = (value: string) => {
|
||||
|
||||
@@ -3,13 +3,12 @@ import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function VolunteerSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.volunteer;
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.volunteer);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof volunteerItemSchema>[]) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@r
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { IconPicker } from "@/components/input/icon-picker";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { getSectionIcon, getSectionTitle } from "@/libs/resume/section";
|
||||
import { useSectionStore } from "../../../-store/section";
|
||||
import { SectionDropdownMenu } from "./section-menu";
|
||||
@@ -17,11 +17,13 @@ type Props = React.ComponentProps<typeof AccordionContent> & {
|
||||
};
|
||||
|
||||
export function SectionBase({ type, className, ...props }: Props) {
|
||||
const resume = useCurrentResume();
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
const data = resume.data;
|
||||
const section =
|
||||
type === "basics"
|
||||
// Subscribe to only this section's slice, not the whole resume. Otherwise editing any field
|
||||
// (which replaces the resume reference) re-renders all ~15 section wrappers on every keystroke.
|
||||
// Immer keeps untouched slices reference-stable, so Zustand bails out of the unrelated sections.
|
||||
const section = useCurrentBuilderResumeSelector((resume) => {
|
||||
const data = resume.data;
|
||||
return type === "basics"
|
||||
? data.basics
|
||||
: type === "summary"
|
||||
? data.summary
|
||||
@@ -30,6 +32,7 @@ export function SectionBase({ type, className, ...props }: Props) {
|
||||
: type === "custom"
|
||||
? data.customSections
|
||||
: data.sections[type];
|
||||
});
|
||||
|
||||
const isHidden = "hidden" in section && section.hidden;
|
||||
const hasSectionIcon = !["picture", "basics", "custom"].includes(type);
|
||||
|
||||
Reference in New Issue
Block a user