ran check:fix in orchestrator
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,11 +7,11 @@ import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
|||||||
import { CSSTransition, SwitchTransition } from "react-transition-group";
|
import { CSSTransition, SwitchTransition } from "react-transition-group";
|
||||||
|
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { OnboardingGate } from "./components/OnboardingGate";
|
||||||
import { OrchestratorPage } from "./pages/OrchestratorPage";
|
import { OrchestratorPage } from "./pages/OrchestratorPage";
|
||||||
import { SettingsPage } from "./pages/SettingsPage";
|
import { SettingsPage } from "./pages/SettingsPage";
|
||||||
import { UkVisaJobsPage } from "./pages/UkVisaJobsPage";
|
import { UkVisaJobsPage } from "./pages/UkVisaJobsPage";
|
||||||
import { VisaSponsorsPage } from "./pages/VisaSponsorsPage";
|
import { VisaSponsorsPage } from "./pages/VisaSponsorsPage";
|
||||||
import { OnboardingGate } from "./components/OnboardingGate";
|
|
||||||
|
|
||||||
export const App: React.FC = () => {
|
export const App: React.FC = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|||||||
@@ -2,40 +2,40 @@
|
|||||||
* API client for the orchestrator backend.
|
* API client for the orchestrator backend.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { trackEvent } from "@/lib/analytics";
|
||||||
import type {
|
import type {
|
||||||
Job,
|
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
JobsListResponse,
|
|
||||||
PipelineStatusResponse,
|
|
||||||
JobSource,
|
|
||||||
AppSettings,
|
AppSettings,
|
||||||
ResumeProjectsSettings,
|
|
||||||
ResumeProjectCatalogItem,
|
|
||||||
UkVisaJobsSearchResponse,
|
|
||||||
UkVisaJobsImportResponse,
|
|
||||||
CreateJobInput,
|
CreateJobInput,
|
||||||
|
Job,
|
||||||
|
JobSource,
|
||||||
|
JobsListResponse,
|
||||||
ManualJobDraft,
|
ManualJobDraft,
|
||||||
ManualJobInferenceResponse,
|
|
||||||
ManualJobFetchResponse,
|
ManualJobFetchResponse,
|
||||||
|
ManualJobInferenceResponse,
|
||||||
|
PipelineStatusResponse,
|
||||||
|
ProfileStatusResponse,
|
||||||
|
ResumeProfile,
|
||||||
|
ResumeProjectCatalogItem,
|
||||||
|
ResumeProjectsSettings,
|
||||||
|
UkVisaJobsImportResponse,
|
||||||
|
UkVisaJobsSearchResponse,
|
||||||
|
ValidationResult,
|
||||||
|
VisaSponsor,
|
||||||
VisaSponsorSearchResponse,
|
VisaSponsorSearchResponse,
|
||||||
VisaSponsorStatusResponse,
|
VisaSponsorStatusResponse,
|
||||||
VisaSponsor,
|
} from "../../shared/types";
|
||||||
ResumeProfile,
|
|
||||||
ProfileStatusResponse,
|
|
||||||
ValidationResult,
|
|
||||||
} from '../../shared/types';
|
|
||||||
import { trackEvent } from "@/lib/analytics";
|
|
||||||
|
|
||||||
const API_BASE = '/api';
|
const API_BASE = "/api";
|
||||||
|
|
||||||
async function fetchApi<T>(
|
async function fetchApi<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
options?: RequestInit
|
options?: RequestInit,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
...options?.headers,
|
...options?.headers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -47,12 +47,14 @@ async function fetchApi<T>(
|
|||||||
data = JSON.parse(text);
|
data = JSON.parse(text);
|
||||||
} catch {
|
} catch {
|
||||||
// If the response is not JSON, it's likely an HTML error page
|
// If the response is not JSON, it's likely an HTML error page
|
||||||
console.error('API returned non-JSON response:', text.substring(0, 500));
|
console.error("API returned non-JSON response:", text.substring(0, 500));
|
||||||
throw new Error(`Server error (${response.status}): Expected JSON but received HTML. Is the backend server running?`);
|
throw new Error(
|
||||||
|
`Server error (${response.status}): Expected JSON but received HTML. Is the backend server running?`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
throw new Error(data.error || 'API request failed');
|
throw new Error(data.error || "API request failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
return data.data as T;
|
return data.data as T;
|
||||||
@@ -60,7 +62,7 @@ async function fetchApi<T>(
|
|||||||
|
|
||||||
// Jobs API
|
// Jobs API
|
||||||
export async function getJobs(statuses?: string[]): Promise<JobsListResponse> {
|
export async function getJobs(statuses?: string[]): Promise<JobsListResponse> {
|
||||||
const query = statuses?.length ? `?status=${statuses.join(',')}` : '';
|
const query = statuses?.length ? `?status=${statuses.join(",")}` : "";
|
||||||
return fetchApi<JobsListResponse>(`/jobs${query}`);
|
return fetchApi<JobsListResponse>(`/jobs${query}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,61 +72,67 @@ export async function getJob(id: string): Promise<Job> {
|
|||||||
|
|
||||||
export async function updateJob(
|
export async function updateJob(
|
||||||
id: string,
|
id: string,
|
||||||
update: Partial<Job>
|
update: Partial<Job>,
|
||||||
): Promise<Job> {
|
): Promise<Job> {
|
||||||
return fetchApi<Job>(`/jobs/${id}`, {
|
return fetchApi<Job>(`/jobs/${id}`, {
|
||||||
method: 'PATCH',
|
method: "PATCH",
|
||||||
body: JSON.stringify(update),
|
body: JSON.stringify(update),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function processJob(id: string, options?: { force?: boolean }): Promise<Job> {
|
export async function processJob(
|
||||||
const query = options?.force ? '?force=1' : '';
|
id: string,
|
||||||
|
options?: { force?: boolean },
|
||||||
|
): Promise<Job> {
|
||||||
|
const query = options?.force ? "?force=1" : "";
|
||||||
return fetchApi<Job>(`/jobs/${id}/process${query}`, {
|
return fetchApi<Job>(`/jobs/${id}/process${query}`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function rescoreJob(id: string): Promise<Job> {
|
export async function rescoreJob(id: string): Promise<Job> {
|
||||||
return fetchApi<Job>(`/jobs/${id}/rescore`, {
|
return fetchApi<Job>(`/jobs/${id}/rescore`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function summarizeJob(id: string, options?: { force?: boolean }): Promise<Job> {
|
export async function summarizeJob(
|
||||||
const query = options?.force ? '?force=1' : '';
|
id: string,
|
||||||
|
options?: { force?: boolean },
|
||||||
|
): Promise<Job> {
|
||||||
|
const query = options?.force ? "?force=1" : "";
|
||||||
return fetchApi<Job>(`/jobs/${id}/summarize${query}`, {
|
return fetchApi<Job>(`/jobs/${id}/summarize${query}`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateJobPdf(id: string): Promise<Job> {
|
export async function generateJobPdf(id: string): Promise<Job> {
|
||||||
return fetchApi<Job>(`/jobs/${id}/generate-pdf`, {
|
return fetchApi<Job>(`/jobs/${id}/generate-pdf`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkSponsor(id: string): Promise<Job> {
|
export async function checkSponsor(id: string): Promise<Job> {
|
||||||
return fetchApi<Job>(`/jobs/${id}/check-sponsor`, {
|
return fetchApi<Job>(`/jobs/${id}/check-sponsor`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markAsApplied(id: string): Promise<Job> {
|
export async function markAsApplied(id: string): Promise<Job> {
|
||||||
return fetchApi<Job>(`/jobs/${id}/apply`, {
|
return fetchApi<Job>(`/jobs/${id}/apply`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function skipJob(id: string): Promise<Job> {
|
export async function skipJob(id: string): Promise<Job> {
|
||||||
return fetchApi<Job>(`/jobs/${id}/skip`, {
|
return fetchApi<Job>(`/jobs/${id}/skip`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pipeline API
|
// Pipeline API
|
||||||
export async function getPipelineStatus(): Promise<PipelineStatusResponse> {
|
export async function getPipelineStatus(): Promise<PipelineStatusResponse> {
|
||||||
return fetchApi<PipelineStatusResponse>('/pipeline/status');
|
return fetchApi<PipelineStatusResponse>("/pipeline/status");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runPipeline(config?: {
|
export async function runPipeline(config?: {
|
||||||
@@ -132,8 +140,8 @@ export async function runPipeline(config?: {
|
|||||||
minSuitabilityScore?: number;
|
minSuitabilityScore?: number;
|
||||||
sources?: JobSource[];
|
sources?: JobSource[];
|
||||||
}): Promise<{ message: string }> {
|
}): Promise<{ message: string }> {
|
||||||
return fetchApi<{ message: string }>('/pipeline/run', {
|
return fetchApi<{ message: string }>("/pipeline/run", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify(config || {}),
|
body: JSON.stringify(config || {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -144,13 +152,13 @@ export async function searchUkVisaJobs(input: {
|
|||||||
page?: number;
|
page?: number;
|
||||||
}): Promise<UkVisaJobsSearchResponse> {
|
}): Promise<UkVisaJobsSearchResponse> {
|
||||||
if (input.searchTerm?.trim()) {
|
if (input.searchTerm?.trim()) {
|
||||||
trackEvent('ukvisajobs_search', {
|
trackEvent("ukvisajobs_search", {
|
||||||
searchTerm: input.searchTerm.trim(),
|
searchTerm: input.searchTerm.trim(),
|
||||||
page: input.page ?? 1,
|
page: input.page ?? 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return fetchApi<UkVisaJobsSearchResponse>('/ukvisajobs/search', {
|
return fetchApi<UkVisaJobsSearchResponse>("/ukvisajobs/search", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -158,8 +166,8 @@ export async function searchUkVisaJobs(input: {
|
|||||||
export async function importUkVisaJobs(input: {
|
export async function importUkVisaJobs(input: {
|
||||||
jobs: CreateJobInput[];
|
jobs: CreateJobInput[];
|
||||||
}): Promise<UkVisaJobsImportResponse> {
|
}): Promise<UkVisaJobsImportResponse> {
|
||||||
return fetchApi<UkVisaJobsImportResponse>('/ukvisajobs/import', {
|
return fetchApi<UkVisaJobsImportResponse>("/ukvisajobs/import", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -168,8 +176,8 @@ export async function importUkVisaJobs(input: {
|
|||||||
export async function fetchJobFromUrl(input: {
|
export async function fetchJobFromUrl(input: {
|
||||||
url: string;
|
url: string;
|
||||||
}): Promise<ManualJobFetchResponse> {
|
}): Promise<ManualJobFetchResponse> {
|
||||||
return fetchApi<ManualJobFetchResponse>('/manual-jobs/fetch', {
|
return fetchApi<ManualJobFetchResponse>("/manual-jobs/fetch", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -177,8 +185,8 @@ export async function fetchJobFromUrl(input: {
|
|||||||
export async function inferManualJob(input: {
|
export async function inferManualJob(input: {
|
||||||
jobDescription: string;
|
jobDescription: string;
|
||||||
}): Promise<ManualJobInferenceResponse> {
|
}): Promise<ManualJobInferenceResponse> {
|
||||||
return fetchApi<ManualJobInferenceResponse>('/manual-jobs/infer', {
|
return fetchApi<ManualJobInferenceResponse>("/manual-jobs/infer", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -186,8 +194,8 @@ export async function inferManualJob(input: {
|
|||||||
export async function importManualJob(input: {
|
export async function importManualJob(input: {
|
||||||
job: ManualJobDraft;
|
job: ManualJobDraft;
|
||||||
}): Promise<Job> {
|
}): Promise<Job> {
|
||||||
return fetchApi<Job>('/manual-jobs/import', {
|
return fetchApi<Job>("/manual-jobs/import", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -198,7 +206,7 @@ let settingsPromise: Promise<AppSettings> | null = null;
|
|||||||
export async function getSettings(): Promise<AppSettings> {
|
export async function getSettings(): Promise<AppSettings> {
|
||||||
if (settingsPromise) return settingsPromise;
|
if (settingsPromise) return settingsPromise;
|
||||||
|
|
||||||
settingsPromise = fetchApi<AppSettings>('/settings').finally(() => {
|
settingsPromise = fetchApi<AppSettings>("/settings").finally(() => {
|
||||||
// Clear the promise after a short delay to allow subsequent fresh fetches
|
// Clear the promise after a short delay to allow subsequent fresh fetches
|
||||||
// but coalesce simultaneous requests.
|
// but coalesce simultaneous requests.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -209,11 +217,15 @@ export async function getSettings(): Promise<AppSettings> {
|
|||||||
return settingsPromise;
|
return settingsPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProfileProjects(): Promise<ResumeProjectCatalogItem[]> {
|
export async function getProfileProjects(): Promise<
|
||||||
return fetchApi<ResumeProjectCatalogItem[]>('/profile/projects');
|
ResumeProjectCatalogItem[]
|
||||||
|
> {
|
||||||
|
return fetchApi<ResumeProjectCatalogItem[]>("/profile/projects");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getResumeProjectsCatalog(): Promise<ResumeProjectCatalogItem[]> {
|
export async function getResumeProjectsCatalog(): Promise<
|
||||||
|
ResumeProjectCatalogItem[]
|
||||||
|
> {
|
||||||
try {
|
try {
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
if (settings.rxresumeBaseResumeId) {
|
if (settings.rxresumeBaseResumeId) {
|
||||||
@@ -227,85 +239,94 @@ export async function getResumeProjectsCatalog(): Promise<ResumeProjectCatalogIt
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getProfile(): Promise<ResumeProfile> {
|
export async function getProfile(): Promise<ResumeProfile> {
|
||||||
return fetchApi<ResumeProfile>('/profile');
|
return fetchApi<ResumeProfile>("/profile");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProfileStatus(): Promise<ProfileStatusResponse> {
|
export async function getProfileStatus(): Promise<ProfileStatusResponse> {
|
||||||
return fetchApi<ProfileStatusResponse>('/profile/status');
|
return fetchApi<ProfileStatusResponse>("/profile/status");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshProfile(): Promise<ResumeProfile> {
|
export async function refreshProfile(): Promise<ResumeProfile> {
|
||||||
return fetchApi<ResumeProfile>('/profile/refresh', {
|
return fetchApi<ResumeProfile>("/profile/refresh", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateOpenrouter(apiKey?: string): Promise<ValidationResult> {
|
export async function validateOpenrouter(
|
||||||
return fetchApi<ValidationResult>('/onboarding/validate/openrouter', {
|
apiKey?: string,
|
||||||
method: 'POST',
|
): Promise<ValidationResult> {
|
||||||
|
return fetchApi<ValidationResult>("/onboarding/validate/openrouter", {
|
||||||
|
method: "POST",
|
||||||
body: JSON.stringify({ apiKey }),
|
body: JSON.stringify({ apiKey }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateRxresume(email?: string, password?: string): Promise<ValidationResult> {
|
export async function validateRxresume(
|
||||||
return fetchApi<ValidationResult>('/onboarding/validate/rxresume', {
|
email?: string,
|
||||||
method: 'POST',
|
password?: string,
|
||||||
|
): Promise<ValidationResult> {
|
||||||
|
return fetchApi<ValidationResult>("/onboarding/validate/rxresume", {
|
||||||
|
method: "POST",
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateResumeConfig(): Promise<ValidationResult> {
|
export async function validateResumeConfig(): Promise<ValidationResult> {
|
||||||
return fetchApi<ValidationResult>('/onboarding/validate/resume');
|
return fetchApi<ValidationResult>("/onboarding/validate/resume");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSettings(update: {
|
export async function updateSettings(update: {
|
||||||
model?: string | null
|
model?: string | null;
|
||||||
modelScorer?: string | null
|
modelScorer?: string | null;
|
||||||
modelTailoring?: string | null
|
modelTailoring?: string | null;
|
||||||
modelProjectSelection?: string | null
|
modelProjectSelection?: string | null;
|
||||||
pipelineWebhookUrl?: string | null
|
pipelineWebhookUrl?: string | null;
|
||||||
jobCompleteWebhookUrl?: string | null
|
jobCompleteWebhookUrl?: string | null;
|
||||||
resumeProjects?: ResumeProjectsSettings | null
|
resumeProjects?: ResumeProjectsSettings | null;
|
||||||
ukvisajobsMaxJobs?: number | null
|
ukvisajobsMaxJobs?: number | null;
|
||||||
gradcrackerMaxJobsPerTerm?: number | null
|
gradcrackerMaxJobsPerTerm?: number | null;
|
||||||
searchTerms?: string[] | null
|
searchTerms?: string[] | null;
|
||||||
jobspyLocation?: string | null
|
jobspyLocation?: string | null;
|
||||||
jobspyResultsWanted?: number | null
|
jobspyResultsWanted?: number | null;
|
||||||
jobspyHoursOld?: number | null
|
jobspyHoursOld?: number | null;
|
||||||
jobspyCountryIndeed?: string | null
|
jobspyCountryIndeed?: string | null;
|
||||||
jobspySites?: string[] | null
|
jobspySites?: string[] | null;
|
||||||
jobspyLinkedinFetchDescription?: boolean | null
|
jobspyLinkedinFetchDescription?: boolean | null;
|
||||||
showSponsorInfo?: boolean | null
|
showSponsorInfo?: boolean | null;
|
||||||
openrouterApiKey?: string | null
|
openrouterApiKey?: string | null;
|
||||||
rxresumeEmail?: string | null
|
rxresumeEmail?: string | null;
|
||||||
rxresumePassword?: string | null
|
rxresumePassword?: string | null;
|
||||||
basicAuthUser?: string | null
|
basicAuthUser?: string | null;
|
||||||
basicAuthPassword?: string | null
|
basicAuthPassword?: string | null;
|
||||||
ukvisajobsEmail?: string | null
|
ukvisajobsEmail?: string | null;
|
||||||
ukvisajobsPassword?: string | null
|
ukvisajobsPassword?: string | null;
|
||||||
webhookSecret?: string | null
|
webhookSecret?: string | null;
|
||||||
rxresumeBaseResumeId?: string | null
|
rxresumeBaseResumeId?: string | null;
|
||||||
}): Promise<AppSettings> {
|
}): Promise<AppSettings> {
|
||||||
return fetchApi<AppSettings>('/settings', {
|
return fetchApi<AppSettings>("/settings", {
|
||||||
method: 'PATCH',
|
method: "PATCH",
|
||||||
body: JSON.stringify(update),
|
body: JSON.stringify(update),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRxResumes(): Promise<{ id: string; name: string }[]> {
|
export async function getRxResumes(): Promise<{ id: string; name: string }[]> {
|
||||||
const data = await fetchApi<{ resumes: { id: string; name: string }[] }>('/settings/rx-resumes');
|
const data = await fetchApi<{ resumes: { id: string; name: string }[] }>(
|
||||||
|
"/settings/rx-resumes",
|
||||||
|
);
|
||||||
return data.resumes;
|
return data.resumes;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRxResumeProjects(resumeId: string, signal?: AbortSignal): Promise<ResumeProjectCatalogItem[]> {
|
export async function getRxResumeProjects(
|
||||||
|
resumeId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ResumeProjectCatalogItem[]> {
|
||||||
const data = await fetchApi<{ projects: ResumeProjectCatalogItem[] }>(
|
const data = await fetchApi<{ projects: ResumeProjectCatalogItem[] }>(
|
||||||
`/settings/rx-resumes/${encodeURIComponent(resumeId)}/projects`,
|
`/settings/rx-resumes/${encodeURIComponent(resumeId)}/projects`,
|
||||||
{ signal }
|
{ signal },
|
||||||
);
|
);
|
||||||
return data.projects;
|
return data.projects;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Database API
|
// Database API
|
||||||
export async function clearDatabase(): Promise<{
|
export async function clearDatabase(): Promise<{
|
||||||
message: string;
|
message: string;
|
||||||
@@ -316,8 +337,8 @@ export async function clearDatabase(): Promise<{
|
|||||||
message: string;
|
message: string;
|
||||||
jobsDeleted: number;
|
jobsDeleted: number;
|
||||||
runsDeleted: number;
|
runsDeleted: number;
|
||||||
}>('/database', {
|
}>("/database", {
|
||||||
method: 'DELETE',
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,13 +350,13 @@ export async function deleteJobsByStatus(status: string): Promise<{
|
|||||||
message: string;
|
message: string;
|
||||||
count: number;
|
count: number;
|
||||||
}>(`/jobs/status/${status}`, {
|
}>(`/jobs/status/${status}`, {
|
||||||
method: 'DELETE',
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Visa Sponsors API
|
// Visa Sponsors API
|
||||||
export async function getVisaSponsorStatus(): Promise<VisaSponsorStatusResponse> {
|
export async function getVisaSponsorStatus(): Promise<VisaSponsorStatusResponse> {
|
||||||
return fetchApi<VisaSponsorStatusResponse>('/visa-sponsors/status');
|
return fetchApi<VisaSponsorStatusResponse>("/visa-sponsors/status");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchVisaSponsors(input: {
|
export async function searchVisaSponsors(input: {
|
||||||
@@ -344,20 +365,24 @@ export async function searchVisaSponsors(input: {
|
|||||||
minScore?: number;
|
minScore?: number;
|
||||||
}): Promise<VisaSponsorSearchResponse> {
|
}): Promise<VisaSponsorSearchResponse> {
|
||||||
if (input.query?.trim()) {
|
if (input.query?.trim()) {
|
||||||
trackEvent('visa_sponsor_search', {
|
trackEvent("visa_sponsor_search", {
|
||||||
query: input.query.trim(),
|
query: input.query.trim(),
|
||||||
limit: input.limit,
|
limit: input.limit,
|
||||||
minScore: input.minScore,
|
minScore: input.minScore,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return fetchApi<VisaSponsorSearchResponse>('/visa-sponsors/search', {
|
return fetchApi<VisaSponsorSearchResponse>("/visa-sponsors/search", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getVisaSponsorOrganization(name: string): Promise<VisaSponsor[]> {
|
export async function getVisaSponsorOrganization(
|
||||||
return fetchApi<VisaSponsor[]>(`/visa-sponsors/organization/${encodeURIComponent(name)}`);
|
name: string,
|
||||||
|
): Promise<VisaSponsor[]> {
|
||||||
|
return fetchApi<VisaSponsor[]>(
|
||||||
|
`/visa-sponsors/organization/${encodeURIComponent(name)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateVisaSponsorList(): Promise<{
|
export async function updateVisaSponsorList(): Promise<{
|
||||||
@@ -367,8 +392,8 @@ export async function updateVisaSponsorList(): Promise<{
|
|||||||
return fetchApi<{
|
return fetchApi<{
|
||||||
message: string;
|
message: string;
|
||||||
status: VisaSponsorStatusResponse;
|
status: VisaSponsorStatusResponse;
|
||||||
}>('/visa-sponsors/update', {
|
}>("/visa-sponsors/update", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export * from './client';
|
export * from "./client";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
|
||||||
import { Sparkles } from "lucide-react";
|
import { Sparkles } from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Job } from "../../shared/types";
|
import type { Job } from "../../shared/types";
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
* Header component with logo and pipeline trigger.
|
* Header component with logo and pipeline trigger.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import {
|
import {
|
||||||
Briefcase,
|
Briefcase,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -14,6 +13,7 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import React from "react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -56,7 +56,12 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [sheetOpen, setSheetOpen] = React.useState(false);
|
const [sheetOpen, setSheetOpen] = React.useState(false);
|
||||||
|
|
||||||
const orderedSources: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"];
|
const orderedSources: JobSource[] = [
|
||||||
|
"gradcracker",
|
||||||
|
"indeed",
|
||||||
|
"linkedin",
|
||||||
|
"ukvisajobs",
|
||||||
|
];
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ to: "/", label: "Dashboard", icon: Home },
|
{ to: "/", label: "Dashboard", icon: Home },
|
||||||
@@ -75,23 +80,21 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className='sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60'>
|
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<div className='container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4'>
|
<div className="container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4">
|
||||||
<div className='flex items-center gap-3'>
|
<div className="flex items-center gap-3">
|
||||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Button variant='ghost' size='icon'>
|
<Button variant="ghost" size="icon">
|
||||||
<Menu className='h-5 w-5' />
|
<Menu className="h-5 w-5" />
|
||||||
<span className='sr-only'>Open navigation menu</span>
|
<span className="sr-only">Open navigation menu</span>
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent side='left' className='w-64'>
|
<SheetContent side="left" className="w-64">
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>
|
<SheetTitle>JobOps</SheetTitle>
|
||||||
JobOps
|
|
||||||
</SheetTitle>
|
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<nav className='mt-6 flex flex-col gap-2'>
|
<nav className="mt-6 flex flex-col gap-2">
|
||||||
{navLinks.map(({ to, label, icon: Icon }) => (
|
{navLinks.map(({ to, label, icon: Icon }) => (
|
||||||
<Link
|
<Link
|
||||||
key={to}
|
key={to}
|
||||||
@@ -103,7 +106,7 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
: "text-muted-foreground"
|
: "text-muted-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className='h-4 w-4' />
|
<Icon className="h-4 w-4" />
|
||||||
{label}
|
{label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
@@ -112,49 +115,51 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
to='/'
|
to="/"
|
||||||
className='flex items-center gap-3 hover:opacity-80 transition-opacity'
|
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
|
||||||
>
|
>
|
||||||
<div className='flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg bg-transparent shadow-sm'>
|
<div className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg bg-transparent shadow-sm">
|
||||||
<img
|
<img
|
||||||
src='/favicon.png'
|
src="/favicon.png"
|
||||||
alt='Job Ops Logo'
|
alt="Job Ops Logo"
|
||||||
className='h-full w-full object-contain'
|
className="h-full w-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='leading-tight'>
|
<div className="leading-tight">
|
||||||
<div className='text-sm font-semibold tracking-tight'>Job Ops</div>
|
<div className="text-sm font-semibold tracking-tight">
|
||||||
<div className='text-xs text-muted-foreground'>Orchestrator</div>
|
Job Ops
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Orchestrator</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex flex-wrap items-center gap-1.5'>
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant="outline"
|
||||||
size='sm'
|
size="sm"
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
<RefreshCcw className='h-4 w-4' />
|
<RefreshCcw className="h-4 w-4" />
|
||||||
<span className='hidden sm:inline'>Refresh</span>
|
<span className="hidden sm:inline">Refresh</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
size='sm'
|
size="sm"
|
||||||
onClick={onRunPipeline}
|
onClick={onRunPipeline}
|
||||||
disabled={isPipelineRunning}
|
disabled={isPipelineRunning}
|
||||||
className='rounded-r-none'
|
className="rounded-r-none"
|
||||||
>
|
>
|
||||||
{isPipelineRunning ? (
|
{isPipelineRunning ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className='h-4 w-4 animate-spin' />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Running...
|
Running...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Play className='h-4 w-4' />
|
<Play className="h-4 w-4" />
|
||||||
Run Pipeline
|
Run Pipeline
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -163,18 +168,15 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
size='sm'
|
size="sm"
|
||||||
disabled={isPipelineRunning}
|
disabled={isPipelineRunning}
|
||||||
className='rounded-l-none border-l border-primary-foreground/20'
|
className="rounded-l-none border-l border-primary-foreground/20"
|
||||||
aria-label='Select pipeline sources'
|
aria-label="Select pipeline sources"
|
||||||
>
|
>
|
||||||
<ChevronDown className='h-4 w-4' />
|
<ChevronDown className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
align='end'
|
|
||||||
className='w-56'
|
|
||||||
>
|
|
||||||
<DropdownMenuLabel>Sources</DropdownMenuLabel>
|
<DropdownMenuLabel>Sources</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{orderedSources.map((source) => (
|
{orderedSources.map((source) => (
|
||||||
|
|||||||
@@ -1,104 +1,122 @@
|
|||||||
import React from "react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import type React from "react";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { JobHeader } from "./JobHeader";
|
|
||||||
import { useSettings } from "../hooks/useSettings";
|
|
||||||
import type { Job } from "../../shared/types";
|
import type { Job } from "../../shared/types";
|
||||||
|
import { useSettings } from "../hooks/useSettings";
|
||||||
|
import { JobHeader } from "./JobHeader";
|
||||||
|
|
||||||
// Mock useSettings
|
// Mock useSettings
|
||||||
vi.mock("../hooks/useSettings", () => ({
|
vi.mock("../hooks/useSettings", () => ({
|
||||||
useSettings: vi.fn(),
|
useSettings: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock api
|
// Mock api
|
||||||
vi.mock("../api", () => ({
|
vi.mock("../api", () => ({
|
||||||
checkSponsor: vi.fn(),
|
checkSponsor: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock Tooltip components to simplify testing
|
// Mock Tooltip components to simplify testing
|
||||||
vi.mock("@/components/ui/tooltip", () => ({
|
vi.mock("@/components/ui/tooltip", () => ({
|
||||||
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
TooltipProvider: ({ children }: { children: React.ReactNode }) => (
|
||||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
<>{children}</>
|
||||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
),
|
||||||
TooltipContent: ({ children }: { children: React.ReactNode }) => (
|
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
<div data-testid="tooltip-content">{children}</div>
|
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||||
),
|
<>{children}</>
|
||||||
|
),
|
||||||
|
TooltipContent: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div data-testid="tooltip-content">{children}</div>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockJob: Job = {
|
const mockJob: Job = {
|
||||||
id: "job-1",
|
id: "job-1",
|
||||||
title: "Software Engineer",
|
title: "Software Engineer",
|
||||||
employer: "Tech Corp",
|
employer: "Tech Corp",
|
||||||
location: "London",
|
location: "London",
|
||||||
salary: "£60,000",
|
salary: "£60,000",
|
||||||
deadline: "2025-12-31",
|
deadline: "2025-12-31",
|
||||||
status: "discovered",
|
status: "discovered",
|
||||||
source: "linkedin",
|
source: "linkedin",
|
||||||
suitabilityScore: 85,
|
suitabilityScore: 85,
|
||||||
suitabilityReason: "Strong match",
|
suitabilityReason: "Strong match",
|
||||||
sponsorMatchScore: null,
|
sponsorMatchScore: null,
|
||||||
sponsorMatchNames: null,
|
sponsorMatchNames: null,
|
||||||
// Other fields...
|
// Other fields...
|
||||||
} as Job;
|
} as Job;
|
||||||
|
|
||||||
describe("JobHeader", () => {
|
describe("JobHeader", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
(useSettings as any).mockReturnValue({
|
(useSettings as any).mockReturnValue({
|
||||||
showSponsorInfo: true,
|
showSponsorInfo: true,
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders basic job information", () => {
|
||||||
|
render(<JobHeader job={mockJob} />);
|
||||||
|
expect(screen.getByText("Software Engineer")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Tech Corp")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("London")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("£60,000")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'Check Sponsorship Status' button when sponsorMatchScore is null", async () => {
|
||||||
|
const onCheckSponsor = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(<JobHeader job={mockJob} onCheckSponsor={onCheckSponsor} />);
|
||||||
|
|
||||||
|
const button = screen.getByText("Check Sponsorship Status");
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(onCheckSponsor).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'Confirmed Sponsor' when score >= 95", () => {
|
||||||
|
const jobWithSponsor = {
|
||||||
|
...mockJob,
|
||||||
|
sponsorMatchScore: 98,
|
||||||
|
sponsorMatchNames: '["Tech Corp Ltd"]',
|
||||||
|
};
|
||||||
|
render(<JobHeader job={jobWithSponsor} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Confirmed Sponsor")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'Potential Sponsor' when score is between 80 and 94", () => {
|
||||||
|
const jobWithPotential = {
|
||||||
|
...mockJob,
|
||||||
|
sponsorMatchScore: 85,
|
||||||
|
sponsorMatchNames: '["Techy Corp"]',
|
||||||
|
};
|
||||||
|
render(<JobHeader job={jobWithPotential} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Potential Sponsor")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'Sponsor Not Found' when score < 80", () => {
|
||||||
|
const jobNoSponsor = {
|
||||||
|
...mockJob,
|
||||||
|
sponsorMatchScore: 40,
|
||||||
|
sponsorMatchNames: '["Other Corp"]',
|
||||||
|
};
|
||||||
|
render(<JobHeader job={jobNoSponsor} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Sponsor Not Found")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides sponsor info when showSponsorInfo is false", () => {
|
||||||
|
(useSettings as any).mockReturnValue({
|
||||||
|
showSponsorInfo: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders basic job information", () => {
|
const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98 };
|
||||||
render(<JobHeader job={mockJob} />);
|
render(<JobHeader job={jobWithSponsor} />);
|
||||||
expect(screen.getByText("Software Engineer")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Tech Corp")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("London")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("£60,000")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows 'Check Sponsorship Status' button when sponsorMatchScore is null", async () => {
|
expect(screen.queryByText("Confirmed Sponsor")).not.toBeInTheDocument();
|
||||||
const onCheckSponsor = vi.fn().mockResolvedValue(undefined);
|
expect(
|
||||||
render(<JobHeader job={mockJob} onCheckSponsor={onCheckSponsor} />);
|
screen.queryByText("Check Sponsorship Status"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
const button = screen.getByText("Check Sponsorship Status");
|
});
|
||||||
expect(button).toBeInTheDocument();
|
|
||||||
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
expect(onCheckSponsor).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows 'Confirmed Sponsor' when score >= 95", () => {
|
|
||||||
const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98, sponsorMatchNames: '["Tech Corp Ltd"]' };
|
|
||||||
render(<JobHeader job={jobWithSponsor} />);
|
|
||||||
|
|
||||||
expect(screen.getByText("Confirmed Sponsor")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows 'Potential Sponsor' when score is between 80 and 94", () => {
|
|
||||||
const jobWithPotential = { ...mockJob, sponsorMatchScore: 85, sponsorMatchNames: '["Techy Corp"]' };
|
|
||||||
render(<JobHeader job={jobWithPotential} />);
|
|
||||||
|
|
||||||
expect(screen.getByText("Potential Sponsor")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows 'Sponsor Not Found' when score < 80", () => {
|
|
||||||
const jobNoSponsor = { ...mockJob, sponsorMatchScore: 40, sponsorMatchNames: '["Other Corp"]' };
|
|
||||||
render(<JobHeader job={jobNoSponsor} />);
|
|
||||||
|
|
||||||
expect(screen.getByText("Sponsor Not Found")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides sponsor info when showSponsorInfo is false", () => {
|
|
||||||
(useSettings as any).mockReturnValue({
|
|
||||||
showSponsorInfo: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98 };
|
|
||||||
render(<JobHeader job={jobWithSponsor} />);
|
|
||||||
|
|
||||||
expect(screen.queryByText("Confirmed Sponsor")).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("Check Sponsorship Status")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import React, { useMemo, useState } from "react";
|
|
||||||
import { Calendar, DollarSign, Loader2, MapPin, Search } from "lucide-react";
|
import { Calendar, DollarSign, Loader2, MapPin, Search } from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import { cn, formatDate, sourceLabel } from "@/lib/utils";
|
import { cn, formatDate, sourceLabel } from "@/lib/utils";
|
||||||
import type { Job, JobStatus } from "../../shared/types";
|
import type { Job, JobStatus } from "../../shared/types";
|
||||||
import { defaultStatusToken, statusTokens } from "../pages/orchestrator/constants";
|
|
||||||
|
|
||||||
import { useSettings } from "../hooks/useSettings";
|
import { useSettings } from "../hooks/useSettings";
|
||||||
|
import {
|
||||||
|
defaultStatusToken,
|
||||||
|
statusTokens,
|
||||||
|
} from "../pages/orchestrator/constants";
|
||||||
|
|
||||||
interface JobHeaderProps {
|
interface JobHeaderProps {
|
||||||
job: Job;
|
job: Job;
|
||||||
@@ -93,7 +101,9 @@ const SponsorPill: React.FC<SponsorPillProps> = ({ score, names, onCheck }) => {
|
|||||||
) : (
|
) : (
|
||||||
<Search className="h-2 w-2" />
|
<Search className="h-2 w-2" />
|
||||||
)}
|
)}
|
||||||
<span>{isChecking ? "Checking..." : "Check Sponsorship Status"}</span>
|
<span>
|
||||||
|
{isChecking ? "Checking..." : "Check Sponsorship Status"}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
@@ -109,9 +119,23 @@ const SponsorPill: React.FC<SponsorPillProps> = ({ score, names, onCheck }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getStatus = (s: number) => {
|
const getStatus = (s: number) => {
|
||||||
if (s >= 95) return { label: "Confirmed Sponsor", dot: "bg-emerald-500", color: "text-emerald-400" };
|
if (s >= 95)
|
||||||
if (s >= 80) return { label: "Potential Sponsor", dot: "bg-amber-500", color: "text-amber-400" };
|
return {
|
||||||
return { label: "Sponsor Not Found", dot: "bg-slate-500", color: "text-slate-400" };
|
label: "Confirmed Sponsor",
|
||||||
|
dot: "bg-emerald-500",
|
||||||
|
color: "text-emerald-400",
|
||||||
|
};
|
||||||
|
if (s >= 80)
|
||||||
|
return {
|
||||||
|
label: "Potential Sponsor",
|
||||||
|
dot: "bg-amber-500",
|
||||||
|
color: "text-amber-400",
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
label: "Sponsor Not Found",
|
||||||
|
dot: "bg-slate-500",
|
||||||
|
color: "text-slate-400",
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const status = getStatus(score);
|
const status = getStatus(score);
|
||||||
@@ -122,7 +146,9 @@ const SponsorPill: React.FC<SponsorPillProps> = ({ score, names, onCheck }) => {
|
|||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/80 cursor-help">
|
<span className="inline-flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/80 cursor-help">
|
||||||
<span className={cn("h-1.5 w-1.5 rounded-full opacity-80", status.dot)} />
|
<span
|
||||||
|
className={cn("h-1.5 w-1.5 rounded-full opacity-80", status.dot)}
|
||||||
|
/>
|
||||||
{status.label}
|
{status.label}
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -140,7 +166,11 @@ const SponsorPill: React.FC<SponsorPillProps> = ({ score, names, onCheck }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const JobHeader: React.FC<JobHeaderProps> = ({ job, className, onCheckSponsor }) => {
|
export const JobHeader: React.FC<JobHeaderProps> = ({
|
||||||
|
job,
|
||||||
|
className,
|
||||||
|
onCheckSponsor,
|
||||||
|
}) => {
|
||||||
const { showSponsorInfo } = useSettings();
|
const { showSponsorInfo } = useSettings();
|
||||||
const deadline = formatDate(job.deadline);
|
const deadline = formatDate(job.deadline);
|
||||||
|
|
||||||
@@ -149,12 +179,17 @@ export const JobHeader: React.FC<JobHeaderProps> = ({ job, className, onCheckSpo
|
|||||||
{/* Detail header: lighter weight than list items */}
|
{/* Detail header: lighter weight than list items */}
|
||||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-base font-semibold text-foreground/90">{job.title}</div>
|
<div className="truncate text-base font-semibold text-foreground/90">
|
||||||
|
{job.title}
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span>{job.employer}</span>
|
<span>{job.employer}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50">
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] uppercase tracking-wide text-muted-foreground border-border/50"
|
||||||
|
>
|
||||||
{sourceLabel[job.source]}
|
{sourceLabel[job.source]}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
||||||
|
|
||||||
import { ManualImportSheet } from "./ManualImportSheet";
|
|
||||||
import * as api from "../api";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import * as api from "../api";
|
||||||
|
import { ManualImportSheet } from "./ManualImportSheet";
|
||||||
|
|
||||||
vi.mock("../api", () => ({
|
vi.mock("../api", () => ({
|
||||||
fetchJobFromUrl: vi.fn(),
|
fetchJobFromUrl: vi.fn(),
|
||||||
@@ -38,19 +37,29 @@ describe("ManualImportSheet", () => {
|
|||||||
vi.mocked(api.importManualJob).mockResolvedValue({ id: "job-1" } as any);
|
vi.mocked(api.importManualJob).mockResolvedValue({ id: "job-1" } as any);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<ManualImportSheet open onOpenChange={onOpenChange} onImported={onImported} />
|
<ManualImportSheet
|
||||||
|
open
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onImported={onImported}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.change(
|
fireEvent.change(
|
||||||
screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it..."),
|
screen.getByPlaceholderText(
|
||||||
{ target: { value: rawDescription } }
|
"Paste the full job description here, or enter a URL above to fetch it...",
|
||||||
|
),
|
||||||
|
{ target: { value: rawDescription } },
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: /analyze jd/i }));
|
fireEvent.click(screen.getByRole("button", { name: /analyze jd/i }));
|
||||||
|
|
||||||
const titleInput = await screen.findByPlaceholderText("e.g. Junior Backend Engineer");
|
const titleInput = await screen.findByPlaceholderText(
|
||||||
|
"e.g. Junior Backend Engineer",
|
||||||
|
);
|
||||||
expect(titleInput).toHaveValue("Backend Engineer");
|
expect(titleInput).toHaveValue("Backend Engineer");
|
||||||
|
|
||||||
const jdTextarea = screen.getByPlaceholderText("Paste the job description...") as HTMLTextAreaElement;
|
const jdTextarea = screen.getByPlaceholderText(
|
||||||
|
"Paste the job description...",
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
expect(jdTextarea.value).toBe(rawDescription.trim());
|
expect(jdTextarea.value).toBe(rawDescription.trim());
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("e.g. GBP 45k-55k"), {
|
fireEvent.change(screen.getByPlaceholderText("e.g. GBP 45k-55k"), {
|
||||||
@@ -76,7 +85,7 @@ describe("ManualImportSheet", () => {
|
|||||||
"Job imported",
|
"Job imported",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
description: expect.any(String),
|
description: expect.any(String),
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,12 +98,14 @@ describe("ManualImportSheet", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />
|
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.change(
|
fireEvent.change(
|
||||||
screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it..."),
|
screen.getByPlaceholderText(
|
||||||
{ target: { value: rawDescription } }
|
"Paste the full job description here, or enter a URL above to fetch it...",
|
||||||
|
),
|
||||||
|
{ target: { value: rawDescription } },
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: /analyze jd/i }));
|
fireEvent.click(screen.getByRole("button", { name: /analyze jd/i }));
|
||||||
|
|
||||||
@@ -103,9 +114,12 @@ describe("ManualImportSheet", () => {
|
|||||||
const importButton = screen.getByRole("button", { name: /import job/i });
|
const importButton = screen.getByRole("button", { name: /import job/i });
|
||||||
expect(importButton).toBeDisabled();
|
expect(importButton).toBeDisabled();
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("e.g. Junior Backend Engineer"), {
|
fireEvent.change(
|
||||||
target: { value: "QA Engineer" },
|
screen.getByPlaceholderText("e.g. Junior Backend Engineer"),
|
||||||
});
|
{
|
||||||
|
target: { value: "QA Engineer" },
|
||||||
|
},
|
||||||
|
);
|
||||||
fireEvent.change(screen.getByPlaceholderText("e.g. Acme Labs"), {
|
fireEvent.change(screen.getByPlaceholderText("e.g. Acme Labs"), {
|
||||||
target: { value: "Acme Labs" },
|
target: { value: "Acme Labs" },
|
||||||
});
|
});
|
||||||
@@ -116,22 +130,28 @@ describe("ManualImportSheet", () => {
|
|||||||
it("returns to the paste step when inference fails", async () => {
|
it("returns to the paste step when inference fails", async () => {
|
||||||
const rawDescription = "Backend role description.";
|
const rawDescription = "Backend role description.";
|
||||||
|
|
||||||
vi.mocked(api.inferManualJob).mockRejectedValue(new Error("Inference failed"));
|
vi.mocked(api.inferManualJob).mockRejectedValue(
|
||||||
|
new Error("Inference failed"),
|
||||||
|
);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />
|
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.change(
|
fireEvent.change(
|
||||||
screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it..."),
|
screen.getByPlaceholderText(
|
||||||
{ target: { value: rawDescription } }
|
"Paste the full job description here, or enter a URL above to fetch it...",
|
||||||
|
),
|
||||||
|
{ target: { value: rawDescription } },
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: /analyze jd/i }));
|
fireEvent.click(screen.getByRole("button", { name: /analyze jd/i }));
|
||||||
|
|
||||||
await screen.findByText("Inference failed");
|
await screen.findByText("Inference failed");
|
||||||
expect(screen.getByRole("button", { name: /analyze jd/i })).toBeInTheDocument();
|
|
||||||
expect(
|
expect(
|
||||||
screen.queryByPlaceholderText("e.g. Junior Backend Engineer")
|
screen.getByRole("button", { name: /analyze jd/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByPlaceholderText("e.g. Junior Backend Engineer"),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,17 +162,25 @@ describe("ManualImportSheet", () => {
|
|||||||
employer: "Acme Labs",
|
employer: "Acme Labs",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
vi.mocked(api.importManualJob).mockRejectedValue(new Error("Import failed"));
|
vi.mocked(api.importManualJob).mockRejectedValue(
|
||||||
|
new Error("Import failed"),
|
||||||
|
);
|
||||||
|
|
||||||
const onOpenChange = vi.fn();
|
const onOpenChange = vi.fn();
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<ManualImportSheet open onOpenChange={onOpenChange} onImported={vi.fn()} />
|
<ManualImportSheet
|
||||||
|
open
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onImported={vi.fn()}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.change(
|
fireEvent.change(
|
||||||
screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it..."),
|
screen.getByPlaceholderText(
|
||||||
{ target: { value: "Backend Engineer role." } }
|
"Paste the full job description here, or enter a URL above to fetch it...",
|
||||||
|
),
|
||||||
|
{ target: { value: "Backend Engineer role." } },
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: /analyze jd/i }));
|
fireEvent.click(screen.getByRole("button", { name: /analyze jd/i }));
|
||||||
|
|
||||||
@@ -161,7 +189,7 @@ describe("ManualImportSheet", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: /import job/i }));
|
fireEvent.click(screen.getByRole("button", { name: /import job/i }));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(toast.error).toHaveBeenCalledWith("Import failed")
|
expect(toast.error).toHaveBeenCalledWith("Import failed"),
|
||||||
);
|
);
|
||||||
expect(onOpenChange).not.toHaveBeenCalled();
|
expect(onOpenChange).not.toHaveBeenCalled();
|
||||||
expect(screen.getByRole("button", { name: /import job/i })).toBeEnabled();
|
expect(screen.getByRole("button", { name: /import job/i })).toBeEnabled();
|
||||||
@@ -170,20 +198,24 @@ describe("ManualImportSheet", () => {
|
|||||||
describe("URL fetch functionality", () => {
|
describe("URL fetch functionality", () => {
|
||||||
it("shows Paste button when URL field is empty, Fetch when URL is entered", async () => {
|
it("shows Paste button when URL field is empty, Fetch when URL is entered", async () => {
|
||||||
render(
|
render(
|
||||||
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />
|
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initially should show Paste button
|
// Initially should show Paste button
|
||||||
expect(screen.getByRole("button", { name: /paste/i })).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByRole("button", { name: /paste/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
// Enter a URL
|
// Enter a URL
|
||||||
fireEvent.change(
|
fireEvent.change(
|
||||||
screen.getByPlaceholderText("https://example.com/job-posting"),
|
screen.getByPlaceholderText("https://example.com/job-posting"),
|
||||||
{ target: { value: "https://example.com/job" } }
|
{ target: { value: "https://example.com/job" } },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Should now show Fetch button
|
// Should now show Fetch button
|
||||||
expect(screen.getByRole("button", { name: /fetch/i })).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByRole("button", { name: /fetch/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetches URL and proceeds to review on successful fetch", async () => {
|
it("fetches URL and proceeds to review on successful fetch", async () => {
|
||||||
@@ -201,13 +233,13 @@ describe("ManualImportSheet", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />
|
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Enter a URL
|
// Enter a URL
|
||||||
fireEvent.change(
|
fireEvent.change(
|
||||||
screen.getByPlaceholderText("https://example.com/job-posting"),
|
screen.getByPlaceholderText("https://example.com/job-posting"),
|
||||||
{ target: { value: "https://example.com/job" } }
|
{ target: { value: "https://example.com/job" } },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Click Fetch
|
// Click Fetch
|
||||||
@@ -224,8 +256,12 @@ describe("ManualImportSheet", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Check inferred values are shown
|
// Check inferred values are shown
|
||||||
expect(screen.getByPlaceholderText("e.g. Junior Backend Engineer")).toHaveValue("Software Engineer");
|
expect(
|
||||||
expect(screen.getByPlaceholderText("e.g. Acme Labs")).toHaveValue("Acme Corp");
|
screen.getByPlaceholderText("e.g. Junior Backend Engineer"),
|
||||||
|
).toHaveValue("Software Engineer");
|
||||||
|
expect(screen.getByPlaceholderText("e.g. Acme Labs")).toHaveValue(
|
||||||
|
"Acme Corp",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves fetched URL in the job URL field", async () => {
|
it("preserves fetched URL in the job URL field", async () => {
|
||||||
@@ -241,12 +277,12 @@ describe("ManualImportSheet", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />
|
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.change(
|
fireEvent.change(
|
||||||
screen.getByPlaceholderText("https://example.com/job-posting"),
|
screen.getByPlaceholderText("https://example.com/job-posting"),
|
||||||
{ target: { value: "https://example.com/job" } }
|
{ target: { value: "https://example.com/job" } },
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: /fetch/i }));
|
fireEvent.click(screen.getByRole("button", { name: /fetch/i }));
|
||||||
|
|
||||||
@@ -258,22 +294,28 @@ describe("ManualImportSheet", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("shows error and returns to paste step when fetch fails", async () => {
|
it("shows error and returns to paste step when fetch fails", async () => {
|
||||||
vi.mocked(api.fetchJobFromUrl).mockRejectedValue(new Error("Failed to fetch URL"));
|
vi.mocked(api.fetchJobFromUrl).mockRejectedValue(
|
||||||
|
new Error("Failed to fetch URL"),
|
||||||
|
);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />
|
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.change(
|
fireEvent.change(
|
||||||
screen.getByPlaceholderText("https://example.com/job-posting"),
|
screen.getByPlaceholderText("https://example.com/job-posting"),
|
||||||
{ target: { value: "https://example.com/bad-url" } }
|
{ target: { value: "https://example.com/bad-url" } },
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: /fetch/i }));
|
fireEvent.click(screen.getByRole("button", { name: /fetch/i }));
|
||||||
|
|
||||||
await screen.findByText("Failed to fetch URL");
|
await screen.findByText("Failed to fetch URL");
|
||||||
|
|
||||||
// Should still be on paste step
|
// Should still be on paste step
|
||||||
expect(screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it...")).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByPlaceholderText(
|
||||||
|
"Paste the full job description here, or enter a URL above to fetch it...",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows error when inference fails after fetch", async () => {
|
it("shows error when inference fails after fetch", async () => {
|
||||||
@@ -281,22 +323,28 @@ describe("ManualImportSheet", () => {
|
|||||||
content: "Job content",
|
content: "Job content",
|
||||||
url: "https://example.com/job",
|
url: "https://example.com/job",
|
||||||
});
|
});
|
||||||
vi.mocked(api.inferManualJob).mockRejectedValue(new Error("Inference failed"));
|
vi.mocked(api.inferManualJob).mockRejectedValue(
|
||||||
|
new Error("Inference failed"),
|
||||||
|
);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />
|
<ManualImportSheet open onOpenChange={vi.fn()} onImported={vi.fn()} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.change(
|
fireEvent.change(
|
||||||
screen.getByPlaceholderText("https://example.com/job-posting"),
|
screen.getByPlaceholderText("https://example.com/job-posting"),
|
||||||
{ target: { value: "https://example.com/job" } }
|
{ target: { value: "https://example.com/job" } },
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: /fetch/i }));
|
fireEvent.click(screen.getByRole("button", { name: /fetch/i }));
|
||||||
|
|
||||||
await screen.findByText("Inference failed");
|
await screen.findByText("Inference failed");
|
||||||
|
|
||||||
// Should be back on paste step
|
// Should be back on paste step
|
||||||
expect(screen.getByPlaceholderText("Paste the full job description here, or enter a URL above to fetch it...")).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByPlaceholderText(
|
||||||
|
"Paste the full job description here, or enter a URL above to fetch it...",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,8 +2,16 @@
|
|||||||
* Manual job import flow (paste JD -> infer -> review -> import).
|
* Manual job import flow (paste JD -> infer -> review -> import).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import {
|
||||||
import { ArrowLeft, ClipboardPaste, FileText, Link, Loader2, Sparkles } from "lucide-react";
|
ArrowLeft,
|
||||||
|
ClipboardPaste,
|
||||||
|
FileText,
|
||||||
|
Link,
|
||||||
|
Loader2,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -18,8 +26,8 @@ import {
|
|||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import * as api from "../api";
|
|
||||||
import type { ManualJobDraft } from "../../shared/types";
|
import type { ManualJobDraft } from "../../shared/types";
|
||||||
|
import * as api from "../api";
|
||||||
|
|
||||||
type ManualImportStep = "paste" | "loading" | "review";
|
type ManualImportStep = "paste" | "loading" | "review";
|
||||||
|
|
||||||
@@ -57,7 +65,10 @@ const emptyDraft: ManualJobDraftState = {
|
|||||||
starting: "",
|
starting: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeDraft = (draft?: ManualJobDraft | null, jd?: string): ManualJobDraftState => ({
|
const normalizeDraft = (
|
||||||
|
draft?: ManualJobDraft | null,
|
||||||
|
jd?: string,
|
||||||
|
): ManualJobDraftState => ({
|
||||||
...emptyDraft,
|
...emptyDraft,
|
||||||
title: draft?.title ?? "",
|
title: draft?.title ?? "",
|
||||||
employer: draft?.employer ?? "",
|
employer: draft?.employer ?? "",
|
||||||
@@ -136,7 +147,8 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
|
|||||||
const stepLabel = ["Paste JD", "Infer details", "Review & import"][stepIndex];
|
const stepLabel = ["Paste JD", "Infer details", "Review & import"][stepIndex];
|
||||||
|
|
||||||
const canAnalyze = rawDescription.trim().length > 0 && step !== "loading";
|
const canAnalyze = rawDescription.trim().length > 0 && step !== "loading";
|
||||||
const canFetch = fetchUrl.trim().length > 0 && !isFetching && step === "paste";
|
const canFetch =
|
||||||
|
fetchUrl.trim().length > 0 && !isFetching && step === "paste";
|
||||||
const canImport = useMemo(() => {
|
const canImport = useMemo(() => {
|
||||||
if (step !== "review") return false;
|
if (step !== "review") return false;
|
||||||
return (
|
return (
|
||||||
@@ -163,7 +175,9 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
|
|||||||
|
|
||||||
// Automatically proceed to analysis
|
// Automatically proceed to analysis
|
||||||
setStep("loading");
|
setStep("loading");
|
||||||
const inferResponse = await api.inferManualJob({ jobDescription: fetchedContent });
|
const inferResponse = await api.inferManualJob({
|
||||||
|
jobDescription: fetchedContent,
|
||||||
|
});
|
||||||
// Don't pass raw HTML as job description - let user fill it in or use inferred data
|
// Don't pass raw HTML as job description - let user fill it in or use inferred data
|
||||||
const normalized = normalizeDraft(inferResponse.job);
|
const normalized = normalizeDraft(inferResponse.job);
|
||||||
|
|
||||||
@@ -176,7 +190,8 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
|
|||||||
setWarning(inferResponse.warning ?? null);
|
setWarning(inferResponse.warning ?? null);
|
||||||
setStep("review");
|
setStep("review");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Failed to fetch URL";
|
const message =
|
||||||
|
err instanceof Error ? err.message : "Failed to fetch URL";
|
||||||
setError(message);
|
setError(message);
|
||||||
setIsFetching(false);
|
setIsFetching(false);
|
||||||
setStep("paste");
|
setStep("paste");
|
||||||
@@ -193,7 +208,9 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
|
|||||||
setError(null);
|
setError(null);
|
||||||
setWarning(null);
|
setWarning(null);
|
||||||
setStep("loading");
|
setStep("loading");
|
||||||
const response = await api.inferManualJob({ jobDescription: rawDescription });
|
const response = await api.inferManualJob({
|
||||||
|
jobDescription: rawDescription,
|
||||||
|
});
|
||||||
const normalized = normalizeDraft(response.job, rawDescription.trim());
|
const normalized = normalizeDraft(response.job, rawDescription.trim());
|
||||||
// Preserve the fetched URL if we fetched from a URL
|
// Preserve the fetched URL if we fetched from a URL
|
||||||
if (draft.jobUrl && !normalized.jobUrl) {
|
if (draft.jobUrl && !normalized.jobUrl) {
|
||||||
@@ -203,7 +220,10 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
|
|||||||
setWarning(response.warning ?? null);
|
setWarning(response.warning ?? null);
|
||||||
setStep("review");
|
setStep("review");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Failed to analyze job description";
|
const message =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Failed to analyze job description";
|
||||||
setError(message);
|
setError(message);
|
||||||
setStep("paste");
|
setStep("paste");
|
||||||
}
|
}
|
||||||
@@ -222,7 +242,8 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
|
|||||||
await onImported(created.id);
|
await onImported(created.id);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Failed to import job";
|
const message =
|
||||||
|
err instanceof Error ? err.message : "Failed to import job";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsImporting(false);
|
setIsImporting(false);
|
||||||
@@ -241,7 +262,8 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
|
|||||||
Manual Import
|
Manual Import
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
Paste a job description, review the AI draft, then import the role.
|
Paste a job description, review the AI draft, then import the
|
||||||
|
role.
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
@@ -306,7 +328,11 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<ClipboardPaste className="h-4 w-4" />
|
<ClipboardPaste className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
{isFetching ? "Fetching..." : fetchUrl.trim() ? "Fetch" : "Paste"}
|
{isFetching
|
||||||
|
? "Fetching..."
|
||||||
|
: fetchUrl.trim()
|
||||||
|
? "Fetch"
|
||||||
|
: "Paste"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,7 +373,9 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
|
|||||||
{step === "loading" && (
|
{step === "loading" && (
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
<div className="text-sm font-semibold">Analyzing job description</div>
|
<div className="text-sm font-semibold">
|
||||||
|
Analyzing job description
|
||||||
|
</div>
|
||||||
<p className="text-xs text-muted-foreground max-w-xs">
|
<p className="text-xs text-muted-foreground max-w-xs">
|
||||||
Extracting title, company, location, and other details.
|
Extracting title, company, location, and other details.
|
||||||
</p>
|
</p>
|
||||||
@@ -380,106 +408,197 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
|
|||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-xs font-medium text-muted-foreground">Title *</label>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
Title *
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={draft.title}
|
value={draft.title}
|
||||||
onChange={(event) => setDraft((prev) => ({ ...prev, title: event.target.value }))}
|
onChange={(event) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
title: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
placeholder="e.g. Junior Backend Engineer"
|
placeholder="e.g. Junior Backend Engineer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-xs font-medium text-muted-foreground">Employer *</label>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
Employer *
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={draft.employer}
|
value={draft.employer}
|
||||||
onChange={(event) => setDraft((prev) => ({ ...prev, employer: event.target.value }))}
|
onChange={(event) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
employer: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
placeholder="e.g. Acme Labs"
|
placeholder="e.g. Acme Labs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-xs font-medium text-muted-foreground">Location</label>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
Location
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={draft.location}
|
value={draft.location}
|
||||||
onChange={(event) => setDraft((prev) => ({ ...prev, location: event.target.value }))}
|
onChange={(event) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
location: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
placeholder="e.g. London, UK"
|
placeholder="e.g. London, UK"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-xs font-medium text-muted-foreground">Salary</label>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
Salary
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={draft.salary}
|
value={draft.salary}
|
||||||
onChange={(event) => setDraft((prev) => ({ ...prev, salary: event.target.value }))}
|
onChange={(event) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
salary: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
placeholder="e.g. GBP 45k-55k"
|
placeholder="e.g. GBP 45k-55k"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-xs font-medium text-muted-foreground">Deadline</label>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
Deadline
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={draft.deadline}
|
value={draft.deadline}
|
||||||
onChange={(event) => setDraft((prev) => ({ ...prev, deadline: event.target.value }))}
|
onChange={(event) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
deadline: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
placeholder="e.g. 30 Sep 2025"
|
placeholder="e.g. 30 Sep 2025"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-xs font-medium text-muted-foreground">Job type</label>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
Job type
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={draft.jobType}
|
value={draft.jobType}
|
||||||
onChange={(event) => setDraft((prev) => ({ ...prev, jobType: event.target.value }))}
|
onChange={(event) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
jobType: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
placeholder="e.g. Full-time"
|
placeholder="e.g. Full-time"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-xs font-medium text-muted-foreground">Job level</label>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
Job level
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={draft.jobLevel}
|
value={draft.jobLevel}
|
||||||
onChange={(event) => setDraft((prev) => ({ ...prev, jobLevel: event.target.value }))}
|
onChange={(event) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
jobLevel: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
placeholder="e.g. Graduate"
|
placeholder="e.g. Graduate"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-xs font-medium text-muted-foreground">Job function</label>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
Job function
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={draft.jobFunction}
|
value={draft.jobFunction}
|
||||||
onChange={(event) => setDraft((prev) => ({ ...prev, jobFunction: event.target.value }))}
|
onChange={(event) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
jobFunction: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
placeholder="e.g. Software Engineering"
|
placeholder="e.g. Software Engineering"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-xs font-medium text-muted-foreground">Disciplines</label>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
Disciplines
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={draft.disciplines}
|
value={draft.disciplines}
|
||||||
onChange={(event) => setDraft((prev) => ({ ...prev, disciplines: event.target.value }))}
|
onChange={(event) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
disciplines: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
placeholder="e.g. Computer Science"
|
placeholder="e.g. Computer Science"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-xs font-medium text-muted-foreground">Degree required</label>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
Degree required
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={draft.degreeRequired}
|
value={draft.degreeRequired}
|
||||||
onChange={(event) => setDraft((prev) => ({ ...prev, degreeRequired: event.target.value }))}
|
onChange={(event) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
degreeRequired: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
placeholder="e.g. BSc or MSc"
|
placeholder="e.g. BSc or MSc"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-xs font-medium text-muted-foreground">Starting</label>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
Starting
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={draft.starting}
|
value={draft.starting}
|
||||||
onChange={(event) => setDraft((prev) => ({ ...prev, starting: event.target.value }))}
|
onChange={(event) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
starting: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
placeholder="e.g. Summer 2026"
|
placeholder="e.g. Summer 2026"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-xs font-medium text-muted-foreground">Job URL</label>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
Job URL
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={draft.jobUrl}
|
value={draft.jobUrl}
|
||||||
onChange={(event) => setDraft((prev) => ({ ...prev, jobUrl: event.target.value }))}
|
onChange={(event) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
jobUrl: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
placeholder="https://..."
|
placeholder="https://..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-xs font-medium text-muted-foreground">Application link</label>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
|
Application link
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={draft.applicationLink}
|
value={draft.applicationLink}
|
||||||
onChange={(event) => setDraft((prev) => ({ ...prev, applicationLink: event.target.value }))}
|
onChange={(event) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
applicationLink: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
placeholder="https://..."
|
placeholder="https://..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -491,7 +610,12 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
|
|||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={draft.jobDescription}
|
value={draft.jobDescription}
|
||||||
onChange={(event) => setDraft((prev) => ({ ...prev, jobDescription: event.target.value }))}
|
onChange={(event) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
jobDescription: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
className="min-h-[200px] font-mono text-sm leading-relaxed"
|
className="min-h-[200px] font-mono text-sm leading-relaxed"
|
||||||
placeholder="Paste the job description..."
|
placeholder="Paste the job description..."
|
||||||
/>
|
/>
|
||||||
@@ -502,7 +626,10 @@ export const ManualImportSheet: React.FC<ManualImportSheetProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
onClick={handleImport}
|
onClick={handleImport}
|
||||||
disabled={!canImport || isImporting}
|
disabled={!canImport || isImporting}
|
||||||
className={cn("w-full h-10 gap-2", !canImport && "opacity-70")}
|
className={cn(
|
||||||
|
"w-full h-10 gap-2",
|
||||||
|
!canImport && "opacity-70",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{isImporting ? (
|
{isImporting ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
|||||||
@@ -1,119 +1,154 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
import * as api from "@client/api";
|
||||||
import { Check } from "lucide-react"
|
import { useSettings } from "@client/hooks/useSettings";
|
||||||
import { toast } from "sonner"
|
import { BaseResumeSelection } from "@client/pages/settings/components/BaseResumeSelection";
|
||||||
|
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
|
||||||
|
import { formatSecretHint } from "@client/pages/settings/utils";
|
||||||
|
import type { ValidationResult } from "@shared/types";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldDescription,
|
||||||
|
FieldLabel,
|
||||||
|
FieldTitle,
|
||||||
|
} from "@/components/ui/field";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
|
type ValidationState = ValidationResult & { checked: boolean };
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Field, FieldContent, FieldDescription, FieldLabel, FieldTitle } from "@/components/ui/field"
|
|
||||||
import { Progress } from "@/components/ui/progress"
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import * as api from "@client/api"
|
|
||||||
import { useSettings } from "@client/hooks/useSettings"
|
|
||||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
|
||||||
import { formatSecretHint } from "@client/pages/settings/utils"
|
|
||||||
import { BaseResumeSelection } from "@client/pages/settings/components/BaseResumeSelection"
|
|
||||||
import type { ValidationResult } from "@shared/types"
|
|
||||||
|
|
||||||
type ValidationState = ValidationResult & { checked: boolean }
|
|
||||||
|
|
||||||
export const OnboardingGate: React.FC = () => {
|
export const OnboardingGate: React.FC = () => {
|
||||||
const { settings, isLoading: settingsLoading, refreshSettings } = useSettings()
|
const {
|
||||||
const [isSavingEnv, setIsSavingEnv] = useState(false)
|
settings,
|
||||||
const [isValidatingOpenrouter, setIsValidatingOpenrouter] = useState(false)
|
isLoading: settingsLoading,
|
||||||
const [isValidatingRxresume, setIsValidatingRxresume] = useState(false)
|
refreshSettings,
|
||||||
const [isValidatingBaseResume, setIsValidatingBaseResume] = useState(false)
|
} = useSettings();
|
||||||
const [openrouterValidation, setOpenrouterValidation] = useState<ValidationState>({
|
const [isSavingEnv, setIsSavingEnv] = useState(false);
|
||||||
valid: false,
|
const [isValidatingOpenrouter, setIsValidatingOpenrouter] = useState(false);
|
||||||
message: null,
|
const [isValidatingRxresume, setIsValidatingRxresume] = useState(false);
|
||||||
checked: false,
|
const [isValidatingBaseResume, setIsValidatingBaseResume] = useState(false);
|
||||||
})
|
const [openrouterValidation, setOpenrouterValidation] =
|
||||||
const [rxresumeValidation, setRxresumeValidation] = useState<ValidationState>({
|
useState<ValidationState>({
|
||||||
valid: false,
|
valid: false,
|
||||||
message: null,
|
message: null,
|
||||||
checked: false,
|
checked: false,
|
||||||
})
|
});
|
||||||
const [baseResumeValidation, setBaseResumeValidation] = useState<ValidationState>({
|
const [rxresumeValidation, setRxresumeValidation] = useState<ValidationState>(
|
||||||
valid: false,
|
{
|
||||||
message: null,
|
valid: false,
|
||||||
checked: false,
|
message: null,
|
||||||
})
|
checked: false,
|
||||||
const [currentStep, setCurrentStep] = useState<string | null>(null)
|
},
|
||||||
|
);
|
||||||
|
const [baseResumeValidation, setBaseResumeValidation] =
|
||||||
|
useState<ValidationState>({
|
||||||
|
valid: false,
|
||||||
|
message: null,
|
||||||
|
checked: false,
|
||||||
|
});
|
||||||
|
const [currentStep, setCurrentStep] = useState<string | null>(null);
|
||||||
|
|
||||||
const [openrouterApiKey, setOpenrouterApiKey] = useState("")
|
const [openrouterApiKey, setOpenrouterApiKey] = useState("");
|
||||||
const [rxresumeEmail, setRxresumeEmail] = useState("")
|
const [rxresumeEmail, setRxresumeEmail] = useState("");
|
||||||
const [rxresumePassword, setRxresumePassword] = useState("")
|
const [rxresumePassword, setRxresumePassword] = useState("");
|
||||||
const [rxresumeBaseResumeId, setRxresumeBaseResumeId] = useState<string | null>(null)
|
const [rxresumeBaseResumeId, setRxresumeBaseResumeId] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
const validateOpenrouter = useCallback(async (apiKey?: string) => {
|
const validateOpenrouter = useCallback(async (apiKey?: string) => {
|
||||||
setIsValidatingOpenrouter(true)
|
setIsValidatingOpenrouter(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.validateOpenrouter(apiKey)
|
const result = await api.validateOpenrouter(apiKey);
|
||||||
setOpenrouterValidation({ ...result, checked: true })
|
setOpenrouterValidation({ ...result, checked: true });
|
||||||
return result
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "OpenRouter validation failed"
|
const message =
|
||||||
const result = { valid: false, message }
|
error instanceof Error ? error.message : "OpenRouter validation failed";
|
||||||
setOpenrouterValidation({ ...result, checked: true })
|
const result = { valid: false, message };
|
||||||
return result
|
setOpenrouterValidation({ ...result, checked: true });
|
||||||
|
return result;
|
||||||
} finally {
|
} finally {
|
||||||
setIsValidatingOpenrouter(false)
|
setIsValidatingOpenrouter(false);
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const validateRxresume = useCallback(async (email?: string, password?: string) => {
|
const validateRxresume = useCallback(
|
||||||
setIsValidatingRxresume(true)
|
async (email?: string, password?: string) => {
|
||||||
try {
|
setIsValidatingRxresume(true);
|
||||||
const result = await api.validateRxresume(email, password)
|
try {
|
||||||
setRxresumeValidation({ ...result, checked: true })
|
const result = await api.validateRxresume(email, password);
|
||||||
return result
|
setRxresumeValidation({ ...result, checked: true });
|
||||||
} catch (error) {
|
return result;
|
||||||
const message = error instanceof Error ? error.message : "RxResume validation failed"
|
} catch (error) {
|
||||||
const result = { valid: false, message }
|
const message =
|
||||||
setRxresumeValidation({ ...result, checked: true })
|
error instanceof Error ? error.message : "RxResume validation failed";
|
||||||
return result
|
const result = { valid: false, message };
|
||||||
} finally {
|
setRxresumeValidation({ ...result, checked: true });
|
||||||
setIsValidatingRxresume(false)
|
return result;
|
||||||
}
|
} finally {
|
||||||
}, [])
|
setIsValidatingRxresume(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const validateBaseResume = useCallback(async () => {
|
const validateBaseResume = useCallback(async () => {
|
||||||
setIsValidatingBaseResume(true)
|
setIsValidatingBaseResume(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.validateResumeConfig()
|
const result = await api.validateResumeConfig();
|
||||||
setBaseResumeValidation({ ...result, checked: true })
|
setBaseResumeValidation({ ...result, checked: true });
|
||||||
return result
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Base resume validation failed"
|
const message =
|
||||||
const result = { valid: false, message }
|
error instanceof Error
|
||||||
setBaseResumeValidation({ ...result, checked: true })
|
? error.message
|
||||||
return result
|
: "Base resume validation failed";
|
||||||
|
const result = { valid: false, message };
|
||||||
|
setBaseResumeValidation({ ...result, checked: true });
|
||||||
|
return result;
|
||||||
} finally {
|
} finally {
|
||||||
setIsValidatingBaseResume(false)
|
setIsValidatingBaseResume(false);
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint)
|
const hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint);
|
||||||
const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim())
|
const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim());
|
||||||
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint)
|
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint);
|
||||||
const shouldOpen = Boolean(settings && !settingsLoading)
|
const shouldOpen =
|
||||||
&& !(openrouterValidation.valid && rxresumeValidation.valid && baseResumeValidation.valid)
|
Boolean(settings && !settingsLoading) &&
|
||||||
|
!(
|
||||||
|
openrouterValidation.valid &&
|
||||||
|
rxresumeValidation.valid &&
|
||||||
|
baseResumeValidation.valid
|
||||||
|
);
|
||||||
|
|
||||||
const openrouterCurrent = settings?.openrouterApiKeyHint
|
const openrouterCurrent = settings?.openrouterApiKeyHint
|
||||||
? formatSecretHint(settings.openrouterApiKeyHint)
|
? formatSecretHint(settings.openrouterApiKeyHint)
|
||||||
: undefined
|
: undefined;
|
||||||
const rxresumeEmailCurrent = settings?.rxresumeEmail?.trim()
|
const rxresumeEmailCurrent = settings?.rxresumeEmail?.trim()
|
||||||
? settings.rxresumeEmail
|
? settings.rxresumeEmail
|
||||||
: undefined
|
: undefined;
|
||||||
const rxresumePasswordCurrent = settings?.rxresumePasswordHint
|
const rxresumePasswordCurrent = settings?.rxresumePasswordHint
|
||||||
? formatSecretHint(settings.rxresumePasswordHint)
|
? formatSecretHint(settings.rxresumePasswordHint)
|
||||||
: undefined
|
: undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings) {
|
if (settings) {
|
||||||
setRxresumeBaseResumeId(settings.rxresumeBaseResumeId || null)
|
setRxresumeBaseResumeId(settings.rxresumeBaseResumeId || null);
|
||||||
}
|
}
|
||||||
}, [settings])
|
}, [settings]);
|
||||||
|
|
||||||
const steps = useMemo(
|
const steps = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -139,189 +174,236 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
disabled: !rxresumeValidation.valid,
|
disabled: !rxresumeValidation.valid,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[openrouterValidation.valid, rxresumeValidation.valid, baseResumeValidation.valid]
|
[
|
||||||
)
|
openrouterValidation.valid,
|
||||||
|
rxresumeValidation.valid,
|
||||||
|
baseResumeValidation.valid,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const defaultStep = steps.find((step) => !step.complete)?.id ?? steps[0]?.id
|
const defaultStep = steps.find((step) => !step.complete)?.id ?? steps[0]?.id;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shouldOpen) return
|
if (!shouldOpen) return;
|
||||||
if (!currentStep && defaultStep) {
|
if (!currentStep && defaultStep) {
|
||||||
setCurrentStep(defaultStep)
|
setCurrentStep(defaultStep);
|
||||||
}
|
}
|
||||||
}, [currentStep, defaultStep, shouldOpen])
|
}, [currentStep, defaultStep, shouldOpen]);
|
||||||
|
|
||||||
const runAllValidations = useCallback(async () => {
|
const runAllValidations = useCallback(async () => {
|
||||||
if (!settings) return
|
if (!settings) return;
|
||||||
const results = await Promise.allSettled([
|
const results = await Promise.allSettled([
|
||||||
validateOpenrouter(),
|
validateOpenrouter(),
|
||||||
validateRxresume(),
|
validateRxresume(),
|
||||||
validateBaseResume(),
|
validateBaseResume(),
|
||||||
])
|
]);
|
||||||
|
|
||||||
const failed = results.find((result) => result.status === "rejected")
|
const failed = results.find((result) => result.status === "rejected");
|
||||||
if (failed) {
|
if (failed) {
|
||||||
const reason = failed.status === "rejected" ? failed.reason : null
|
const reason = failed.status === "rejected" ? failed.reason : null;
|
||||||
const message = reason instanceof Error ? reason.message : "Validation checks failed"
|
const message =
|
||||||
toast.error(message)
|
reason instanceof Error ? reason.message : "Validation checks failed";
|
||||||
|
toast.error(message);
|
||||||
}
|
}
|
||||||
}, [settings, validateOpenrouter, validateRxresume, validateBaseResume])
|
}, [settings, validateOpenrouter, validateRxresume, validateBaseResume]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settings || settingsLoading) return
|
if (!settings || settingsLoading) return;
|
||||||
if (openrouterValidation.checked || rxresumeValidation.checked || baseResumeValidation.checked) return
|
if (
|
||||||
void runAllValidations()
|
openrouterValidation.checked ||
|
||||||
}, [settings, settingsLoading, openrouterValidation.checked, rxresumeValidation.checked, baseResumeValidation.checked, runAllValidations])
|
rxresumeValidation.checked ||
|
||||||
|
baseResumeValidation.checked
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
void runAllValidations();
|
||||||
|
}, [
|
||||||
|
settings,
|
||||||
|
settingsLoading,
|
||||||
|
openrouterValidation.checked,
|
||||||
|
rxresumeValidation.checked,
|
||||||
|
baseResumeValidation.checked,
|
||||||
|
runAllValidations,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
const results = await Promise.allSettled([refreshSettings(), runAllValidations()])
|
const results = await Promise.allSettled([
|
||||||
const failed = results.find((result) => result.status === "rejected")
|
refreshSettings(),
|
||||||
|
runAllValidations(),
|
||||||
|
]);
|
||||||
|
const failed = results.find((result) => result.status === "rejected");
|
||||||
if (failed) {
|
if (failed) {
|
||||||
const reason = failed.status === "rejected" ? failed.reason : null
|
const reason = failed.status === "rejected" ? failed.reason : null;
|
||||||
const message = reason instanceof Error ? reason.message : "Failed to refresh setup"
|
const message =
|
||||||
toast.error(message)
|
reason instanceof Error ? reason.message : "Failed to refresh setup";
|
||||||
|
toast.error(message);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSaveOpenrouter = async (): Promise<boolean> => {
|
const handleSaveOpenrouter = async (): Promise<boolean> => {
|
||||||
const openrouterValue = openrouterApiKey.trim()
|
const openrouterValue = openrouterApiKey.trim();
|
||||||
if (!openrouterValue && !hasOpenrouterKey) {
|
if (!openrouterValue && !hasOpenrouterKey) {
|
||||||
toast.info("Add your OpenRouter API key to continue")
|
toast.info("Add your OpenRouter API key to continue");
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const validation = await validateOpenrouter(openrouterValue || undefined)
|
const validation = await validateOpenrouter(openrouterValue || undefined);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
toast.error(validation.message || "OpenRouter validation failed")
|
toast.error(validation.message || "OpenRouter validation failed");
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (openrouterValue) {
|
if (openrouterValue) {
|
||||||
setIsSavingEnv(true)
|
setIsSavingEnv(true);
|
||||||
await api.updateSettings({ openrouterApiKey: openrouterValue })
|
await api.updateSettings({ openrouterApiKey: openrouterValue });
|
||||||
await refreshSettings()
|
await refreshSettings();
|
||||||
setOpenrouterApiKey("")
|
setOpenrouterApiKey("");
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success("OpenRouter connected")
|
toast.success("OpenRouter connected");
|
||||||
return true
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to save OpenRouter key"
|
const message =
|
||||||
toast.error(message)
|
error instanceof Error
|
||||||
return false
|
? error.message
|
||||||
|
: "Failed to save OpenRouter key";
|
||||||
|
toast.error(message);
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingEnv(false)
|
setIsSavingEnv(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSaveRxresume = async (): Promise<boolean> => {
|
const handleSaveRxresume = async (): Promise<boolean> => {
|
||||||
const emailValue = rxresumeEmail.trim()
|
const emailValue = rxresumeEmail.trim();
|
||||||
const passwordValue = rxresumePassword.trim()
|
const passwordValue = rxresumePassword.trim();
|
||||||
const missing: string[] = []
|
const missing: string[] = [];
|
||||||
|
|
||||||
if (!hasRxresumeEmail && !emailValue) missing.push("RxResume email")
|
if (!hasRxresumeEmail && !emailValue) missing.push("RxResume email");
|
||||||
if (!hasRxresumePassword && !passwordValue) missing.push("RxResume password")
|
if (!hasRxresumePassword && !passwordValue)
|
||||||
|
missing.push("RxResume password");
|
||||||
|
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
toast.info("Almost there", {
|
toast.info("Almost there", {
|
||||||
description: `Missing: ${missing.join(", ")}`,
|
description: `Missing: ${missing.join(", ")}`,
|
||||||
})
|
});
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const validation = await validateRxresume(emailValue || undefined, passwordValue || undefined)
|
const validation = await validateRxresume(
|
||||||
|
emailValue || undefined,
|
||||||
|
passwordValue || undefined,
|
||||||
|
);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
toast.error(validation.message || "RxResume validation failed")
|
toast.error(validation.message || "RxResume validation failed");
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const update: { rxresumeEmail?: string; rxresumePassword?: string } = {}
|
const update: { rxresumeEmail?: string; rxresumePassword?: string } = {};
|
||||||
if (emailValue) update.rxresumeEmail = emailValue
|
if (emailValue) update.rxresumeEmail = emailValue;
|
||||||
if (passwordValue) update.rxresumePassword = passwordValue
|
if (passwordValue) update.rxresumePassword = passwordValue;
|
||||||
|
|
||||||
if (Object.keys(update).length > 0) {
|
if (Object.keys(update).length > 0) {
|
||||||
setIsSavingEnv(true)
|
setIsSavingEnv(true);
|
||||||
await api.updateSettings(update)
|
await api.updateSettings(update);
|
||||||
await refreshSettings()
|
await refreshSettings();
|
||||||
setRxresumePassword("")
|
setRxresumePassword("");
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success("RxResume connected")
|
toast.success("RxResume connected");
|
||||||
return true
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to save RxResume credentials"
|
const message =
|
||||||
toast.error(message)
|
error instanceof Error
|
||||||
return false
|
? error.message
|
||||||
|
: "Failed to save RxResume credentials";
|
||||||
|
toast.error(message);
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingEnv(false)
|
setIsSavingEnv(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSaveBaseResume = async (): Promise<boolean> => {
|
const handleSaveBaseResume = async (): Promise<boolean> => {
|
||||||
if (!rxresumeBaseResumeId) {
|
if (!rxresumeBaseResumeId) {
|
||||||
toast.info("Select a base resume to continue")
|
toast.info("Select a base resume to continue");
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSavingEnv(true)
|
setIsSavingEnv(true);
|
||||||
await api.updateSettings({ rxresumeBaseResumeId: rxresumeBaseResumeId })
|
await api.updateSettings({ rxresumeBaseResumeId: rxresumeBaseResumeId });
|
||||||
const validation = await validateBaseResume()
|
const validation = await validateBaseResume();
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
toast.error(validation.message || "Base resume validation failed")
|
toast.error(validation.message || "Base resume validation failed");
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await refreshSettings()
|
await refreshSettings();
|
||||||
toast.success("Base resume set")
|
toast.success("Base resume set");
|
||||||
return true
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to save base resume"
|
const message =
|
||||||
toast.error(message)
|
error instanceof Error ? error.message : "Failed to save base resume";
|
||||||
return false
|
toast.error(message);
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingEnv(false)
|
setIsSavingEnv(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const resolvedStepIndex = currentStep ? steps.findIndex((step) => step.id === currentStep) : 0
|
const resolvedStepIndex = currentStep
|
||||||
const stepIndex = resolvedStepIndex >= 0 ? resolvedStepIndex : 0
|
? steps.findIndex((step) => step.id === currentStep)
|
||||||
const completedSteps = steps.filter((step) => step.complete).length
|
: 0;
|
||||||
const progressValue = steps.length > 0 ? Math.round((completedSteps / steps.length) * 100) : 0
|
const stepIndex = resolvedStepIndex >= 0 ? resolvedStepIndex : 0;
|
||||||
const isBusy = isSavingEnv || settingsLoading || isValidatingOpenrouter || isValidatingRxresume || isValidatingBaseResume
|
const completedSteps = steps.filter((step) => step.complete).length;
|
||||||
const canGoBack = stepIndex > 0
|
const progressValue =
|
||||||
const primaryLabel = currentStep === "openrouter"
|
steps.length > 0 ? Math.round((completedSteps / steps.length) * 100) : 0;
|
||||||
? (openrouterValidation.valid ? "Revalidate" : "Validate")
|
const isBusy =
|
||||||
: currentStep === "rxresume"
|
isSavingEnv ||
|
||||||
? (rxresumeValidation.valid ? "Revalidate" : "Validate")
|
settingsLoading ||
|
||||||
: currentStep === "baseresume"
|
isValidatingOpenrouter ||
|
||||||
? (baseResumeValidation.valid ? "Revalidate" : "Validate")
|
isValidatingRxresume ||
|
||||||
|
isValidatingBaseResume;
|
||||||
|
const canGoBack = stepIndex > 0;
|
||||||
|
const primaryLabel =
|
||||||
|
currentStep === "openrouter"
|
||||||
|
? openrouterValidation.valid
|
||||||
|
? "Revalidate"
|
||||||
: "Validate"
|
: "Validate"
|
||||||
|
: currentStep === "rxresume"
|
||||||
|
? rxresumeValidation.valid
|
||||||
|
? "Revalidate"
|
||||||
|
: "Validate"
|
||||||
|
: currentStep === "baseresume"
|
||||||
|
? baseResumeValidation.valid
|
||||||
|
? "Revalidate"
|
||||||
|
: "Validate"
|
||||||
|
: "Validate";
|
||||||
|
|
||||||
const handlePrimaryAction = async () => {
|
const handlePrimaryAction = async () => {
|
||||||
if (!currentStep) return
|
if (!currentStep) return;
|
||||||
if (currentStep === "openrouter") {
|
if (currentStep === "openrouter") {
|
||||||
await handleSaveOpenrouter()
|
await handleSaveOpenrouter();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (currentStep === "rxresume") {
|
if (currentStep === "rxresume") {
|
||||||
await handleSaveRxresume()
|
await handleSaveRxresume();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (currentStep === "baseresume") {
|
if (currentStep === "baseresume") {
|
||||||
await handleSaveBaseResume()
|
await handleSaveBaseResume();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
if (!canGoBack) return
|
if (!canGoBack) return;
|
||||||
setCurrentStep(steps[stepIndex - 1]?.id ?? currentStep)
|
setCurrentStep(steps[stepIndex - 1]?.id ?? currentStep);
|
||||||
}
|
};
|
||||||
|
|
||||||
if (!shouldOpen || !currentStep) return null
|
if (!shouldOpen || !currentStep) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertDialog open>
|
<AlertDialog open>
|
||||||
@@ -333,22 +415,23 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Welcome to Job Ops</AlertDialogTitle>
|
<AlertDialogTitle>Welcome to Job Ops</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Let’s get your workspace ready. Add your keys and resume once, then the pipeline can run end-to-end.
|
Let’s get your workspace ready. Add your keys and resume once,
|
||||||
|
then the pipeline can run end-to-end.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
|
|
||||||
<Tabs value={currentStep} onValueChange={setCurrentStep}>
|
<Tabs value={currentStep} onValueChange={setCurrentStep}>
|
||||||
<TabsList className="grid h-auto w-full grid-cols-1 gap-2 border-b border-border/60 bg-transparent p-0 text-left sm:grid-cols-3">
|
<TabsList className="grid h-auto w-full grid-cols-1 gap-2 border-b border-border/60 bg-transparent p-0 text-left sm:grid-cols-3">
|
||||||
{steps.map((step, index) => {
|
{steps.map((step, index) => {
|
||||||
const isActive = step.id === currentStep
|
const isActive = step.id === currentStep;
|
||||||
const isComplete = step.complete
|
const isComplete = step.complete;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FieldLabel
|
<FieldLabel
|
||||||
key={step.id}
|
key={step.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full [&>[data-slot=field]]:border-0 [&>[data-slot=field]]:p-0 [&>[data-slot=field]]:rounded-none",
|
"w-full [&>[data-slot=field]]:border-0 [&>[data-slot=field]]:p-0 [&>[data-slot=field]]:rounded-none",
|
||||||
step.disabled && "opacity-50 cursor-not-allowed"
|
step.disabled && "opacity-50 cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
@@ -356,7 +439,9 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
disabled={step.disabled}
|
disabled={step.disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full rounded-md hover:bg-muted/60 border-b-2 border-transparent px-3 py-4 text-left shadow-none",
|
"w-full rounded-md hover:bg-muted/60 border-b-2 border-transparent px-3 py-4 text-left shadow-none",
|
||||||
isActive ? "border-primary !bg-muted/60 text-foreground" : "text-muted-foreground"
|
isActive
|
||||||
|
? "border-primary !bg-muted/60 text-foreground"
|
||||||
|
: "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Field orientation="horizontal" className="items-start">
|
<Field orientation="horizontal" className="items-start">
|
||||||
@@ -369,22 +454,28 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
"mt-0.5 flex h-6 w-6 items-center justify-center rounded-md text-xs font-semibold",
|
"mt-0.5 flex h-6 w-6 items-center justify-center rounded-md text-xs font-semibold",
|
||||||
isComplete
|
isComplete
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "bg-muted text-muted-foreground"
|
: "bg-muted text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isComplete ? <Check className="h-3.5 w-3.5" /> : index + 1}
|
{isComplete ? (
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
index + 1
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</Field>
|
</Field>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="openrouter" className="space-y-4 pt-6">
|
<TabsContent value="openrouter" className="space-y-4 pt-6">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold">Connect OpenRouter</p>
|
<p className="text-sm font-semibold">Connect OpenRouter</p>
|
||||||
<p className="text-xs text-muted-foreground">Used for job scoring, summaries, and tailoring.</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Used for job scoring, summaries, and tailoring.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<SettingsInput
|
<SettingsInput
|
||||||
label="OpenRouter API key"
|
label="OpenRouter API key"
|
||||||
@@ -403,8 +494,12 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
|
|
||||||
<TabsContent value="rxresume" className="space-y-4 pt-6">
|
<TabsContent value="rxresume" className="space-y-4 pt-6">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold">Link your RxResume account</p>
|
<p className="text-sm font-semibold">
|
||||||
<p className="text-xs text-muted-foreground">Used to export tailored PDFs.</p>
|
Link your RxResume account
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Used to export tailored PDFs.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<SettingsInput
|
<SettingsInput
|
||||||
@@ -423,7 +518,8 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
inputProps={{
|
inputProps={{
|
||||||
name: "rxresumePassword",
|
name: "rxresumePassword",
|
||||||
value: rxresumePassword,
|
value: rxresumePassword,
|
||||||
onChange: (event) => setRxresumePassword(event.target.value),
|
onChange: (event) =>
|
||||||
|
setRxresumePassword(event.target.value),
|
||||||
}}
|
}}
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter password"
|
placeholder="Enter password"
|
||||||
@@ -435,9 +531,12 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
|
|
||||||
<TabsContent value="baseresume" className="space-y-4 pt-6">
|
<TabsContent value="baseresume" className="space-y-4 pt-6">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold">Select your template resume</p>
|
<p className="text-sm font-semibold">
|
||||||
<p className="text-xs text-muted-foreground">Choose the resume you want to use as a template.
|
Select your template resume
|
||||||
The selected resume will be used as a template for tailoring.
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Choose the resume you want to use as a template. The selected
|
||||||
|
resume will be used as a template for tailoring.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<BaseResumeSelection
|
<BaseResumeSelection
|
||||||
@@ -447,11 +546,14 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
disabled={isSavingEnv}
|
disabled={isSavingEnv}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Button variant="outline" onClick={handleBack} disabled={!canGoBack || isBusy}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleBack}
|
||||||
|
disabled={!canGoBack || isBusy}
|
||||||
|
>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -467,8 +569,8 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
<Progress value={progressValue} className="h-2" />
|
<Progress value={progressValue} className="h-2" />
|
||||||
|
|
||||||
<div className="rounded-lg border border-muted bg-muted/30 p-3 text-xs text-muted-foreground">
|
<div className="rounded-lg border border-muted bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||||
Friendly heads-up: pipelines can be slow or a little flaky in alpha. If anything feels off, open a GitHub issue and
|
Friendly heads-up: pipelines can be slow or a little flaky in alpha.
|
||||||
we will take a look.{" "}
|
If anything feels off, open a GitHub issue and we will take a look.{" "}
|
||||||
<a
|
<a
|
||||||
className="font-semibold text-foreground underline underline-offset-2"
|
className="font-semibold text-foreground underline underline-offset-2"
|
||||||
href="https://github.com/DaKheera47/job-ops/issues"
|
href="https://github.com/DaKheera47/job-ops/issues"
|
||||||
@@ -482,5 +584,5 @@ export const OnboardingGate: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
* Live pipeline progress display component.
|
* Live pipeline progress display component.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -12,7 +13,14 @@ import { Separator } from "@/components/ui/separator";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface PipelineProgress {
|
interface PipelineProgress {
|
||||||
step: "idle" | "crawling" | "importing" | "scoring" | "processing" | "completed" | "failed";
|
step:
|
||||||
|
| "idle"
|
||||||
|
| "crawling"
|
||||||
|
| "importing"
|
||||||
|
| "scoring"
|
||||||
|
| "processing"
|
||||||
|
| "completed"
|
||||||
|
| "failed";
|
||||||
message: string;
|
message: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
crawlingListPagesProcessed: number;
|
crawlingListPagesProcessed: number;
|
||||||
@@ -61,9 +69,12 @@ const stepBadgeClasses: Record<PipelineProgress["step"], string> = {
|
|||||||
failed: "bg-destructive/10 text-destructive border-destructive/20",
|
failed: "bg-destructive/10 text-destructive border-destructive/20",
|
||||||
};
|
};
|
||||||
|
|
||||||
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
|
const clamp = (value: number, min: number, max: number) =>
|
||||||
|
Math.max(min, Math.min(max, value));
|
||||||
|
|
||||||
export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning }) => {
|
export const PipelineProgress: React.FC<PipelineProgressProps> = ({
|
||||||
|
isRunning,
|
||||||
|
}) => {
|
||||||
const [progress, setProgress] = useState<PipelineProgress | null>(null);
|
const [progress, setProgress] = useState<PipelineProgress | null>(null);
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
|
||||||
@@ -74,9 +85,11 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning })
|
|||||||
case "crawling": {
|
case "crawling": {
|
||||||
if (progress.crawlingListPagesTotal > 0) {
|
if (progress.crawlingListPagesTotal > 0) {
|
||||||
return clamp(
|
return clamp(
|
||||||
(progress.crawlingListPagesProcessed / progress.crawlingListPagesTotal) * 15,
|
(progress.crawlingListPagesProcessed /
|
||||||
|
progress.crawlingListPagesTotal) *
|
||||||
|
15,
|
||||||
0,
|
0,
|
||||||
15
|
15,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (progress.crawlingListPagesProcessed > 0) return 8;
|
if (progress.crawlingListPagesProcessed > 0) return 8;
|
||||||
@@ -87,9 +100,10 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning })
|
|||||||
case "scoring": {
|
case "scoring": {
|
||||||
if (progress.jobsScored > 0) {
|
if (progress.jobsScored > 0) {
|
||||||
return clamp(
|
return clamp(
|
||||||
20 + (progress.jobsScored / Math.max(progress.jobsDiscovered, 1)) * 30,
|
20 +
|
||||||
|
(progress.jobsScored / Math.max(progress.jobsDiscovered, 1)) * 30,
|
||||||
20,
|
20,
|
||||||
50
|
50,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return 25;
|
return 25;
|
||||||
@@ -99,7 +113,7 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning })
|
|||||||
return clamp(
|
return clamp(
|
||||||
50 + (progress.jobsProcessed / progress.totalToProcess) * 50,
|
50 + (progress.jobsProcessed / progress.totalToProcess) * 50,
|
||||||
50,
|
50,
|
||||||
100
|
100,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return 55;
|
return 55;
|
||||||
@@ -151,7 +165,9 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning })
|
|||||||
const step = progress?.step ?? "idle";
|
const step = progress?.step ?? "idle";
|
||||||
const isActive = step !== "idle" && step !== "completed" && step !== "failed";
|
const isActive = step !== "idle" && step !== "completed" && step !== "failed";
|
||||||
|
|
||||||
const showStats = !!progress && ["crawling", "scoring", "processing", "completed"].includes(step);
|
const showStats =
|
||||||
|
!!progress &&
|
||||||
|
["crawling", "scoring", "processing", "completed"].includes(step);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -159,7 +175,10 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning })
|
|||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
<CardTitle className="text-base">Pipeline</CardTitle>
|
<CardTitle className="text-base">Pipeline</CardTitle>
|
||||||
<Badge variant="outline" className={cn("uppercase tracking-wide", stepBadgeClasses[step])}>
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn("uppercase tracking-wide", stepBadgeClasses[step])}
|
||||||
|
>
|
||||||
{stepLabels[step]}
|
{stepLabels[step]}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="truncate text-xs text-muted-foreground">
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
@@ -180,7 +199,9 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning })
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm">{progress.message}</p>
|
<p className="text-sm">{progress.message}</p>
|
||||||
{progress.detail && <p className="text-sm text-muted-foreground">{progress.detail}</p>}
|
{progress.detail && (
|
||||||
|
<p className="text-sm text-muted-foreground">{progress.detail}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showStats && (
|
{showStats && (
|
||||||
@@ -190,46 +211,73 @@ export const PipelineProgress: React.FC<PipelineProgressProps> = ({ isRunning })
|
|||||||
{step === "crawling" ? (
|
{step === "crawling" ? (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-muted-foreground">Sources</div>
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Sources
|
||||||
|
</div>
|
||||||
<div className="tabular-nums">
|
<div className="tabular-nums">
|
||||||
{progress.crawlingListPagesProcessed}
|
{progress.crawlingListPagesProcessed}
|
||||||
{progress.crawlingListPagesTotal > 0 ? `/${progress.crawlingListPagesTotal}` : ""}
|
{progress.crawlingListPagesTotal > 0
|
||||||
|
? `/${progress.crawlingListPagesTotal}`
|
||||||
|
: ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-muted-foreground">Pages</div>
|
<div className="text-xs text-muted-foreground">Pages</div>
|
||||||
<div className="tabular-nums">
|
<div className="tabular-nums">
|
||||||
{progress.crawlingJobPagesProcessed}/{Math.max(progress.crawlingJobPagesEnqueued, 0)}
|
{progress.crawlingJobPagesProcessed}/
|
||||||
|
{Math.max(progress.crawlingJobPagesEnqueued, 0)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-muted-foreground">Enqueued</div>
|
<div className="text-xs text-muted-foreground">
|
||||||
<div className="tabular-nums">{progress.crawlingJobPagesEnqueued}</div>
|
Enqueued
|
||||||
|
</div>
|
||||||
|
<div className="tabular-nums">
|
||||||
|
{progress.crawlingJobPagesEnqueued}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-muted-foreground">Skipped</div>
|
<div className="text-xs text-muted-foreground">
|
||||||
<div className="tabular-nums">{progress.crawlingJobPagesSkipped}</div>
|
Skipped
|
||||||
|
</div>
|
||||||
|
<div className="tabular-nums">
|
||||||
|
{progress.crawlingJobPagesSkipped}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-muted-foreground">Discovered</div>
|
<div className="text-xs text-muted-foreground">
|
||||||
<div className="tabular-nums">{progress.jobsDiscovered}</div>
|
Discovered
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground">Scored</div>
|
|
||||||
<div className="tabular-nums">{progress.jobsScored}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground">Processed</div>
|
|
||||||
<div className="tabular-nums">
|
<div className="tabular-nums">
|
||||||
{progress.totalToProcess > 0 ? `${progress.jobsProcessed}/${progress.totalToProcess}` : progress.jobsProcessed}
|
{progress.jobsDiscovered}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-muted-foreground">To process</div>
|
<div className="text-xs text-muted-foreground">
|
||||||
<div className="tabular-nums">{progress.totalToProcess}</div>
|
Scored
|
||||||
|
</div>
|
||||||
|
<div className="tabular-nums">{progress.jobsScored}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Processed
|
||||||
|
</div>
|
||||||
|
<div className="tabular-nums">
|
||||||
|
{progress.totalToProcess > 0
|
||||||
|
? `${progress.jobsProcessed}/${progress.totalToProcess}`
|
||||||
|
: progress.jobsProcessed}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
To process
|
||||||
|
</div>
|
||||||
|
<div className="tabular-nums">
|
||||||
|
{progress.totalToProcess}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import React from "react";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import type React from "react";
|
||||||
import { ReadyPanel } from "./ReadyPanel";
|
import { toast } from "sonner";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { Job } from "../../shared/types";
|
import type { Job } from "../../shared/types";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
import { toast } from "sonner";
|
import { ReadyPanel } from "./ReadyPanel";
|
||||||
|
|
||||||
vi.mock("@/components/ui/dropdown-menu", () => {
|
vi.mock("@/components/ui/dropdown-menu", () => {
|
||||||
return {
|
return {
|
||||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
||||||
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
<div>{children}</div>
|
||||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>,
|
),
|
||||||
|
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<>{children}</>
|
||||||
|
),
|
||||||
|
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div role="menu">{children}</div>
|
||||||
|
),
|
||||||
DropdownMenuItem: ({
|
DropdownMenuItem: ({
|
||||||
children,
|
children,
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -20,7 +25,12 @@ vi.mock("@/components/ui/dropdown-menu", () => {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
onSelect?: () => void;
|
onSelect?: () => void;
|
||||||
}) => (
|
}) => (
|
||||||
<button type="button" role="menuitem" onClick={() => onSelect?.()} {...props}>
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => onSelect?.()}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
@@ -126,14 +136,12 @@ describe("ReadyPanel", () => {
|
|||||||
vi.mocked(api.rescoreJob).mockResolvedValue(job as Job);
|
vi.mocked(api.rescoreJob).mockResolvedValue(job as Job);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<ReadyPanel
|
<ReadyPanel job={job} onJobUpdated={onJobUpdated} onJobMoved={vi.fn()} />,
|
||||||
job={job}
|
|
||||||
onJobUpdated={onJobUpdated}
|
|
||||||
onJobMoved={vi.fn()}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("menuitem", { name: /recalculate match/i }));
|
fireEvent.click(
|
||||||
|
screen.getByRole("menuitem", { name: /recalculate match/i }),
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(api.rescoreJob).toHaveBeenCalledWith("job-1"));
|
await waitFor(() => expect(api.rescoreJob).toHaveBeenCalledWith("job-1"));
|
||||||
expect(onJobUpdated).toHaveBeenCalled();
|
expect(onJobUpdated).toHaveBeenCalled();
|
||||||
|
|||||||
@@ -7,26 +7,32 @@
|
|||||||
* Now includes inline tailoring mode for editing and regenerating PDFs without switching tabs.
|
* Now includes inline tailoring mode for editing and regenerating PDFs without switching tabs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
import {
|
||||||
|
Briefcase,
|
||||||
|
Building2,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
|
Edit2,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
|
FolderKanban,
|
||||||
Loader2,
|
Loader2,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
Undo2,
|
Undo2,
|
||||||
Copy,
|
|
||||||
Edit2,
|
|
||||||
XCircle,
|
XCircle,
|
||||||
Briefcase,
|
|
||||||
Building2,
|
|
||||||
FolderKanban,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -35,19 +41,13 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from "@/components/ui/accordion";
|
|
||||||
import { cn, copyTextToClipboard, formatJobForWebhook } from "@/lib/utils";
|
import { cn, copyTextToClipboard, formatJobForWebhook } from "@/lib/utils";
|
||||||
|
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
import { FitAssessment, JobHeader, TailoredSummary } from ".";
|
|
||||||
import { TailorMode } from "./discovered-panel/TailorMode";
|
|
||||||
import { useProfile } from "../hooks/useProfile";
|
import { useProfile } from "../hooks/useProfile";
|
||||||
import { useRescoreJob } from "../hooks/useRescoreJob";
|
import { useRescoreJob } from "../hooks/useRescoreJob";
|
||||||
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
|
import { FitAssessment, JobHeader, TailoredSummary } from ".";
|
||||||
|
import { TailorMode } from "./discovered-panel/TailorMode";
|
||||||
|
|
||||||
type PanelMode = "ready" | "tailor";
|
type PanelMode = "ready" | "tailor";
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
const selectedProjectNames = useMemo(() => {
|
const selectedProjectNames = useMemo(() => {
|
||||||
if (!catalog.length || !selectedProjectIds.length) return [];
|
if (!catalog.length || !selectedProjectIds.length) return [];
|
||||||
return selectedProjectIds
|
return selectedProjectIds
|
||||||
.map(id => catalog.find(p => p.id === id)?.name)
|
.map((id) => catalog.find((p) => p.id === id)?.name)
|
||||||
.filter(Boolean) as string[];
|
.filter(Boolean) as string[];
|
||||||
}, [catalog, selectedProjectIds]);
|
}, [catalog, selectedProjectIds]);
|
||||||
|
|
||||||
@@ -140,7 +140,8 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
duration: 6000,
|
duration: 6000,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to mark as applied";
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to mark as applied";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsMarkingApplied(false);
|
setIsMarkingApplied(false);
|
||||||
@@ -160,7 +161,8 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
setRecentlyApplied(null);
|
setRecentlyApplied(null);
|
||||||
await onJobUpdated();
|
await onJobUpdated();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to undo";
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to undo";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -176,14 +178,18 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
toast.success("PDF regenerated");
|
toast.success("PDF regenerated");
|
||||||
await onJobUpdated();
|
await onJobUpdated();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to regenerate PDF";
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to regenerate PDF";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsRegenerating(false);
|
setIsRegenerating(false);
|
||||||
}
|
}
|
||||||
}, [job, onJobUpdated]);
|
}, [job, onJobUpdated]);
|
||||||
|
|
||||||
const handleRescore = useCallback(() => rescoreJob(job?.id), [job?.id, rescoreJob]);
|
const handleRescore = useCallback(
|
||||||
|
() => rescoreJob(job?.id),
|
||||||
|
[job?.id, rescoreJob],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSkip = useCallback(async () => {
|
const handleSkip = useCallback(async () => {
|
||||||
if (!job) return;
|
if (!job) return;
|
||||||
@@ -222,7 +228,8 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
await onJobUpdated();
|
await onJobUpdated();
|
||||||
setMode("ready");
|
setMode("ready");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to regenerate PDF";
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to regenerate PDF";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsRegenerating(false);
|
setIsRegenerating(false);
|
||||||
@@ -236,7 +243,9 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted/30">
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted/30">
|
||||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium text-muted-foreground">No job selected</div>
|
<div className="text-sm font-medium text-muted-foreground">
|
||||||
|
No job selected
|
||||||
|
</div>
|
||||||
<p className="text-xs text-muted-foreground/70 max-w-[200px]">
|
<p className="text-xs text-muted-foreground/70 max-w-[200px]">
|
||||||
Select a Ready job to view its application kit and take action.
|
Select a Ready job to view its application kit and take action.
|
||||||
</p>
|
</p>
|
||||||
@@ -275,7 +284,11 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
<div className="pb-4 border-b border-border/40">
|
<div className="pb-4 border-b border-border/40">
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
{/* Show PDF - to verify quickly without download */}
|
{/* Show PDF - to verify quickly without download */}
|
||||||
<Button asChild variant="outline" className="h-9 w-full gap-1 px-2 text-xs">
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="outline"
|
||||||
|
className="h-9 w-full gap-1 px-2 text-xs"
|
||||||
|
>
|
||||||
<a href={pdfHref} target="_blank" rel="noopener noreferrer">
|
<a href={pdfHref} target="_blank" rel="noopener noreferrer">
|
||||||
<FileText className="h-3.5 w-3.5 shrink-0" />
|
<FileText className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="truncate">View PDF</span>
|
<span className="truncate">View PDF</span>
|
||||||
@@ -283,7 +296,11 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Download PDF - primary artifact action */}
|
{/* Download PDF - primary artifact action */}
|
||||||
<Button asChild variant="outline" className="h-9 w-full gap-1 px-2 text-xs">
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="outline"
|
||||||
|
className="h-9 w-full gap-1 px-2 text-xs"
|
||||||
|
>
|
||||||
<a
|
<a
|
||||||
href={pdfHref}
|
href={pdfHref}
|
||||||
download={`${safeFilenamePart(personName)}_${safeFilenamePart(job.employer)}.pdf`}
|
download={`${safeFilenamePart(personName)}_${safeFilenamePart(job.employer)}.pdf`}
|
||||||
@@ -294,7 +311,11 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Open job - to verify before applying */}
|
{/* Open job - to verify before applying */}
|
||||||
<Button asChild variant="outline" className="h-9 w-full gap-1 px-2 text-xs">
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="outline"
|
||||||
|
className="h-9 w-full gap-1 px-2 text-xs"
|
||||||
|
>
|
||||||
<a href={jobLink} target="_blank" rel="noopener noreferrer">
|
<a href={jobLink} target="_blank" rel="noopener noreferrer">
|
||||||
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
|
<ExternalLink className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="truncate">Open Job Listing</span>
|
<span className="truncate">Open Job Listing</span>
|
||||||
@@ -338,7 +359,9 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1 text-left">
|
<div className="min-w-0 flex-1 text-left">
|
||||||
<div className="text-sm font-medium text-foreground leading-tight">
|
<div className="text-sm font-medium text-foreground leading-tight">
|
||||||
{selectedProjectIds.length} {selectedProjectIds.length === 1 ? "project" : "projects"} selected
|
{selectedProjectIds.length}{" "}
|
||||||
|
{selectedProjectIds.length === 1 ? "project" : "projects"}{" "}
|
||||||
|
selected
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -385,15 +408,16 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
|||||||
onSelect={handleRegenerate}
|
onSelect={handleRegenerate}
|
||||||
disabled={isRegenerating}
|
disabled={isRegenerating}
|
||||||
>
|
>
|
||||||
<RefreshCcw className={cn("mr-2 h-4 w-4", isRegenerating && "animate-spin")} />
|
<RefreshCcw
|
||||||
|
className={cn("mr-2 h-4 w-4", isRegenerating && "animate-spin")}
|
||||||
|
/>
|
||||||
{isRegenerating ? "Regenerating..." : "Regenerate PDF"}
|
{isRegenerating ? "Regenerating..." : "Regenerate PDF"}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onSelect={handleRescore} disabled={isRescoring}>
|
||||||
onSelect={handleRescore}
|
<RefreshCcw
|
||||||
disabled={isRescoring}
|
className={cn("mr-2 h-4 w-4", isRescoring && "animate-spin")}
|
||||||
>
|
/>
|
||||||
<RefreshCcw className={cn("mr-2 h-4 w-4", isRescoring && "animate-spin")} />
|
|
||||||
{isRescoring ? "Recalculating..." : "Recalculate match"}
|
{isRescoring ? "Recalculating..." : "Recalculate match"}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Suitability score display component.
|
* Suitability score display component.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import type React from "react";
|
||||||
|
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
|
||||||
@@ -18,8 +18,9 @@ export const ScoreIndicator: React.FC<ScoreIndicatorProps> = ({ score }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Progress value={score} className="h-2 w-20" />
|
<Progress value={score} className="h-2 w-20" />
|
||||||
<span className="text-sm tabular-nums text-muted-foreground">{score}</span>
|
<span className="text-sm tabular-nums text-muted-foreground">
|
||||||
|
{score}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
* Stats dashboard showing job counts by status.
|
* Stats dashboard showing job counts by status.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -11,6 +10,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
XCircle,
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import type { JobStatus } from "../../shared/types";
|
import type { JobStatus } from "../../shared/types";
|
||||||
@@ -74,4 +74,3 @@ export const Stats: React.FC<StatsProps> = ({ stats }) => {
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* Status badge component.
|
* Status badge component.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -24,12 +24,18 @@ const statusLabels: Record<JobStatus, string> = {
|
|||||||
|
|
||||||
const statusStyles: Record<
|
const statusStyles: Record<
|
||||||
JobStatus,
|
JobStatus,
|
||||||
{ variant: "default" | "secondary" | "destructive" | "outline"; className?: string }
|
{
|
||||||
|
variant: "default" | "secondary" | "destructive" | "outline";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
> = {
|
> = {
|
||||||
discovered: { variant: "secondary" },
|
discovered: { variant: "secondary" },
|
||||||
processing: { variant: "secondary" },
|
processing: { variant: "secondary" },
|
||||||
ready: { variant: "default" },
|
ready: { variant: "default" },
|
||||||
applied: { variant: "outline", className: "text-emerald-400 border-emerald-500/30" },
|
applied: {
|
||||||
|
variant: "outline",
|
||||||
|
className: "text-emerald-400 border-emerald-500/30",
|
||||||
|
},
|
||||||
skipped: { variant: "destructive" },
|
skipped: { variant: "destructive" },
|
||||||
expired: { variant: "outline", className: "text-muted-foreground" },
|
expired: { variant: "outline", className: "text-muted-foreground" },
|
||||||
};
|
};
|
||||||
@@ -44,4 +50,3 @@ export const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => {
|
|||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import type React from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Job } from "../../shared/types";
|
import type { Job } from "../../shared/types";
|
||||||
|
|
||||||
@@ -7,11 +7,19 @@ interface TailoredSummaryProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TailoredSummary: React.FC<TailoredSummaryProps> = ({ job, className }) => {
|
export const TailoredSummary: React.FC<TailoredSummaryProps> = ({
|
||||||
|
job,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
if (!job.tailoredSummary) return null;
|
if (!job.tailoredSummary) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("rounded-lg border border-border/40 bg-muted/10 px-3 py-2.5", className)}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border border-border/40 bg-muted/10 px-3 py-2.5",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground mb-1.5">
|
<div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground mb-1.5">
|
||||||
Tailored Summary
|
Tailored Summary
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import {
|
||||||
import { Check, Loader2, Sparkles, FileText, AlertTriangle } from "lucide-react";
|
AlertTriangle,
|
||||||
|
Check,
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import * as api from "../api";
|
|
||||||
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
|
import type { Job, ResumeProjectCatalogItem } from "../../shared/types";
|
||||||
|
import * as api from "../api";
|
||||||
|
|
||||||
interface TailoringEditorProps {
|
interface TailoringEditorProps {
|
||||||
job: Job;
|
job: Job;
|
||||||
@@ -26,7 +33,9 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
||||||
const [summary, setSummary] = useState(job.tailoredSummary || "");
|
const [summary, setSummary] = useState(job.tailoredSummary || "");
|
||||||
const [jobDescription, setJobDescription] = useState(job.jobDescription || "");
|
const [jobDescription, setJobDescription] = useState(
|
||||||
|
job.jobDescription || "",
|
||||||
|
);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [isSummarizing, setIsSummarizing] = useState(false);
|
const [isSummarizing, setIsSummarizing] = useState(false);
|
||||||
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
|
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
|
||||||
@@ -45,9 +54,10 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
|||||||
return false;
|
return false;
|
||||||
}, [selectedIds, savedSelectedIds]);
|
}, [selectedIds, savedSelectedIds]);
|
||||||
|
|
||||||
const isDirty = summary !== (job.tailoredSummary || "") ||
|
const isDirty =
|
||||||
jobDescription !== (job.jobDescription || "") ||
|
summary !== (job.tailoredSummary || "") ||
|
||||||
hasSelectionDiff;
|
jobDescription !== (job.jobDescription || "") ||
|
||||||
|
hasSelectionDiff;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onDirtyChange?.(isDirty);
|
onDirtyChange?.(isDirty);
|
||||||
@@ -59,7 +69,9 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
|||||||
|
|
||||||
// Set initial selection
|
// Set initial selection
|
||||||
if (job.selectedProjectIds) {
|
if (job.selectedProjectIds) {
|
||||||
setSelectedIds(new Set(job.selectedProjectIds.split(',').filter(Boolean)));
|
setSelectedIds(
|
||||||
|
new Set(job.selectedProjectIds.split(",").filter(Boolean)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setJobDescription(job.jobDescription || "");
|
setJobDescription(job.jobDescription || "");
|
||||||
}, [job.selectedProjectIds, job.jobDescription]);
|
}, [job.selectedProjectIds, job.jobDescription]);
|
||||||
@@ -119,7 +131,9 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
|||||||
setSummary(updatedJob.tailoredSummary || "");
|
setSummary(updatedJob.tailoredSummary || "");
|
||||||
setJobDescription(updatedJob.jobDescription || "");
|
setJobDescription(updatedJob.jobDescription || "");
|
||||||
if (updatedJob.selectedProjectIds) {
|
if (updatedJob.selectedProjectIds) {
|
||||||
setSelectedIds(new Set(updatedJob.selectedProjectIds.split(',').filter(Boolean)));
|
setSelectedIds(
|
||||||
|
new Set(updatedJob.selectedProjectIds.split(",").filter(Boolean)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
toast.success("AI Summary & Projects generated");
|
toast.success("AI Summary & Projects generated");
|
||||||
await onUpdate();
|
await onUpdate();
|
||||||
@@ -164,7 +178,11 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
|||||||
disabled={isSummarizing || isGeneratingPdf || isSaving}
|
disabled={isSummarizing || isGeneratingPdf || isSaving}
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
{isSummarizing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
|
{isSummarizing ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
AI Summarize
|
AI Summarize
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -173,7 +191,11 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
|||||||
disabled={isSummarizing || isGeneratingPdf || isSaving || !summary}
|
disabled={isSummarizing || isGeneratingPdf || isSaving || !summary}
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
{isGeneratingPdf ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileText className="mr-2 h-4 w-4" />}
|
{isGeneratingPdf ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
Generate PDF
|
Generate PDF
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,7 +203,9 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
|||||||
|
|
||||||
<div className="space-y-4 rounded-lg border bg-card p-4 shadow-sm">
|
<div className="space-y-4 rounded-lg border bg-card p-4 shadow-sm">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Job Description (Edit to help AI tailoring)</label>
|
<label className="text-sm font-medium">
|
||||||
|
Job Description (Edit to help AI tailoring)
|
||||||
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full min-h-[120px] max-h-[250px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
className="w-full min-h-[120px] max-h-[250px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
value={jobDescription}
|
value={jobDescription}
|
||||||
@@ -210,7 +234,8 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
|||||||
{tooManyProjects && (
|
{tooManyProjects && (
|
||||||
<span className="flex items-center gap-1 text-xs text-amber-600 font-medium">
|
<span className="flex items-center gap-1 text-xs text-amber-600 font-medium">
|
||||||
<AlertTriangle className="h-3 w-3" />
|
<AlertTriangle className="h-3 w-3" />
|
||||||
Warning: More than {maxProjects} projects might make the resume too long.
|
Warning: More than {maxProjects} projects might make the resume
|
||||||
|
too long.
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -231,7 +256,9 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
|||||||
className="flex flex-1 flex-col gap-1 cursor-pointer"
|
className="flex flex-1 flex-col gap-1 cursor-pointer"
|
||||||
>
|
>
|
||||||
<span className="font-semibold">{project.name}</span>
|
<span className="font-semibold">{project.name}</span>
|
||||||
<span className="text-xs text-muted-foreground line-clamp-2">{project.description}</span>
|
<span className="text-xs text-muted-foreground line-clamp-2">
|
||||||
|
{project.description}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -239,10 +266,19 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end border-t pt-4">
|
<div className="flex justify-end border-t pt-4">
|
||||||
<Button variant="ghost" size="sm" onClick={handleSave} disabled={isSaving}>
|
<Button
|
||||||
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="mr-2 h-4 w-4" />}
|
variant="ghost"
|
||||||
Save Selection
|
size="sm"
|
||||||
</Button>
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Save Selection
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
|
||||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
|
||||||
interface CollapsibleSectionProps {
|
interface CollapsibleSectionProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -15,16 +15,16 @@ export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
|
|||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className='space-y-2'>
|
<div className="space-y-2">
|
||||||
<button
|
<button
|
||||||
type='button'
|
type="button"
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className='flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-full'
|
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors w-full"
|
||||||
>
|
>
|
||||||
{isOpen ? (
|
{isOpen ? (
|
||||||
<ChevronUp className='h-3.5 w-3.5' />
|
<ChevronUp className="h-3.5 w-3.5" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronDown className='h-3.5 w-3.5' />
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import React, { useMemo, useState } from "react";
|
import {
|
||||||
import { ChevronUp, ExternalLink, Loader2, RefreshCcw, Sparkles, XCircle } from "lucide-react";
|
ChevronUp,
|
||||||
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
|
RefreshCcw,
|
||||||
|
Sparkles,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -9,9 +17,8 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
import { FitAssessment, JobHeader, TailoredSummary } from "..";
|
|
||||||
import type { Job } from "../../../shared/types";
|
import type { Job } from "../../../shared/types";
|
||||||
|
import { FitAssessment, JobHeader, TailoredSummary } from "..";
|
||||||
import { CollapsibleSection } from "./CollapsibleSection";
|
import { CollapsibleSection } from "./CollapsibleSection";
|
||||||
import { getPlainDescription } from "./helpers";
|
import { getPlainDescription } from "./helpers";
|
||||||
|
|
||||||
@@ -39,46 +46,43 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
|||||||
|
|
||||||
const description = useMemo(
|
const description = useMemo(
|
||||||
() => getPlainDescription(job.jobDescription),
|
() => getPlainDescription(job.jobDescription),
|
||||||
[job.jobDescription]
|
[job.jobDescription],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col h-full'>
|
<div className="flex flex-col h-full">
|
||||||
<div className='space-y-4 pb-4'>
|
<div className="space-y-4 pb-4">
|
||||||
<JobHeader
|
<JobHeader job={job} onCheckSponsor={onCheckSponsor} />
|
||||||
job={job}
|
|
||||||
onCheckSponsor={onCheckSponsor}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className='flex flex-col gap-2.5 pt-2 sm:flex-row'>
|
<div className="flex flex-col gap-2.5 pt-2 sm:flex-row">
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant="outline"
|
||||||
size='default'
|
size="default"
|
||||||
onClick={onSkip}
|
onClick={onSkip}
|
||||||
disabled={isSkipping}
|
disabled={isSkipping}
|
||||||
className='flex-1 h-11 text-sm text-muted-foreground hover:text-rose-500 hover:border-rose-500/30 hover:bg-rose-500/5 sm:h-10 sm:text-xs'
|
className="flex-1 h-11 text-sm text-muted-foreground hover:text-rose-500 hover:border-rose-500/30 hover:bg-rose-500/5 sm:h-10 sm:text-xs"
|
||||||
>
|
>
|
||||||
{isSkipping ? (
|
{isSkipping ? (
|
||||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<XCircle className='mr-2 h-4 w-4' />
|
<XCircle className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
Skip Job
|
Skip Job
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size='default'
|
size="default"
|
||||||
onClick={onTailor}
|
onClick={onTailor}
|
||||||
className='flex-1 h-11 text-sm bg-primary/90 hover:bg-primary sm:h-10 sm:text-xs shadow-sm'
|
className="flex-1 h-11 text-sm bg-primary/90 hover:bg-primary sm:h-10 sm:text-xs shadow-sm"
|
||||||
>
|
>
|
||||||
<Sparkles className='mr-2 h-4 w-4' />
|
<Sparkles className="mr-2 h-4 w-4" />
|
||||||
Start Tailoring
|
Start Tailoring
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className='opacity-40' />
|
<Separator className="opacity-40" />
|
||||||
|
|
||||||
<div className='flex-1 py-6 space-y-6 overflow-y-auto'>
|
<div className="flex-1 py-6 space-y-6 overflow-y-auto">
|
||||||
<FitAssessment job={job} />
|
<FitAssessment job={job} />
|
||||||
<TailoredSummary job={job} />
|
<TailoredSummary job={job} />
|
||||||
|
|
||||||
@@ -87,17 +91,17 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
|||||||
onToggle={() => setShowDescription((prev) => !prev)}
|
onToggle={() => setShowDescription((prev) => !prev)}
|
||||||
label={`${showDescription ? "Hide" : "View"} Full Job Description`}
|
label={`${showDescription ? "Hide" : "View"} Full Job Description`}
|
||||||
>
|
>
|
||||||
<div className='rounded-xl border border-border/40 bg-muted/5 p-4 mt-2 max-h-[400px] overflow-y-auto shadow-inner'>
|
<div className="rounded-xl border border-border/40 bg-muted/5 p-4 mt-2 max-h-[400px] overflow-y-auto shadow-inner">
|
||||||
<p className='text-xs text-muted-foreground/90 whitespace-pre-wrap leading-relaxed'>
|
<p className="text-xs text-muted-foreground/90 whitespace-pre-wrap leading-relaxed">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className='opacity-40' />
|
<Separator className="opacity-40" />
|
||||||
|
|
||||||
<div className='pt-4 pb-2 space-y-4'>
|
<div className="pt-4 pb-2 space-y-4">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -111,21 +115,25 @@ export const DecideMode: React.FC<DecideModeProps> = ({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="center" className="w-56">
|
<DropdownMenuContent align="center" className="w-56">
|
||||||
<DropdownMenuItem onSelect={onRescore} disabled={isRescoring}>
|
<DropdownMenuItem onSelect={onRescore} disabled={isRescoring}>
|
||||||
<RefreshCcw className={isRescoring ? "mr-2 h-4 w-4 animate-spin" : "mr-2 h-4 w-4"} />
|
<RefreshCcw
|
||||||
|
className={
|
||||||
|
isRescoring ? "mr-2 h-4 w-4 animate-spin" : "mr-2 h-4 w-4"
|
||||||
|
}
|
||||||
|
/>
|
||||||
{isRescoring ? "Recalculating..." : "Recalculate match"}
|
{isRescoring ? "Recalculating..." : "Recalculate match"}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
{jobLink ? (
|
{jobLink ? (
|
||||||
<div className='flex justify-center'>
|
<div className="flex justify-center">
|
||||||
<a
|
<a
|
||||||
href={jobLink}
|
href={jobLink}
|
||||||
target='_blank'
|
target="_blank"
|
||||||
rel='noopener noreferrer'
|
rel="noopener noreferrer"
|
||||||
className='inline-flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground hover:text-foreground transition-colors'
|
className="inline-flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<ExternalLink className='h-3.5 w-3.5' />
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
Original Job Listing
|
Original Job Listing
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import React from "react";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import type React from "react";
|
||||||
import { DiscoveredPanel } from "./DiscoveredPanel";
|
import { toast } from "sonner";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { Job } from "../../../shared/types";
|
import type { Job } from "../../../shared/types";
|
||||||
import * as api from "../../api";
|
import * as api from "../../api";
|
||||||
import { toast } from "sonner";
|
import { DiscoveredPanel } from "./DiscoveredPanel";
|
||||||
|
|
||||||
vi.mock("@/components/ui/dropdown-menu", () => {
|
vi.mock("@/components/ui/dropdown-menu", () => {
|
||||||
return {
|
return {
|
||||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
||||||
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
<div>{children}</div>
|
||||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>,
|
),
|
||||||
|
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<>{children}</>
|
||||||
|
),
|
||||||
|
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div role="menu">{children}</div>
|
||||||
|
),
|
||||||
DropdownMenuItem: ({
|
DropdownMenuItem: ({
|
||||||
children,
|
children,
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -20,7 +25,12 @@ vi.mock("@/components/ui/dropdown-menu", () => {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
onSelect?: () => void;
|
onSelect?: () => void;
|
||||||
}) => (
|
}) => (
|
||||||
<button type="button" role="menuitem" onClick={() => onSelect?.()} {...props}>
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => onSelect?.()}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
@@ -123,10 +133,12 @@ describe("DiscoveredPanel", () => {
|
|||||||
job={job}
|
job={job}
|
||||||
onJobUpdated={onJobUpdated}
|
onJobUpdated={onJobUpdated}
|
||||||
onJobMoved={vi.fn()}
|
onJobMoved={vi.fn()}
|
||||||
/>
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("menuitem", { name: /recalculate match/i }));
|
fireEvent.click(
|
||||||
|
screen.getByRole("menuitem", { name: /recalculate match/i }),
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(api.rescoreJob).toHaveBeenCalledWith("job-2"));
|
await waitFor(() => expect(api.rescoreJob).toHaveBeenCalledWith("job-2"));
|
||||||
expect(onJobUpdated).toHaveBeenCalled();
|
expect(onJobUpdated).toHaveBeenCalled();
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import type React from "react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import * as api from "../../api";
|
|
||||||
import type { Job } from "../../../shared/types";
|
import type { Job } from "../../../shared/types";
|
||||||
|
import * as api from "../../api";
|
||||||
|
import { useRescoreJob } from "../../hooks/useRescoreJob";
|
||||||
import { DecideMode } from "./DecideMode";
|
import { DecideMode } from "./DecideMode";
|
||||||
import { EmptyState } from "./EmptyState";
|
import { EmptyState } from "./EmptyState";
|
||||||
import { ProcessingState } from "./ProcessingState";
|
import { ProcessingState } from "./ProcessingState";
|
||||||
import { TailorMode } from "./TailorMode";
|
import { TailorMode } from "./TailorMode";
|
||||||
import { useRescoreJob } from "../../hooks/useRescoreJob";
|
|
||||||
|
|
||||||
type PanelMode = "decide" | "tailor";
|
type PanelMode = "decide" | "tailor";
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='h-full'>
|
<div className="h-full">
|
||||||
{mode === "decide" ? (
|
{mode === "decide" ? (
|
||||||
<DecideMode
|
<DecideMode
|
||||||
job={job}
|
job={job}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import React from "react";
|
|
||||||
import { Sparkles } from "lucide-react";
|
import { Sparkles } from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
|
||||||
export const EmptyState: React.FC = () => {
|
export const EmptyState: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<div className='flex h-full min-h-[300px] flex-col items-center justify-center gap-2 text-center px-4'>
|
<div className="flex h-full min-h-[300px] flex-col items-center justify-center gap-2 text-center px-4">
|
||||||
<div className='h-10 w-10 rounded-full border border-border/40 bg-muted/20 flex items-center justify-center'>
|
<div className="h-10 w-10 rounded-full border border-border/40 bg-muted/20 flex items-center justify-center">
|
||||||
<Sparkles className='h-4 w-4 text-muted-foreground/50' />
|
<Sparkles className="h-4 w-4 text-muted-foreground/50" />
|
||||||
</div>
|
</div>
|
||||||
<div className='text-sm font-medium text-muted-foreground'>
|
<div className="text-sm font-medium text-muted-foreground">
|
||||||
No job selected
|
No job selected
|
||||||
</div>
|
</div>
|
||||||
<p className='text-xs text-muted-foreground/70 max-w-[200px]'>
|
<p className="text-xs text-muted-foreground/70 max-w-[200px]">
|
||||||
Select a job from the list to see details and decide whether to tailor.
|
Select a job from the list to see details and decide whether to tailor.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import React from "react";
|
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
|
||||||
export const ProcessingState: React.FC = () => {
|
export const ProcessingState: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<div className='flex h-full min-h-[300px] flex-col items-center justify-center gap-3 text-center px-4'>
|
<div className="flex h-full min-h-[300px] flex-col items-center justify-center gap-3 text-center px-4">
|
||||||
<Loader2 className='h-8 w-8 animate-spin text-amber-400' />
|
<Loader2 className="h-8 w-8 animate-spin text-amber-400" />
|
||||||
<div className='text-sm font-medium text-foreground/80'>
|
<div className="text-sm font-medium text-foreground/80">
|
||||||
Processing job...
|
Processing job...
|
||||||
</div>
|
</div>
|
||||||
<p className='text-xs text-muted-foreground max-w-[220px]'>
|
<p className="text-xs text-muted-foreground max-w-[220px]">
|
||||||
This job is currently being analyzed by the pipeline. Please wait.
|
This job is currently being analyzed by the pipeline. Please wait.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -23,22 +23,22 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
|
|||||||
const tooManyProjects = selectedIds.size > maxProjects;
|
const tooManyProjects = selectedIds.size > maxProjects;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-2'>
|
<div className="space-y-2">
|
||||||
<div className='flex flex-wrap items-start gap-2 sm:items-center sm:justify-between'>
|
<div className="flex flex-wrap items-start gap-2 sm:items-center sm:justify-between">
|
||||||
<label className='text-xs font-medium text-muted-foreground'>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
Selected Projects
|
Selected Projects
|
||||||
</label>
|
</label>
|
||||||
{tooManyProjects && (
|
{tooManyProjects && (
|
||||||
<span className='flex items-center gap-1 text-[10px] text-amber-500 font-medium'>
|
<span className="flex items-center gap-1 text-[10px] text-amber-500 font-medium">
|
||||||
<AlertTriangle className='h-3 w-3' />
|
<AlertTriangle className="h-3 w-3" />
|
||||||
Max {maxProjects} recommended
|
Max {maxProjects} recommended
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-1.5 max-h-[200px] overflow-y-auto pr-1'>
|
<div className="space-y-1.5 max-h-[200px] overflow-y-auto pr-1">
|
||||||
{catalog.length === 0 ? (
|
{catalog.length === 0 ? (
|
||||||
<div className='text-xs text-muted-foreground text-center py-4'>
|
<div className="text-xs text-muted-foreground text-center py-4">
|
||||||
Loading projects...
|
Loading projects...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -49,7 +49,7 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
|
|||||||
"flex items-start gap-2.5 rounded-lg border p-2.5 text-xs transition-colors cursor-pointer",
|
"flex items-start gap-2.5 rounded-lg border p-2.5 text-xs transition-colors cursor-pointer",
|
||||||
selectedIds.has(project.id)
|
selectedIds.has(project.id)
|
||||||
? "border-primary/40 bg-primary/5"
|
? "border-primary/40 bg-primary/5"
|
||||||
: "border-border/40 bg-muted/5 hover:bg-muted/10"
|
: "border-border/40 bg-muted/5 hover:bg-muted/10",
|
||||||
)}
|
)}
|
||||||
onClick={() => !disabled && onToggle(project.id)}
|
onClick={() => !disabled && onToggle(project.id)}
|
||||||
>
|
>
|
||||||
@@ -58,11 +58,11 @@ export const ProjectSelector: React.FC<ProjectSelectorProps> = ({
|
|||||||
checked={selectedIds.has(project.id)}
|
checked={selectedIds.has(project.id)}
|
||||||
onCheckedChange={() => onToggle(project.id)}
|
onCheckedChange={() => onToggle(project.id)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className='mt-0.5'
|
className="mt-0.5"
|
||||||
/>
|
/>
|
||||||
<div className='flex-1 min-w-0'>
|
<div className="flex-1 min-w-0">
|
||||||
<div className='font-medium truncate'>{project.name}</div>
|
<div className="font-medium truncate">{project.name}</div>
|
||||||
<div className='text-[10px] text-muted-foreground line-clamp-1 mt-0.5'>
|
<div className="text-[10px] text-muted-foreground line-clamp-1 mt-0.5">
|
||||||
{project.description}
|
{project.description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { ArrowLeft, Check, Loader2, Sparkles } from "lucide-react";
|
import { ArrowLeft, Check, Loader2, Sparkles } from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
import * as api from "../../api";
|
|
||||||
import type { Job, ResumeProjectCatalogItem } from "../../../shared/types";
|
import type { Job, ResumeProjectCatalogItem } from "../../../shared/types";
|
||||||
|
import * as api from "../../api";
|
||||||
import { CollapsibleSection } from "./CollapsibleSection";
|
import { CollapsibleSection } from "./CollapsibleSection";
|
||||||
import { ProjectSelector } from "./ProjectSelector";
|
import { ProjectSelector } from "./ProjectSelector";
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ interface TailorModeProps {
|
|||||||
onFinalize: () => void;
|
onFinalize: () => void;
|
||||||
isFinalizing: boolean;
|
isFinalizing: boolean;
|
||||||
/** Variant controls the finalize button text. Default is 'discovered'. */
|
/** Variant controls the finalize button text. Default is 'discovered'. */
|
||||||
variant?: 'discovered' | 'ready';
|
variant?: "discovered" | "ready";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TailorMode: React.FC<TailorModeProps> = ({
|
export const TailorMode: React.FC<TailorModeProps> = ({
|
||||||
@@ -24,11 +24,13 @@ export const TailorMode: React.FC<TailorModeProps> = ({
|
|||||||
onBack,
|
onBack,
|
||||||
onFinalize,
|
onFinalize,
|
||||||
isFinalizing,
|
isFinalizing,
|
||||||
variant = 'discovered',
|
variant = "discovered",
|
||||||
}) => {
|
}) => {
|
||||||
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
const [catalog, setCatalog] = useState<ResumeProjectCatalogItem[]>([]);
|
||||||
const [summary, setSummary] = useState(job.tailoredSummary || "");
|
const [summary, setSummary] = useState(job.tailoredSummary || "");
|
||||||
const [jobDescription, setJobDescription] = useState(job.jobDescription || "");
|
const [jobDescription, setJobDescription] = useState(
|
||||||
|
job.jobDescription || "",
|
||||||
|
);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => {
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => {
|
||||||
const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? [];
|
const saved = job.selectedProjectIds?.split(",").filter(Boolean) ?? [];
|
||||||
return new Set(saved);
|
return new Set(saved);
|
||||||
@@ -67,7 +69,14 @@ export const TailorMode: React.FC<TailorModeProps> = ({
|
|||||||
if (!savedIds.has(id)) return true;
|
if (!savedIds.has(id)) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}, [summary, savedSummary, jobDescription, savedDescription, selectedIds, savedIds]);
|
}, [
|
||||||
|
summary,
|
||||||
|
savedSummary,
|
||||||
|
jobDescription,
|
||||||
|
savedDescription,
|
||||||
|
selectedIds,
|
||||||
|
savedIds,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasChanges && draftStatus === "saved") {
|
if (hasChanges && draftStatus === "saved") {
|
||||||
@@ -105,7 +114,7 @@ export const TailorMode: React.FC<TailorModeProps> = ({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[isGenerating, isFinalizing]
|
[isGenerating, isFinalizing],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleGenerateWithAI = async () => {
|
const handleGenerateWithAI = async () => {
|
||||||
@@ -125,7 +134,7 @@ export const TailorMode: React.FC<TailorModeProps> = ({
|
|||||||
setJobDescription(updatedJob.jobDescription || "");
|
setJobDescription(updatedJob.jobDescription || "");
|
||||||
if (updatedJob.selectedProjectIds) {
|
if (updatedJob.selectedProjectIds) {
|
||||||
setSelectedIds(
|
setSelectedIds(
|
||||||
new Set(updatedJob.selectedProjectIds.split(",").filter(Boolean))
|
new Set(updatedJob.selectedProjectIds.split(",").filter(Boolean)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
setDraftStatus("saved");
|
setDraftStatus("saved");
|
||||||
@@ -165,67 +174,69 @@ export const TailorMode: React.FC<TailorModeProps> = ({
|
|||||||
const disableInputs = isGenerating || isFinalizing || isSaving;
|
const disableInputs = isGenerating || isFinalizing || isSaving;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col h-full'>
|
<div className="flex flex-col h-full">
|
||||||
<div className='flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between'>
|
<div className="flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<button
|
<button
|
||||||
type='button'
|
type="button"
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
className='flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors'
|
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft className='h-3.5 w-3.5' />
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
Back to overview
|
Back to overview
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className='flex items-center gap-1.5 text-[10px] text-muted-foreground'>
|
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||||
{draftStatus === "saving" && (
|
{draftStatus === "saving" && (
|
||||||
<>
|
<>
|
||||||
<Loader2 className='h-3 w-3 animate-spin' />
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
Saving...
|
Saving...
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{draftStatus === "saved" && !hasChanges && (
|
{draftStatus === "saved" && !hasChanges && (
|
||||||
<>
|
<>
|
||||||
<Check className='h-3 w-3 text-emerald-400' />
|
<Check className="h-3 w-3 text-emerald-400" />
|
||||||
Saved
|
Saved
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{draftStatus === "unsaved" && (
|
{draftStatus === "unsaved" && (
|
||||||
<span className='text-amber-400'>Unsaved changes</span>
|
<span className="text-amber-400">Unsaved changes</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2 mb-4'>
|
<div className="rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2 mb-4">
|
||||||
<div className='flex items-center gap-2'>
|
<div className="flex items-center gap-2">
|
||||||
<div className='h-2 w-2 rounded-full bg-amber-400 animate-pulse' />
|
<div className="h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
|
||||||
<span className='text-xs font-medium text-amber-300'>
|
<span className="text-xs font-medium text-amber-300">
|
||||||
Draft tailoring for this role
|
Draft tailoring for this role
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className='text-[10px] text-muted-foreground mt-1 ml-4'>
|
<p className="text-[10px] text-muted-foreground mt-1 ml-4">
|
||||||
Edit below, then finalize to generate your PDF and move to Ready.
|
Edit below, then finalize to generate your PDF and move to Ready.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex-1 overflow-y-auto space-y-4 pr-1'>
|
<div className="flex-1 overflow-y-auto space-y-4 pr-1">
|
||||||
<div className='flex flex-col gap-2 rounded-lg border border-border/40 bg-muted/10 p-3 sm:flex-row sm:items-center sm:justify-between'>
|
<div className="flex flex-col gap-2 rounded-lg border border-border/40 bg-muted/10 p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className='text-xs font-medium'>Need help getting started?</div>
|
<div className="text-xs font-medium">
|
||||||
<div className='text-[10px] text-muted-foreground'>
|
Need help getting started?
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">
|
||||||
AI can draft a summary and select projects for you
|
AI can draft a summary and select projects for you
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size='sm'
|
size="sm"
|
||||||
variant='outline'
|
variant="outline"
|
||||||
onClick={handleGenerateWithAI}
|
onClick={handleGenerateWithAI}
|
||||||
disabled={isGenerating || isFinalizing}
|
disabled={isGenerating || isFinalizing}
|
||||||
className='h-8 w-full text-xs sm:w-auto'
|
className="h-8 w-full text-xs sm:w-auto"
|
||||||
>
|
>
|
||||||
{isGenerating ? (
|
{isGenerating ? (
|
||||||
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Sparkles className='mr-1.5 h-3.5 w-3.5' />
|
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
Generate draft
|
Generate draft
|
||||||
</Button>
|
</Button>
|
||||||
@@ -236,29 +247,29 @@ export const TailorMode: React.FC<TailorModeProps> = ({
|
|||||||
onToggle={() => setShowDescription((prev) => !prev)}
|
onToggle={() => setShowDescription((prev) => !prev)}
|
||||||
label={`${showDescription ? "Hide" : "Edit"} job description`}
|
label={`${showDescription ? "Hide" : "Edit"} job description`}
|
||||||
>
|
>
|
||||||
<div className='space-y-1'>
|
<div className="space-y-1">
|
||||||
<label className='text-[10px] font-medium text-muted-foreground/70'>
|
<label className="text-[10px] font-medium text-muted-foreground/70">
|
||||||
Edit to help AI tailoring
|
Edit to help AI tailoring
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
className='w-full min-h-[120px] max-h-[250px] rounded-lg border border-border/60 bg-background/50 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50'
|
className="w-full min-h-[120px] max-h-[250px] rounded-lg border border-border/60 bg-background/50 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
value={jobDescription}
|
value={jobDescription}
|
||||||
onChange={(event) => setJobDescription(event.target.value)}
|
onChange={(event) => setJobDescription(event.target.value)}
|
||||||
placeholder='The raw job description...'
|
placeholder="The raw job description..."
|
||||||
disabled={disableInputs}
|
disabled={disableInputs}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
<div className='space-y-2'>
|
<div className="space-y-2">
|
||||||
<label className='text-xs font-medium text-muted-foreground'>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
Tailored Summary
|
Tailored Summary
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
className='w-full min-h-[100px] rounded-lg border border-border/60 bg-background/50 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50'
|
className="w-full min-h-[100px] rounded-lg border border-border/60 bg-background/50 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
value={summary}
|
value={summary}
|
||||||
onChange={(event) => setSummary(event.target.value)}
|
onChange={(event) => setSummary(event.target.value)}
|
||||||
placeholder='Write a tailored summary for this role, or generate with AI...'
|
placeholder="Write a tailored summary for this role, or generate with AI..."
|
||||||
disabled={disableInputs}
|
disabled={disableInputs}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -272,35 +283,40 @@ export const TailorMode: React.FC<TailorModeProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className='opacity-50 my-4' />
|
<Separator className="opacity-50 my-4" />
|
||||||
|
|
||||||
<div className='space-y-2'>
|
<div className="space-y-2">
|
||||||
{!canFinalize && (
|
{!canFinalize && (
|
||||||
<p className='text-[10px] text-center text-muted-foreground'>
|
<p className="text-[10px] text-center text-muted-foreground">
|
||||||
Add a summary and select at least one project to {variant === 'ready' ? 'regenerate' : 'finalize'}.
|
Add a summary and select at least one project to{" "}
|
||||||
|
{variant === "ready" ? "regenerate" : "finalize"}.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleFinalize}
|
onClick={handleFinalize}
|
||||||
disabled={isFinalizing || !canFinalize || isGenerating}
|
disabled={isFinalizing || !canFinalize || isGenerating}
|
||||||
className='w-full h-10 bg-emerald-600 hover:bg-emerald-500 text-white'
|
className="w-full h-10 bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||||
>
|
>
|
||||||
{isFinalizing ? (
|
{isFinalizing ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
{variant === 'ready' ? 'Regenerating PDF...' : 'Finalizing & generating PDF...'}
|
{variant === "ready"
|
||||||
|
? "Regenerating PDF..."
|
||||||
|
: "Finalizing & generating PDF..."}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Check className='mr-2 h-4 w-4' />
|
<Check className="mr-2 h-4 w-4" />
|
||||||
{variant === 'ready' ? 'Regenerate PDF' : 'Finalize & Move to Ready'}
|
{variant === "ready"
|
||||||
|
? "Regenerate PDF"
|
||||||
|
: "Finalize & Move to Ready"}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<p className='text-[10px] text-center text-muted-foreground/70'>
|
<p className="text-[10px] text-center text-muted-foreground/70">
|
||||||
{variant === 'ready'
|
{variant === "ready"
|
||||||
? 'This will save your changes and regenerate the tailored PDF.'
|
? "This will save your changes and regenerate the tailored PDF."
|
||||||
: 'This will generate your tailored PDF and move the job to Ready.'}
|
: "This will generate your tailored PDF and move the job to Ready."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
export { Header } from './Header';
|
export { DiscoveredPanel } from "./discovered-panel";
|
||||||
export { Stats } from './Stats';
|
export { FitAssessment } from "./FitAssessment";
|
||||||
export { StatusBadge } from './StatusBadge';
|
export { Header } from "./Header";
|
||||||
export { ScoreIndicator } from './ScoreIndicator';
|
export { JobHeader } from "./JobHeader";
|
||||||
export { JobHeader } from './JobHeader';
|
export * from "./layout";
|
||||||
export { TailoredSummary } from './TailoredSummary';
|
export { ManualImportSheet } from "./ManualImportSheet";
|
||||||
export { FitAssessment } from './FitAssessment';
|
export { PipelineProgress } from "./PipelineProgress";
|
||||||
export { PipelineProgress } from './PipelineProgress';
|
export { ReadyPanel } from "./ReadyPanel";
|
||||||
export { TailoringEditor } from './TailoringEditor';
|
export { ScoreIndicator } from "./ScoreIndicator";
|
||||||
export { DiscoveredPanel } from './discovered-panel';
|
export { Stats } from "./Stats";
|
||||||
export { ReadyPanel } from './ReadyPanel';
|
export { StatusBadge } from "./StatusBadge";
|
||||||
export { ManualImportSheet } from './ManualImportSheet';
|
export { TailoredSummary } from "./TailoredSummary";
|
||||||
export * from './layout';
|
export { TailoringEditor } from "./TailoringEditor";
|
||||||
|
|||||||
@@ -2,9 +2,17 @@
|
|||||||
* Shared layout components for consistent page structure.
|
* Shared layout components for consistent page structure.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import {
|
||||||
|
Briefcase,
|
||||||
|
Home,
|
||||||
|
type LucideIcon,
|
||||||
|
Menu,
|
||||||
|
Settings,
|
||||||
|
Shield,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
import { useState } from "react";
|
||||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { Briefcase, Home, LucideIcon, Menu, Settings, Shield } from "lucide-react";
|
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -81,9 +89,16 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
|
|||||||
onClick={() => handleNavClick(to)}
|
onClick={() => handleNavClick(to)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left",
|
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left",
|
||||||
location.pathname === to || (to === "/" && ["/ready", "/discovered", "/applied", "/all"].includes(location.pathname))
|
location.pathname === to ||
|
||||||
|
(to === "/" &&
|
||||||
|
[
|
||||||
|
"/ready",
|
||||||
|
"/discovered",
|
||||||
|
"/applied",
|
||||||
|
"/all",
|
||||||
|
].includes(location.pathname))
|
||||||
? "bg-accent text-accent-foreground"
|
? "bg-accent text-accent-foreground"
|
||||||
: "text-muted-foreground"
|
: "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<NavIcon className="h-4 w-4" />
|
<NavIcon className="h-4 w-4" />
|
||||||
@@ -109,9 +124,7 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
|
|||||||
{statusIndicator}
|
{statusIndicator}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">{actions}</div>
|
||||||
{actions}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
@@ -126,7 +139,10 @@ interface StatusIndicatorProps {
|
|||||||
variant?: "amber" | "emerald" | "sky";
|
variant?: "amber" | "emerald" | "sky";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StatusIndicator: React.FC<StatusIndicatorProps> = ({ label, variant = "amber" }) => {
|
export const StatusIndicator: React.FC<StatusIndicatorProps> = ({
|
||||||
|
label,
|
||||||
|
variant = "amber",
|
||||||
|
}) => {
|
||||||
const colorMap = {
|
const colorMap = {
|
||||||
amber: "border-amber-500/30 bg-amber-500/10 text-amber-200",
|
amber: "border-amber-500/30 bg-amber-500/10 text-amber-200",
|
||||||
emerald: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200",
|
emerald: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200",
|
||||||
@@ -142,10 +158,15 @@ export const StatusIndicator: React.FC<StatusIndicatorProps> = ({ label, variant
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-2 rounded-full border px-2 py-1 text-[11px] font-semibold uppercase tracking-wide",
|
"inline-flex items-center gap-2 rounded-full border px-2 py-1 text-[11px] font-semibold uppercase tracking-wide",
|
||||||
colorMap[variant]
|
colorMap[variant],
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={cn("h-1.5 w-1.5 rounded-full animate-pulse", dotMap[variant])} />
|
<span
|
||||||
|
className={cn(
|
||||||
|
"h-1.5 w-1.5 rounded-full animate-pulse",
|
||||||
|
dotMap[variant],
|
||||||
|
)}
|
||||||
|
/>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -160,8 +181,16 @@ interface SplitLayoutProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SplitLayout: React.FC<SplitLayoutProps> = ({ children, className }) => (
|
export const SplitLayout: React.FC<SplitLayoutProps> = ({
|
||||||
<section className={cn("grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,420px)]", className)}>
|
children,
|
||||||
|
className,
|
||||||
|
}) => (
|
||||||
|
<section
|
||||||
|
className={cn(
|
||||||
|
"grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,420px)]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -177,11 +206,27 @@ interface ListPanelProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ListPanel: React.FC<ListPanelProps> = ({ children, header, footer, className }) => (
|
export const ListPanel: React.FC<ListPanelProps> = ({
|
||||||
<div className={cn("min-w-0 rounded-xl border border-border/60 bg-card/40 flex flex-col", className)}>
|
children,
|
||||||
{header && <div className="border-b border-border/60 px-4 py-3">{header}</div>}
|
header,
|
||||||
<div className="flex-1 divide-y divide-border/60 overflow-y-auto">{children}</div>
|
footer,
|
||||||
{footer && <div className="border-t border-border/60 px-4 py-2">{footer}</div>}
|
className,
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 rounded-xl border border-border/60 bg-card/40 flex flex-col",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{header && (
|
||||||
|
<div className="border-b border-border/60 px-4 py-3">{header}</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 divide-y divide-border/60 overflow-y-auto">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{footer && (
|
||||||
|
<div className="border-t border-border/60 px-4 py-2">{footer}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -196,14 +241,19 @@ interface ListItemProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ListItem: React.FC<ListItemProps> = ({ selected, onClick, children, className }) => (
|
export const ListItem: React.FC<ListItemProps> = ({
|
||||||
|
selected,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-start gap-4 px-4 py-3 text-left transition-colors",
|
"flex w-full items-start gap-4 px-4 py-3 text-left transition-colors",
|
||||||
selected ? "bg-muted/40" : "hover:bg-muted/30",
|
selected ? "bg-muted/40" : "hover:bg-muted/30",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
>
|
>
|
||||||
@@ -221,12 +271,16 @@ interface DetailPanelProps {
|
|||||||
sticky?: boolean;
|
sticky?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DetailPanel: React.FC<DetailPanelProps> = ({ children, className, sticky = true }) => (
|
export const DetailPanel: React.FC<DetailPanelProps> = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
sticky = true,
|
||||||
|
}) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-0 rounded-xl border border-border/60 bg-card/40 p-4",
|
"min-w-0 rounded-xl border border-border/60 bg-card/40 p-4",
|
||||||
sticky && "lg:sticky lg:top-24 lg:self-start",
|
sticky && "lg:sticky lg:top-24 lg:self-start",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -244,11 +298,18 @@ interface EmptyStateProps {
|
|||||||
action?: React.ReactNode;
|
action?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EmptyState: React.FC<EmptyStateProps> = ({ icon: Icon, title, description, action }) => (
|
export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
}) => (
|
||||||
<div className="flex flex-col items-center justify-center gap-2 px-6 py-12 text-center">
|
<div className="flex flex-col items-center justify-center gap-2 px-6 py-12 text-center">
|
||||||
{Icon && <Icon className="h-10 w-10 text-muted-foreground/50 mb-2" />}
|
{Icon && <Icon className="h-10 w-10 text-muted-foreground/50 mb-2" />}
|
||||||
<div className="text-base font-semibold">{title}</div>
|
<div className="text-base font-semibold">{title}</div>
|
||||||
{description && <p className="max-w-md text-sm text-muted-foreground">{description}</p>}
|
{description && (
|
||||||
|
<p className="max-w-md text-sm text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
{action && <div className="mt-2">{action}</div>}
|
{action && <div className="mt-2">{action}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -269,7 +330,10 @@ const getScoreTokens = (score: number) => {
|
|||||||
return { bar: "bg-rose-500/80" };
|
return { bar: "bg-rose-500/80" };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ScoreMeter: React.FC<ScoreMeterProps> = ({ score, showLabel = true }) => {
|
export const ScoreMeter: React.FC<ScoreMeterProps> = ({
|
||||||
|
score,
|
||||||
|
showLabel = true,
|
||||||
|
}) => {
|
||||||
if (score == null) {
|
if (score == null) {
|
||||||
return <span className="text-xs text-muted-foreground">Not scored</span>;
|
return <span className="text-xs text-muted-foreground">Not scored</span>;
|
||||||
}
|
}
|
||||||
@@ -283,7 +347,9 @@ export const ScoreMeter: React.FC<ScoreMeterProps> = ({ score, showLabel = true
|
|||||||
style={{ width: `${Math.max(4, Math.min(100, score))}%` }}
|
style={{ width: `${Math.max(4, Math.min(100, score))}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{showLabel && <span className="tabular-nums text-foreground">{score}%</span>}
|
{showLabel && (
|
||||||
|
<span className="tabular-nums text-foreground">{score}%</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -304,7 +370,14 @@ export const FullHeightSplit: React.FC<FullHeightSplitProps> = ({
|
|||||||
children,
|
children,
|
||||||
}) => (
|
}) => (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden lg:flex-row">
|
<div className="flex flex-1 flex-col overflow-hidden lg:flex-row">
|
||||||
<div className={cn("flex w-full flex-col border-b lg:border-b-0 lg:border-r", sidebarWidth)}>{sidebar}</div>
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex w-full flex-col border-b lg:border-b-0 lg:border-r",
|
||||||
|
sidebarWidth,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{sidebar}
|
||||||
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">{children}</div>
|
<div className="flex-1 overflow-y-auto">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -318,8 +391,16 @@ interface SectionCardProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SectionCard: React.FC<SectionCardProps> = ({ children, className }) => (
|
export const SectionCard: React.FC<SectionCardProps> = ({
|
||||||
<section className={cn("rounded-xl border border-border/60 bg-card/40 p-4", className)}>
|
children,
|
||||||
|
className,
|
||||||
|
}) => (
|
||||||
|
<section
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border border-border/60 bg-card/40 p-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -334,7 +415,12 @@ interface PageMainProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PageMain: React.FC<PageMainProps> = ({ children, className }) => (
|
export const PageMain: React.FC<PageMainProps> = ({ children, className }) => (
|
||||||
<main className={cn("container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12", className)}>
|
<main
|
||||||
|
className={cn(
|
||||||
|
"container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from "react";
|
||||||
import * as api from '../api';
|
import type { ResumeProfile } from "../../shared/types";
|
||||||
import type { ResumeProfile } from '../../shared/types';
|
import * as api from "../api";
|
||||||
|
|
||||||
let profileCache: ResumeProfile | null = null;
|
let profileCache: ResumeProfile | null = null;
|
||||||
let profileError: Error | null = null;
|
let profileError: Error | null = null;
|
||||||
let subscribers: Set<(profile: ResumeProfile | null, error: Error | null) => void> = new Set();
|
const subscribers: Set<
|
||||||
|
(profile: ResumeProfile | null, error: Error | null) => void
|
||||||
|
> = new Set();
|
||||||
let isFetching = false;
|
let isFetching = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,80 +14,84 @@ let isFetching = false;
|
|||||||
* Caches the result to avoid re-fetching.
|
* Caches the result to avoid re-fetching.
|
||||||
*/
|
*/
|
||||||
export function useProfile() {
|
export function useProfile() {
|
||||||
const [profile, setProfile] = useState<ResumeProfile | null>(profileCache);
|
const [profile, setProfile] = useState<ResumeProfile | null>(profileCache);
|
||||||
const [error, setError] = useState<Error | null>(profileError);
|
const [error, setError] = useState<Error | null>(profileError);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (profileCache) {
|
if (profileCache) {
|
||||||
setProfile(profileCache);
|
setProfile(profileCache);
|
||||||
}
|
}
|
||||||
if (profileError) {
|
if (profileError) {
|
||||||
setError(profileError);
|
setError(profileError);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdate = (newProfile: ResumeProfile | null, newError: Error | null) => {
|
const handleUpdate = (
|
||||||
setProfile(newProfile);
|
newProfile: ResumeProfile | null,
|
||||||
setError(newError);
|
newError: Error | null,
|
||||||
};
|
) => {
|
||||||
|
setProfile(newProfile);
|
||||||
subscribers.add(handleUpdate);
|
setError(newError);
|
||||||
|
|
||||||
if (!profileCache && !isFetching) {
|
|
||||||
isFetching = true;
|
|
||||||
profileError = null;
|
|
||||||
api.getProfile()
|
|
||||||
.then((data) => {
|
|
||||||
profileCache = data;
|
|
||||||
profileError = null;
|
|
||||||
subscribers.forEach(sub => sub(data, null));
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
profileError = err instanceof Error ? err : new Error(String(err));
|
|
||||||
subscribers.forEach(sub => sub(profileCache, profileError));
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
isFetching = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
subscribers.delete(handleUpdate);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const refreshProfile = async () => {
|
|
||||||
isFetching = true;
|
|
||||||
profileError = null;
|
|
||||||
subscribers.forEach(sub => sub(profileCache, null));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await api.getProfile();
|
|
||||||
profileCache = data;
|
|
||||||
profileError = null;
|
|
||||||
subscribers.forEach(sub => sub(data, null));
|
|
||||||
return data;
|
|
||||||
} catch (err) {
|
|
||||||
profileError = err instanceof Error ? err : new Error(String(err));
|
|
||||||
subscribers.forEach(sub => sub(profileCache, profileError));
|
|
||||||
throw profileError;
|
|
||||||
} finally {
|
|
||||||
isFetching = false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
subscribers.add(handleUpdate);
|
||||||
profile,
|
|
||||||
error,
|
if (!profileCache && !isFetching) {
|
||||||
isLoading: !profile && isFetching && !error,
|
isFetching = true;
|
||||||
personName: profile?.basics?.name || 'Resume',
|
profileError = null;
|
||||||
refreshProfile,
|
api
|
||||||
|
.getProfile()
|
||||||
|
.then((data) => {
|
||||||
|
profileCache = data;
|
||||||
|
profileError = null;
|
||||||
|
subscribers.forEach((sub) => sub(data, null));
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
profileError = err instanceof Error ? err : new Error(String(err));
|
||||||
|
subscribers.forEach((sub) => sub(profileCache, profileError));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
isFetching = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscribers.delete(handleUpdate);
|
||||||
};
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshProfile = async () => {
|
||||||
|
isFetching = true;
|
||||||
|
profileError = null;
|
||||||
|
subscribers.forEach((sub) => sub(profileCache, null));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.getProfile();
|
||||||
|
profileCache = data;
|
||||||
|
profileError = null;
|
||||||
|
subscribers.forEach((sub) => sub(data, null));
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
profileError = err instanceof Error ? err : new Error(String(err));
|
||||||
|
subscribers.forEach((sub) => sub(profileCache, profileError));
|
||||||
|
throw profileError;
|
||||||
|
} finally {
|
||||||
|
isFetching = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile,
|
||||||
|
error,
|
||||||
|
isLoading: !profile && isFetching && !error,
|
||||||
|
personName: profile?.basics?.name || "Resume",
|
||||||
|
refreshProfile,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal For testing only */
|
/** @internal For testing only */
|
||||||
export function _resetProfileCache() {
|
export function _resetProfileCache() {
|
||||||
profileCache = null;
|
profileCache = null;
|
||||||
profileError = null;
|
profileError = null;
|
||||||
isFetching = false;
|
isFetching = false;
|
||||||
subscribers.clear();
|
subscribers.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { act, renderHook } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import { useRescoreJob } from "./useRescoreJob";
|
|
||||||
import * as api from "../api";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import * as api from "../api";
|
||||||
|
import { useRescoreJob } from "./useRescoreJob";
|
||||||
|
|
||||||
vi.mock("../api", () => ({
|
vi.mock("../api", () => ({
|
||||||
rescoreJob: vi.fn(),
|
rescoreJob: vi.fn(),
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ export function useRescoreJob(onJobUpdated: () => void | Promise<void>) {
|
|||||||
toast.success("Match recalculated");
|
toast.success("Match recalculated");
|
||||||
await onJobUpdated();
|
await onJobUpdated();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to recalculate match";
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to recalculate match";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsRescoring(false);
|
setIsRescoring(false);
|
||||||
|
|||||||
@@ -1,80 +1,80 @@
|
|||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useSettings, _resetSettingsCache } from './useSettings';
|
import * as api from "../api";
|
||||||
import * as api from '../api';
|
import { _resetSettingsCache, useSettings } from "./useSettings";
|
||||||
|
|
||||||
vi.mock('../api', () => ({
|
vi.mock("../api", () => ({
|
||||||
getSettings: vi.fn(),
|
getSettings: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('useSettings', () => {
|
describe("useSettings", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
_resetSettingsCache();
|
_resetSettingsCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches settings on mount if not already cached", async () => {
|
||||||
|
const mockSettings = { showSponsorInfo: false };
|
||||||
|
(api.getSettings as any).mockResolvedValue(mockSettings);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSettings());
|
||||||
|
|
||||||
|
// Should start in loading state
|
||||||
|
expect(result.current.settings).toBeNull();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.settings).toEqual(mockSettings);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fetches settings on mount if not already cached', async () => {
|
expect(result.current.showSponsorInfo).toBe(false);
|
||||||
const mockSettings = { showSponsorInfo: false };
|
expect(api.getSettings).toHaveBeenCalledTimes(1);
|
||||||
(api.getSettings as any).mockResolvedValue(mockSettings);
|
});
|
||||||
|
|
||||||
const { result } = renderHook(() => useSettings());
|
it("uses default values when settings are null", async () => {
|
||||||
|
(api.getSettings as any).mockResolvedValue(null);
|
||||||
|
|
||||||
// Should start in loading state
|
const { result } = renderHook(() => useSettings());
|
||||||
expect(result.current.settings).toBeNull();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.settings).toEqual(mockSettings);
|
// settings is null, so showSponsorInfo should default to true
|
||||||
});
|
expect(result.current.showSponsorInfo).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
expect(result.current.showSponsorInfo).toBe(false);
|
it("provides a refresh function that updates settings", async () => {
|
||||||
expect(api.getSettings).toHaveBeenCalledTimes(1);
|
const initialSettings = { showSponsorInfo: true };
|
||||||
|
const updatedSettings = { showSponsorInfo: false };
|
||||||
|
|
||||||
|
(api.getSettings as any).mockResolvedValueOnce(initialSettings);
|
||||||
|
(api.getSettings as any).mockResolvedValueOnce(updatedSettings);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSettings());
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.settings).toEqual(initialSettings);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses default values when settings are null', async () => {
|
let refreshed;
|
||||||
(api.getSettings as any).mockResolvedValue(null);
|
await waitFor(async () => {
|
||||||
|
refreshed = await result.current.refreshSettings();
|
||||||
const { result } = renderHook(() => useSettings());
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
// settings is null, so showSponsorInfo should default to true
|
|
||||||
expect(result.current.showSponsorInfo).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('provides a refresh function that updates settings', async () => {
|
expect(refreshed).toEqual(updatedSettings);
|
||||||
const initialSettings = { showSponsorInfo: true };
|
expect(result.current.settings).toEqual(updatedSettings);
|
||||||
const updatedSettings = { showSponsorInfo: false };
|
expect(result.current.showSponsorInfo).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
(api.getSettings as any).mockResolvedValueOnce(initialSettings);
|
it("handles errors when fetching settings", async () => {
|
||||||
(api.getSettings as any).mockResolvedValueOnce(updatedSettings);
|
const mockError = new Error("Failed to fetch");
|
||||||
|
(api.getSettings as any).mockRejectedValue(mockError);
|
||||||
|
|
||||||
const { result } = renderHook(() => useSettings());
|
const { result } = renderHook(() => useSettings());
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.settings).toEqual(initialSettings);
|
expect(result.current.error).toEqual(mockError);
|
||||||
});
|
|
||||||
|
|
||||||
let refreshed;
|
|
||||||
await waitFor(async () => {
|
|
||||||
refreshed = await result.current.refreshSettings();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(refreshed).toEqual(updatedSettings);
|
|
||||||
expect(result.current.settings).toEqual(updatedSettings);
|
|
||||||
expect(result.current.showSponsorInfo).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles errors when fetching settings', async () => {
|
expect(result.current.isLoading).toBe(false);
|
||||||
const mockError = new Error('Failed to fetch');
|
expect(result.current.settings).toBeNull();
|
||||||
(api.getSettings as any).mockRejectedValue(mockError);
|
});
|
||||||
|
|
||||||
const { result } = renderHook(() => useSettings());
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.error).toEqual(mockError);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.isLoading).toBe(false);
|
|
||||||
expect(result.current.settings).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,87 +1,93 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from "react";
|
||||||
import type { AppSettings } from '../../shared/types';
|
import type { AppSettings } from "../../shared/types";
|
||||||
import * as api from '../api';
|
import * as api from "../api";
|
||||||
|
|
||||||
let settingsCache: AppSettings | null = null;
|
let settingsCache: AppSettings | null = null;
|
||||||
let settingsError: Error | null = null;
|
let settingsError: Error | null = null;
|
||||||
let subscribers: Set<(settings: AppSettings | null, error: Error | null) => void> = new Set();
|
const subscribers: Set<
|
||||||
|
(settings: AppSettings | null, error: Error | null) => void
|
||||||
|
> = new Set();
|
||||||
let isFetching = false;
|
let isFetching = false;
|
||||||
|
|
||||||
export function useSettings() {
|
export function useSettings() {
|
||||||
const [settings, setSettings] = useState<AppSettings | null>(settingsCache);
|
const [settings, setSettings] = useState<AppSettings | null>(settingsCache);
|
||||||
const [error, setError] = useState<Error | null>(settingsError);
|
const [error, setError] = useState<Error | null>(settingsError);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settingsCache) {
|
if (settingsCache) {
|
||||||
setSettings(settingsCache);
|
setSettings(settingsCache);
|
||||||
}
|
}
|
||||||
if (settingsError) {
|
if (settingsError) {
|
||||||
setError(settingsError);
|
setError(settingsError);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdate = (newSettings: AppSettings | null, newError: Error | null) => {
|
const handleUpdate = (
|
||||||
setSettings(newSettings);
|
newSettings: AppSettings | null,
|
||||||
setError(newError);
|
newError: Error | null,
|
||||||
};
|
) => {
|
||||||
|
setSettings(newSettings);
|
||||||
subscribers.add(handleUpdate);
|
setError(newError);
|
||||||
|
|
||||||
if (!settingsCache && !isFetching) {
|
|
||||||
isFetching = true;
|
|
||||||
settingsError = null;
|
|
||||||
api.getSettings()
|
|
||||||
.then((data) => {
|
|
||||||
settingsCache = data;
|
|
||||||
settingsError = null;
|
|
||||||
subscribers.forEach(sub => sub(data, null));
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
settingsError = err instanceof Error ? err : new Error(String(err));
|
|
||||||
subscribers.forEach(sub => sub(settingsCache, settingsError));
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
isFetching = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
subscribers.delete(handleUpdate);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const refreshSettings = async () => {
|
|
||||||
isFetching = true;
|
|
||||||
settingsError = null;
|
|
||||||
subscribers.forEach(sub => sub(settingsCache, null));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await api.getSettings();
|
|
||||||
settingsCache = data;
|
|
||||||
settingsError = null;
|
|
||||||
subscribers.forEach(sub => sub(data, null));
|
|
||||||
return data;
|
|
||||||
} catch (err) {
|
|
||||||
settingsError = err instanceof Error ? err : new Error(String(err));
|
|
||||||
subscribers.forEach(sub => sub(settingsCache, settingsError));
|
|
||||||
throw settingsError;
|
|
||||||
} finally {
|
|
||||||
isFetching = false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
subscribers.add(handleUpdate);
|
||||||
settings,
|
|
||||||
error,
|
if (!settingsCache && !isFetching) {
|
||||||
isLoading: !settings && isFetching && !error,
|
isFetching = true;
|
||||||
showSponsorInfo: settings?.showSponsorInfo ?? true,
|
settingsError = null;
|
||||||
refreshSettings,
|
api
|
||||||
|
.getSettings()
|
||||||
|
.then((data) => {
|
||||||
|
settingsCache = data;
|
||||||
|
settingsError = null;
|
||||||
|
subscribers.forEach((sub) => sub(data, null));
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
settingsError = err instanceof Error ? err : new Error(String(err));
|
||||||
|
subscribers.forEach((sub) => sub(settingsCache, settingsError));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
isFetching = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscribers.delete(handleUpdate);
|
||||||
};
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshSettings = async () => {
|
||||||
|
isFetching = true;
|
||||||
|
settingsError = null;
|
||||||
|
subscribers.forEach((sub) => sub(settingsCache, null));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.getSettings();
|
||||||
|
settingsCache = data;
|
||||||
|
settingsError = null;
|
||||||
|
subscribers.forEach((sub) => sub(data, null));
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
settingsError = err instanceof Error ? err : new Error(String(err));
|
||||||
|
subscribers.forEach((sub) => sub(settingsCache, settingsError));
|
||||||
|
throw settingsError;
|
||||||
|
} finally {
|
||||||
|
isFetching = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings,
|
||||||
|
error,
|
||||||
|
isLoading: !settings && isFetching && !error,
|
||||||
|
showSponsorInfo: settings?.showSponsorInfo ?? true,
|
||||||
|
refreshSettings,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal For testing only */
|
/** @internal For testing only */
|
||||||
export function _resetSettingsCache() {
|
export function _resetSettingsCache() {
|
||||||
settingsCache = null;
|
settingsCache = null;
|
||||||
settingsError = null;
|
settingsError = null;
|
||||||
isFetching = false;
|
isFetching = false;
|
||||||
subscribers.clear();
|
subscribers.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from "react-dom/client";
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { App } from './App';
|
import { App } from "./App";
|
||||||
import '../index.css';
|
import "../index.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { OrchestratorPage } from "./OrchestratorPage";
|
|
||||||
import type { Job } from "../../shared/types";
|
import type { Job } from "../../shared/types";
|
||||||
|
import { OrchestratorPage } from "./OrchestratorPage";
|
||||||
import type { FilterTab } from "./orchestrator/constants";
|
import type { FilterTab } from "./orchestrator/constants";
|
||||||
|
|
||||||
const jobFixture: Job = {
|
const jobFixture: Job = {
|
||||||
@@ -138,8 +137,12 @@ vi.mock("./orchestrator/OrchestratorFilters", () => ({
|
|||||||
<div data-testid="filters">
|
<div data-testid="filters">
|
||||||
<div data-testid="sources-with-jobs">{sourcesWithJobs.join(",")}</div>
|
<div data-testid="sources-with-jobs">{sourcesWithJobs.join(",")}</div>
|
||||||
<button onClick={() => onTabChange("discovered")}>To Discovered</button>
|
<button onClick={() => onTabChange("discovered")}>To Discovered</button>
|
||||||
<button onClick={() => onSearchQueryChange("test search")}>Set Search</button>
|
<button onClick={() => onSearchQueryChange("test search")}>
|
||||||
<button onClick={() => onSortChange({ key: "title", direction: "asc" })}>Set Sort</button>
|
Set Search
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onSortChange({ key: "title", direction: "asc" })}>
|
||||||
|
Set Sort
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
@@ -149,13 +152,27 @@ vi.mock("./orchestrator/JobDetailPanel", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./orchestrator/JobListPanel", () => ({
|
vi.mock("./orchestrator/JobListPanel", () => ({
|
||||||
JobListPanel: ({ onSelectJob, selectedJobId }: { onSelectJob: (id: string) => void; selectedJobId: string | null }) => (
|
JobListPanel: ({
|
||||||
|
onSelectJob,
|
||||||
|
selectedJobId,
|
||||||
|
}: {
|
||||||
|
onSelectJob: (id: string) => void;
|
||||||
|
selectedJobId: string | null;
|
||||||
|
}) => (
|
||||||
<div>
|
<div>
|
||||||
<div data-testid="selected-job">{selectedJobId ?? "none"}</div>
|
<div data-testid="selected-job">{selectedJobId ?? "none"}</div>
|
||||||
<button data-testid="select-job-1" type="button" onClick={() => onSelectJob("job-1")}>
|
<button
|
||||||
|
data-testid="select-job-1"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectJob("job-1")}
|
||||||
|
>
|
||||||
Select job 1
|
Select job 1
|
||||||
</button>
|
</button>
|
||||||
<button data-testid="select-job-2" type="button" onClick={() => onSelectJob("job-2")}>
|
<button
|
||||||
|
data-testid="select-job-2"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectJob("job-2")}
|
||||||
|
>
|
||||||
Select job 2
|
Select job 2
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,7 +185,9 @@ vi.mock("../components", () => ({
|
|||||||
|
|
||||||
const LocationWatcher = () => {
|
const LocationWatcher = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
return <div data-testid="location">{location.pathname + location.search}</div>;
|
return (
|
||||||
|
<div data-testid="location">{location.pathname + location.search}</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("OrchestratorPage", () => {
|
describe("OrchestratorPage", () => {
|
||||||
@@ -177,7 +196,9 @@ describe("OrchestratorPage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("syncs tab selection to the URL", () => {
|
it("syncs tab selection to the URL", () => {
|
||||||
window.matchMedia = createMatchMedia(true) as unknown as typeof window.matchMedia;
|
window.matchMedia = createMatchMedia(
|
||||||
|
true,
|
||||||
|
) as unknown as typeof window.matchMedia;
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={["/ready"]}>
|
<MemoryRouter initialEntries={["/ready"]}>
|
||||||
@@ -186,7 +207,7 @@ describe("OrchestratorPage", () => {
|
|||||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</MemoryRouter>
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("To Discovered"));
|
fireEvent.click(screen.getByText("To Discovered"));
|
||||||
@@ -194,7 +215,9 @@ describe("OrchestratorPage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("syncs job selection to the URL", async () => {
|
it("syncs job selection to the URL", async () => {
|
||||||
window.matchMedia = createMatchMedia(true) as unknown as typeof window.matchMedia;
|
window.matchMedia = createMatchMedia(
|
||||||
|
true,
|
||||||
|
) as unknown as typeof window.matchMedia;
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={["/all"]}>
|
<MemoryRouter initialEntries={["/all"]}>
|
||||||
@@ -203,7 +226,7 @@ describe("OrchestratorPage", () => {
|
|||||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</MemoryRouter>
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initial load will auto-select the first matching job (job-1 for all tab)
|
// Initial load will auto-select the first matching job (job-1 for all tab)
|
||||||
@@ -221,7 +244,9 @@ describe("OrchestratorPage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("syncs search query to URL as a parameter", () => {
|
it("syncs search query to URL as a parameter", () => {
|
||||||
window.matchMedia = createMatchMedia(true) as unknown as typeof window.matchMedia;
|
window.matchMedia = createMatchMedia(
|
||||||
|
true,
|
||||||
|
) as unknown as typeof window.matchMedia;
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={["/ready"]}>
|
<MemoryRouter initialEntries={["/ready"]}>
|
||||||
@@ -230,15 +255,19 @@ describe("OrchestratorPage", () => {
|
|||||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</MemoryRouter>
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Set Search"));
|
fireEvent.click(screen.getByText("Set Search"));
|
||||||
expect(screen.getByTestId("location").textContent).toContain("q=test+search");
|
expect(screen.getByTestId("location").textContent).toContain(
|
||||||
|
"q=test+search",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("syncs sorting to URL and removes it when default", () => {
|
it("syncs sorting to URL and removes it when default", () => {
|
||||||
window.matchMedia = createMatchMedia(true) as unknown as typeof window.matchMedia;
|
window.matchMedia = createMatchMedia(
|
||||||
|
true,
|
||||||
|
) as unknown as typeof window.matchMedia;
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={["/ready"]}>
|
<MemoryRouter initialEntries={["/ready"]}>
|
||||||
@@ -247,15 +276,19 @@ describe("OrchestratorPage", () => {
|
|||||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</MemoryRouter>
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Set Sort"));
|
fireEvent.click(screen.getByText("Set Sort"));
|
||||||
expect(screen.getByTestId("location").textContent).toContain("sort=title-asc");
|
expect(screen.getByTestId("location").textContent).toContain(
|
||||||
|
"sort=title-asc",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens the detail drawer on mobile when a job is selected", () => {
|
it("opens the detail drawer on mobile when a job is selected", () => {
|
||||||
window.matchMedia = createMatchMedia(false) as unknown as typeof window.matchMedia;
|
window.matchMedia = createMatchMedia(
|
||||||
|
false,
|
||||||
|
) as unknown as typeof window.matchMedia;
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={["/ready"]}>
|
<MemoryRouter initialEntries={["/ready"]}>
|
||||||
@@ -263,7 +296,7 @@ describe("OrchestratorPage", () => {
|
|||||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</MemoryRouter>
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.queryByTestId("detail-panel")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("detail-panel")).not.toBeInTheDocument();
|
||||||
@@ -274,7 +307,9 @@ describe("OrchestratorPage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders the detail panel inline on desktop", () => {
|
it("renders the detail panel inline on desktop", () => {
|
||||||
window.matchMedia = createMatchMedia(true) as unknown as typeof window.matchMedia;
|
window.matchMedia = createMatchMedia(
|
||||||
|
true,
|
||||||
|
) as unknown as typeof window.matchMedia;
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={["/ready"]}>
|
<MemoryRouter initialEntries={["/ready"]}>
|
||||||
@@ -282,14 +317,16 @@ describe("OrchestratorPage", () => {
|
|||||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||||
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</MemoryRouter>
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId("detail-panel")).toBeInTheDocument();
|
expect(screen.getByTestId("detail-panel")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears source filter when no jobs exist for it", async () => {
|
it("clears source filter when no jobs exist for it", async () => {
|
||||||
window.matchMedia = createMatchMedia(true) as unknown as typeof window.matchMedia;
|
window.matchMedia = createMatchMedia(
|
||||||
|
true,
|
||||||
|
) as unknown as typeof window.matchMedia;
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={["/ready?source=ukvisajobs"]}>
|
<MemoryRouter initialEntries={["/ready?source=ukvisajobs"]}>
|
||||||
@@ -297,11 +334,13 @@ describe("OrchestratorPage", () => {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/:tab" element={<OrchestratorPage />} />
|
<Route path="/:tab" element={<OrchestratorPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</MemoryRouter>
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId("location").textContent).not.toContain("source=ukvisajobs");
|
expect(screen.getByTestId("location").textContent).not.toContain(
|
||||||
|
"source=ukvisajobs",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,18 +2,18 @@
|
|||||||
* Orchestrator layout with a split list/detail experience.
|
* Orchestrator layout with a split list/detail experience.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import { useSettings } from "@client/hooks/useSettings";
|
||||||
import { useParams, useNavigate, useSearchParams } from "react-router-dom";
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
|
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
|
||||||
|
|
||||||
import { ManualImportSheet } from "../components";
|
|
||||||
import * as api from "../api";
|
|
||||||
import type { JobSource } from "../../shared/types";
|
import type { JobSource } from "../../shared/types";
|
||||||
import { DEFAULT_SORT } from "./orchestrator/constants";
|
import * as api from "../api";
|
||||||
|
import { ManualImportSheet } from "../components";
|
||||||
import type { FilterTab, JobSort } from "./orchestrator/constants";
|
import type { FilterTab, JobSort } from "./orchestrator/constants";
|
||||||
|
import { DEFAULT_SORT } from "./orchestrator/constants";
|
||||||
import { JobDetailPanel } from "./orchestrator/JobDetailPanel";
|
import { JobDetailPanel } from "./orchestrator/JobDetailPanel";
|
||||||
import { JobListPanel } from "./orchestrator/JobListPanel";
|
import { JobListPanel } from "./orchestrator/JobListPanel";
|
||||||
import { OrchestratorFilters } from "./orchestrator/OrchestratorFilters";
|
import { OrchestratorFilters } from "./orchestrator/OrchestratorFilters";
|
||||||
@@ -22,8 +22,11 @@ import { OrchestratorSummary } from "./orchestrator/OrchestratorSummary";
|
|||||||
import { useFilteredJobs } from "./orchestrator/useFilteredJobs";
|
import { useFilteredJobs } from "./orchestrator/useFilteredJobs";
|
||||||
import { useOrchestratorData } from "./orchestrator/useOrchestratorData";
|
import { useOrchestratorData } from "./orchestrator/useOrchestratorData";
|
||||||
import { usePipelineSources } from "./orchestrator/usePipelineSources";
|
import { usePipelineSources } from "./orchestrator/usePipelineSources";
|
||||||
import { useSettings } from "@client/hooks/useSettings";
|
import {
|
||||||
import { getEnabledSources, getJobCounts, getSourcesWithJobs } from "./orchestrator/utils";
|
getEnabledSources,
|
||||||
|
getJobCounts,
|
||||||
|
getSourcesWithJobs,
|
||||||
|
} from "./orchestrator/utils";
|
||||||
|
|
||||||
export const OrchestratorPage: React.FC = () => {
|
export const OrchestratorPage: React.FC = () => {
|
||||||
const { tab, jobId } = useParams<{ tab: string; jobId?: string }>();
|
const { tab, jobId } = useParams<{ tab: string; jobId?: string }>();
|
||||||
@@ -43,7 +46,9 @@ export const OrchestratorPage: React.FC = () => {
|
|||||||
(newTab: string, newJobId?: string | null, isReplace = false) => {
|
(newTab: string, newJobId?: string | null, isReplace = false) => {
|
||||||
const search = searchParams.toString();
|
const search = searchParams.toString();
|
||||||
const suffix = search ? `?${search}` : "";
|
const suffix = search ? `?${search}` : "";
|
||||||
const path = newJobId ? `/${newTab}/${newJobId}${suffix}` : `/${newTab}${suffix}`;
|
const path = newJobId
|
||||||
|
? `/${newTab}/${newJobId}${suffix}`
|
||||||
|
: `/${newTab}${suffix}`;
|
||||||
navigate(path, { replace: isReplace });
|
navigate(path, { replace: isReplace });
|
||||||
},
|
},
|
||||||
[navigate, searchParams],
|
[navigate, searchParams],
|
||||||
@@ -65,7 +70,8 @@ export const OrchestratorPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Sync sourceFilter with URL
|
// Sync sourceFilter with URL
|
||||||
const sourceFilter = (searchParams.get("source") as JobSource | "all") || "all";
|
const sourceFilter =
|
||||||
|
(searchParams.get("source") as JobSource | "all") || "all";
|
||||||
const setSourceFilter = (source: JobSource | "all") => {
|
const setSourceFilter = (source: JobSource | "all") => {
|
||||||
setSearchParams(
|
setSearchParams(
|
||||||
(prev) => {
|
(prev) => {
|
||||||
@@ -88,7 +94,10 @@ export const OrchestratorPage: React.FC = () => {
|
|||||||
const setSort = (newSort: JobSort) => {
|
const setSort = (newSort: JobSort) => {
|
||||||
setSearchParams(
|
setSearchParams(
|
||||||
(prev) => {
|
(prev) => {
|
||||||
if (newSort.key === DEFAULT_SORT.key && newSort.direction === DEFAULT_SORT.direction) {
|
if (
|
||||||
|
newSort.key === DEFAULT_SORT.key &&
|
||||||
|
newSort.direction === DEFAULT_SORT.direction
|
||||||
|
) {
|
||||||
prev.delete("sort");
|
prev.delete("sort");
|
||||||
} else {
|
} else {
|
||||||
prev.set("sort", `${newSort.key}-${newSort.direction}`);
|
prev.set("sort", `${newSort.key}-${newSort.direction}`);
|
||||||
@@ -107,12 +116,13 @@ export const OrchestratorPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [tab, navigateWithContext]);
|
}, [tab, navigateWithContext]);
|
||||||
|
|
||||||
|
|
||||||
const [navOpen, setNavOpen] = useState(false);
|
const [navOpen, setNavOpen] = useState(false);
|
||||||
const [isManualImportOpen, setIsManualImportOpen] = useState(false);
|
const [isManualImportOpen, setIsManualImportOpen] = useState(false);
|
||||||
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
|
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
|
||||||
const [isDesktop, setIsDesktop] = useState(
|
const [isDesktop, setIsDesktop] = useState(() =>
|
||||||
() => (typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false),
|
typeof window !== "undefined"
|
||||||
|
? window.matchMedia("(min-width: 1024px)").matches
|
||||||
|
: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
const setActiveTab = (newTab: FilterTab) => {
|
const setActiveTab = (newTab: FilterTab) => {
|
||||||
@@ -124,15 +134,35 @@ export const OrchestratorPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { jobs, stats, isLoading, isPipelineRunning, setIsPipelineRunning, loadJobs } = useOrchestratorData();
|
const {
|
||||||
const enabledSources = useMemo(() => getEnabledSources(settings ?? null), [settings]);
|
jobs,
|
||||||
const { pipelineSources, setPipelineSources, toggleSource } = usePipelineSources(enabledSources);
|
stats,
|
||||||
|
isLoading,
|
||||||
|
isPipelineRunning,
|
||||||
|
setIsPipelineRunning,
|
||||||
|
loadJobs,
|
||||||
|
} = useOrchestratorData();
|
||||||
|
const enabledSources = useMemo(
|
||||||
|
() => getEnabledSources(settings ?? null),
|
||||||
|
[settings],
|
||||||
|
);
|
||||||
|
const { pipelineSources, setPipelineSources, toggleSource } =
|
||||||
|
usePipelineSources(enabledSources);
|
||||||
|
|
||||||
const activeJobs = useFilteredJobs(jobs, activeTab, sourceFilter, searchQuery, sort);
|
const activeJobs = useFilteredJobs(
|
||||||
|
jobs,
|
||||||
|
activeTab,
|
||||||
|
sourceFilter,
|
||||||
|
searchQuery,
|
||||||
|
sort,
|
||||||
|
);
|
||||||
const counts = useMemo(() => getJobCounts(jobs), [jobs]);
|
const counts = useMemo(() => getJobCounts(jobs), [jobs]);
|
||||||
const sourcesWithJobs = useMemo(() => getSourcesWithJobs(jobs), [jobs]);
|
const sourcesWithJobs = useMemo(() => getSourcesWithJobs(jobs), [jobs]);
|
||||||
const selectedJob = useMemo(
|
const selectedJob = useMemo(
|
||||||
() => (selectedJobId ? jobs.find((job) => job.id === selectedJobId) ?? null : null),
|
() =>
|
||||||
|
selectedJobId
|
||||||
|
? (jobs.find((job) => job.id === selectedJobId) ?? null)
|
||||||
|
: null,
|
||||||
[jobs, selectedJobId],
|
[jobs, selectedJobId],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -175,7 +205,8 @@ export const OrchestratorPage: React.FC = () => {
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setIsPipelineRunning(false);
|
setIsPipelineRunning(false);
|
||||||
const message = error instanceof Error ? error.message : "Failed to start pipeline";
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to start pipeline";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -237,20 +268,23 @@ export const OrchestratorPage: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<OrchestratorHeader
|
<OrchestratorHeader
|
||||||
navOpen={navOpen}
|
navOpen={navOpen}
|
||||||
onNavOpenChange={setNavOpen}
|
onNavOpenChange={setNavOpen}
|
||||||
isPipelineRunning={isPipelineRunning}
|
isPipelineRunning={isPipelineRunning}
|
||||||
pipelineSources={pipelineSources}
|
pipelineSources={pipelineSources}
|
||||||
enabledSources={enabledSources}
|
enabledSources={enabledSources}
|
||||||
onToggleSource={toggleSource}
|
onToggleSource={toggleSource}
|
||||||
onSetPipelineSources={setPipelineSources}
|
onSetPipelineSources={setPipelineSources}
|
||||||
onRunPipeline={handleRunPipeline}
|
onRunPipeline={handleRunPipeline}
|
||||||
onOpenManualImport={() => setIsManualImportOpen(true)}
|
onOpenManualImport={() => setIsManualImportOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12">
|
<main className="container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12">
|
||||||
<OrchestratorSummary stats={stats} isPipelineRunning={isPipelineRunning} />
|
<OrchestratorSummary
|
||||||
|
stats={stats}
|
||||||
|
isPipelineRunning={isPipelineRunning}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Main content: tabs/filters -> list/detail */}
|
{/* Main content: tabs/filters -> list/detail */}
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
@@ -307,7 +341,9 @@ export const OrchestratorPage: React.FC = () => {
|
|||||||
<Drawer open={isDetailDrawerOpen} onOpenChange={onDrawerOpenChange}>
|
<Drawer open={isDetailDrawerOpen} onOpenChange={onDrawerOpenChange}>
|
||||||
<DrawerContent className="max-h-[90vh]">
|
<DrawerContent className="max-h-[90vh]">
|
||||||
<div className="flex items-center justify-between px-4 pt-2">
|
<div className="flex items-center justify-between px-4 pt-2">
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Job details</div>
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Job details
|
||||||
|
</div>
|
||||||
<DrawerClose asChild>
|
<DrawerClose asChild>
|
||||||
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
|
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
|
||||||
Close
|
Close
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
import type { AppSettings } from "@shared/types";
|
||||||
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"
|
import {
|
||||||
import { MemoryRouter } from "react-router-dom"
|
fireEvent,
|
||||||
|
render,
|
||||||
import { SettingsPage } from "./SettingsPage"
|
screen,
|
||||||
import * as api from "../api"
|
waitFor,
|
||||||
import { toast } from "sonner"
|
within,
|
||||||
import type { AppSettings } from "@shared/types"
|
} from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import * as api from "../api";
|
||||||
|
import { SettingsPage } from "./SettingsPage";
|
||||||
|
|
||||||
vi.mock("../api", () => ({
|
vi.mock("../api", () => ({
|
||||||
getSettings: vi.fn(),
|
getSettings: vi.fn(),
|
||||||
updateSettings: vi.fn(),
|
updateSettings: vi.fn(),
|
||||||
clearDatabase: vi.fn(),
|
clearDatabase: vi.fn(),
|
||||||
deleteJobsByStatus: vi.fn(),
|
deleteJobsByStatus: vi.fn(),
|
||||||
}))
|
}));
|
||||||
|
|
||||||
vi.mock("sonner", () => ({
|
vi.mock("sonner", () => ({
|
||||||
toast: {
|
toast: {
|
||||||
@@ -20,7 +25,7 @@ vi.mock("sonner", () => ({
|
|||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
info: vi.fn(),
|
info: vi.fn(),
|
||||||
},
|
},
|
||||||
}))
|
}));
|
||||||
|
|
||||||
const baseSettings: AppSettings = {
|
const baseSettings: AppSettings = {
|
||||||
model: "google/gemini-3-flash-preview",
|
model: "google/gemini-3-flash-preview",
|
||||||
@@ -104,146 +109,173 @@ const baseSettings: AppSettings = {
|
|||||||
ukvisajobsPasswordHint: null,
|
ukvisajobsPasswordHint: null,
|
||||||
webhookSecretHint: null,
|
webhookSecretHint: null,
|
||||||
basicAuthActive: false,
|
basicAuthActive: false,
|
||||||
}
|
};
|
||||||
|
|
||||||
const renderPage = () => {
|
const renderPage = () => {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter initialEntries={["/settings"]}>
|
<MemoryRouter initialEntries={["/settings"]}>
|
||||||
<SettingsPage />
|
<SettingsPage />
|
||||||
</MemoryRouter>
|
</MemoryRouter>,
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
describe("SettingsPage", () => {
|
describe("SettingsPage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks();
|
||||||
})
|
});
|
||||||
|
|
||||||
it("saves trimmed model overrides", async () => {
|
it("saves trimmed model overrides", async () => {
|
||||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
|
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
||||||
vi.mocked(api.updateSettings).mockResolvedValue({
|
vi.mocked(api.updateSettings).mockResolvedValue({
|
||||||
...baseSettings,
|
...baseSettings,
|
||||||
overrideModel: "gpt-4",
|
overrideModel: "gpt-4",
|
||||||
model: "gpt-4",
|
model: "gpt-4",
|
||||||
})
|
});
|
||||||
|
|
||||||
renderPage()
|
renderPage();
|
||||||
|
|
||||||
const modelTrigger = await screen.findByRole("button", { name: /model/i })
|
const modelTrigger = await screen.findByRole("button", { name: /model/i });
|
||||||
fireEvent.click(modelTrigger)
|
fireEvent.click(modelTrigger);
|
||||||
|
|
||||||
const modelField = screen.getByText("Override model").parentElement ?? screen.getByRole("main")
|
const modelField =
|
||||||
const modelInput = within(modelField).getByRole("textbox")
|
screen.getByText("Override model").parentElement ??
|
||||||
fireEvent.change(modelInput, { target: { value: " gpt-4 " } })
|
screen.getByRole("main");
|
||||||
|
const modelInput = within(modelField).getByRole("textbox");
|
||||||
|
fireEvent.change(modelInput, { target: { value: " gpt-4 " } });
|
||||||
|
|
||||||
const saveButton = screen.getByRole("button", { name: /^save$/i })
|
const saveButton = screen.getByRole("button", { name: /^save$/i });
|
||||||
await waitFor(() => expect(saveButton).toBeEnabled())
|
await waitFor(() => expect(saveButton).toBeEnabled());
|
||||||
|
|
||||||
fireEvent.click(saveButton)
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
await waitFor(() => expect(api.updateSettings).toHaveBeenCalled())
|
await waitFor(() => expect(api.updateSettings).toHaveBeenCalled());
|
||||||
expect(api.updateSettings).toHaveBeenCalledWith(
|
expect(api.updateSettings).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
model: "gpt-4",
|
model: "gpt-4",
|
||||||
})
|
}),
|
||||||
)
|
);
|
||||||
expect(toast.success).toHaveBeenCalledWith("Settings saved")
|
expect(toast.success).toHaveBeenCalledWith("Settings saved");
|
||||||
})
|
});
|
||||||
|
|
||||||
it("shows validation error for too long model override", async () => {
|
it("shows validation error for too long model override", async () => {
|
||||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
|
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
||||||
|
|
||||||
renderPage()
|
renderPage();
|
||||||
|
|
||||||
const modelTrigger = await screen.findByRole("button", { name: /model/i })
|
const modelTrigger = await screen.findByRole("button", { name: /model/i });
|
||||||
fireEvent.click(modelTrigger)
|
fireEvent.click(modelTrigger);
|
||||||
|
|
||||||
const modelField = screen.getByText("Override model").parentElement ?? screen.getByRole("main")
|
const modelField =
|
||||||
const modelInput = within(modelField).getByRole("textbox")
|
screen.getByText("Override model").parentElement ??
|
||||||
|
screen.getByRole("main");
|
||||||
|
const modelInput = within(modelField).getByRole("textbox");
|
||||||
|
|
||||||
// Change to > 200 chars
|
// Change to > 200 chars
|
||||||
fireEvent.change(modelInput, { target: { value: "a".repeat(201) } })
|
fireEvent.change(modelInput, { target: { value: "a".repeat(201) } });
|
||||||
|
|
||||||
// Should see error message
|
// Should see error message
|
||||||
expect(await screen.findByText(/String must contain at most 200 character\(s\)/i)).toBeInTheDocument()
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
/String must contain at most 200 character\(s\)/i,
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
// Save button should be disabled due to validation error (isValid will be false)
|
// Save button should be disabled due to validation error (isValid will be false)
|
||||||
const saveButton = screen.getByRole("button", { name: /^save$/i })
|
const saveButton = screen.getByRole("button", { name: /^save$/i });
|
||||||
expect(saveButton).toBeDisabled()
|
expect(saveButton).toBeDisabled();
|
||||||
})
|
});
|
||||||
|
|
||||||
it("clears jobs by status and summarizes results", async () => {
|
it("clears jobs by status and summarizes results", async () => {
|
||||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
|
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
||||||
vi.mocked(api.deleteJobsByStatus).mockResolvedValue({ message: "", count: 2 })
|
vi.mocked(api.deleteJobsByStatus).mockResolvedValue({
|
||||||
|
message: "",
|
||||||
|
count: 2,
|
||||||
|
});
|
||||||
|
|
||||||
renderPage()
|
renderPage();
|
||||||
|
|
||||||
const dangerTrigger = await screen.findByRole("button", { name: /danger zone/i })
|
const dangerTrigger = await screen.findByRole("button", {
|
||||||
fireEvent.click(dangerTrigger)
|
name: /danger zone/i,
|
||||||
|
});
|
||||||
|
fireEvent.click(dangerTrigger);
|
||||||
|
|
||||||
const clearSelectedButton = await screen.findByRole("button", { name: /clear selected/i })
|
const clearSelectedButton = await screen.findByRole("button", {
|
||||||
fireEvent.click(clearSelectedButton)
|
name: /clear selected/i,
|
||||||
|
});
|
||||||
|
fireEvent.click(clearSelectedButton);
|
||||||
|
|
||||||
const confirmButton = await screen.findByRole("button", { name: /clear 1 status/i })
|
const confirmButton = await screen.findByRole("button", {
|
||||||
fireEvent.click(confirmButton)
|
name: /clear 1 status/i,
|
||||||
|
});
|
||||||
|
fireEvent.click(confirmButton);
|
||||||
|
|
||||||
await waitFor(() => expect(api.deleteJobsByStatus).toHaveBeenCalledWith("discovered"))
|
await waitFor(() =>
|
||||||
|
expect(api.deleteJobsByStatus).toHaveBeenCalledWith("discovered"),
|
||||||
|
);
|
||||||
expect(toast.success).toHaveBeenCalledWith(
|
expect(toast.success).toHaveBeenCalledWith(
|
||||||
"Jobs cleared",
|
"Jobs cleared",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
description: "Deleted 2 jobs: 2 discovered",
|
description: "Deleted 2 jobs: 2 discovered",
|
||||||
})
|
}),
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
|
|
||||||
it("enables save button when model is changed", async () => {
|
it("enables save button when model is changed", async () => {
|
||||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
|
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
||||||
renderPage()
|
renderPage();
|
||||||
const saveButton = screen.getByRole("button", { name: /^save$/i })
|
const saveButton = screen.getByRole("button", { name: /^save$/i });
|
||||||
expect(saveButton).toBeDisabled()
|
expect(saveButton).toBeDisabled();
|
||||||
|
|
||||||
const modelTrigger = await screen.findByRole("button", { name: /model/i })
|
const modelTrigger = await screen.findByRole("button", { name: /model/i });
|
||||||
fireEvent.click(modelTrigger)
|
fireEvent.click(modelTrigger);
|
||||||
const modelInput = screen.getByLabelText(/override model/i)
|
const modelInput = screen.getByLabelText(/override model/i);
|
||||||
fireEvent.change(modelInput, { target: { value: "new-model" } })
|
fireEvent.change(modelInput, { target: { value: "new-model" } });
|
||||||
expect(saveButton).toBeEnabled()
|
expect(saveButton).toBeEnabled();
|
||||||
})
|
});
|
||||||
|
|
||||||
it("enables save button when numeric setting is changed", async () => {
|
it("enables save button when numeric setting is changed", async () => {
|
||||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
|
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
||||||
renderPage()
|
renderPage();
|
||||||
const saveButton = screen.getByRole("button", { name: /^save$/i })
|
const saveButton = screen.getByRole("button", { name: /^save$/i });
|
||||||
|
|
||||||
const visaTrigger = await screen.findByRole("button", { name: /ukvisajobs extractor/i })
|
const visaTrigger = await screen.findByRole("button", {
|
||||||
fireEvent.click(visaTrigger)
|
name: /ukvisajobs extractor/i,
|
||||||
const maxJobsInput = screen.getByLabelText(/max jobs to fetch/i)
|
});
|
||||||
fireEvent.change(maxJobsInput, { target: { value: "100" } })
|
fireEvent.click(visaTrigger);
|
||||||
expect(saveButton).toBeEnabled()
|
const maxJobsInput = screen.getByLabelText(/max jobs to fetch/i);
|
||||||
})
|
fireEvent.change(maxJobsInput, { target: { value: "100" } });
|
||||||
|
expect(saveButton).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
it("enables save button when display setting is changed", async () => {
|
it("enables save button when display setting is changed", async () => {
|
||||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
|
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
||||||
renderPage()
|
renderPage();
|
||||||
const saveButton = screen.getByRole("button", { name: /^save$/i })
|
const saveButton = screen.getByRole("button", { name: /^save$/i });
|
||||||
|
|
||||||
const displayTrigger = await screen.findByRole("button", { name: /display settings/i })
|
const displayTrigger = await screen.findByRole("button", {
|
||||||
fireEvent.click(displayTrigger)
|
name: /display settings/i,
|
||||||
const sponsorCheckbox = screen.getByLabelText(/show visa sponsor information/i)
|
});
|
||||||
fireEvent.click(sponsorCheckbox)
|
fireEvent.click(displayTrigger);
|
||||||
expect(saveButton).toBeEnabled()
|
const sponsorCheckbox = screen.getByLabelText(
|
||||||
})
|
/show visa sponsor information/i,
|
||||||
|
);
|
||||||
|
fireEvent.click(sponsorCheckbox);
|
||||||
|
expect(saveButton).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
it("enables save button when basic auth toggle is changed", async () => {
|
it("enables save button when basic auth toggle is changed", async () => {
|
||||||
vi.mocked(api.getSettings).mockResolvedValue(baseSettings)
|
vi.mocked(api.getSettings).mockResolvedValue(baseSettings);
|
||||||
renderPage()
|
renderPage();
|
||||||
const saveButton = screen.getByRole("button", { name: /^save$/i })
|
const saveButton = screen.getByRole("button", { name: /^save$/i });
|
||||||
|
|
||||||
const envTrigger = await screen.findByRole("button", { name: /environment & accounts/i })
|
const envTrigger = await screen.findByRole("button", {
|
||||||
fireEvent.click(envTrigger)
|
name: /environment & accounts/i,
|
||||||
const authCheckbox = screen.getByLabelText(/enable basic authentication/i)
|
});
|
||||||
fireEvent.click(authCheckbox)
|
fireEvent.click(envTrigger);
|
||||||
expect(saveButton).toBeEnabled()
|
const authCheckbox = screen.getByLabelText(/enable basic authentication/i);
|
||||||
})
|
fireEvent.click(authCheckbox);
|
||||||
|
expect(saveButton).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
it("wipes basic auth credentials when toggle is disabled and saved", async () => {
|
it("wipes basic auth credentials when toggle is disabled and saved", async () => {
|
||||||
// Initial state: Basic Auth is active
|
// Initial state: Basic Auth is active
|
||||||
@@ -252,32 +284,34 @@ describe("SettingsPage", () => {
|
|||||||
basicAuthActive: true,
|
basicAuthActive: true,
|
||||||
basicAuthUser: "admin",
|
basicAuthUser: "admin",
|
||||||
basicAuthPasswordHint: "pass",
|
basicAuthPasswordHint: "pass",
|
||||||
}
|
};
|
||||||
vi.mocked(api.getSettings).mockResolvedValue(activeSettings)
|
vi.mocked(api.getSettings).mockResolvedValue(activeSettings);
|
||||||
vi.mocked(api.updateSettings).mockResolvedValue(baseSettings)
|
vi.mocked(api.updateSettings).mockResolvedValue(baseSettings);
|
||||||
|
|
||||||
renderPage()
|
renderPage();
|
||||||
|
|
||||||
const envTrigger = await screen.findByRole("button", { name: /environment & accounts/i })
|
const envTrigger = await screen.findByRole("button", {
|
||||||
fireEvent.click(envTrigger)
|
name: /environment & accounts/i,
|
||||||
|
});
|
||||||
|
fireEvent.click(envTrigger);
|
||||||
|
|
||||||
const authCheckbox = screen.getByLabelText(/enable basic authentication/i)
|
const authCheckbox = screen.getByLabelText(/enable basic authentication/i);
|
||||||
expect(authCheckbox).toBeChecked()
|
expect(authCheckbox).toBeChecked();
|
||||||
|
|
||||||
// Disable it
|
// Disable it
|
||||||
fireEvent.click(authCheckbox)
|
fireEvent.click(authCheckbox);
|
||||||
expect(authCheckbox).not.toBeChecked()
|
expect(authCheckbox).not.toBeChecked();
|
||||||
|
|
||||||
const saveButton = screen.getByRole("button", { name: /^save$/i })
|
const saveButton = screen.getByRole("button", { name: /^save$/i });
|
||||||
expect(saveButton).toBeEnabled()
|
expect(saveButton).toBeEnabled();
|
||||||
fireEvent.click(saveButton)
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
await waitFor(() => expect(api.updateSettings).toHaveBeenCalled())
|
await waitFor(() => expect(api.updateSettings).toHaveBeenCalled());
|
||||||
expect(api.updateSettings).toHaveBeenCalledWith(
|
expect(api.updateSettings).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
basicAuthUser: null,
|
basicAuthUser: null,
|
||||||
basicAuthPassword: null,
|
basicAuthPassword: null,
|
||||||
})
|
}),
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,27 +1,35 @@
|
|||||||
import React, { useEffect, useState } from "react"
|
import * as api from "@client/api";
|
||||||
import { Settings } from "lucide-react"
|
import { PageHeader } from "@client/components/layout";
|
||||||
import { toast } from "sonner"
|
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection";
|
||||||
import { useForm, FormProvider } from "react-hook-form"
|
import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection";
|
||||||
|
import { GradcrackerSection } from "@client/pages/settings/components/GradcrackerSection";
|
||||||
import { PageHeader } from "@client/components/layout"
|
import { JobspySection } from "@client/pages/settings/components/JobspySection";
|
||||||
import { Accordion } from "@/components/ui/accordion"
|
import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection";
|
||||||
import { Button } from "@/components/ui/button"
|
import { ReactiveResumeSection } from "@client/pages/settings/components/ReactiveResumeSection";
|
||||||
import type { AppSettings, JobStatus, ResumeProjectCatalogItem, ResumeProjectsSettings } from "@shared/types"
|
import { SearchTermsSection } from "@client/pages/settings/components/SearchTermsSection";
|
||||||
import { updateSettingsSchema, type UpdateSettingsInput } from "@shared/settings-schema"
|
import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection";
|
||||||
import * as api from "@client/api"
|
import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection";
|
||||||
import { arraysEqual } from "@/lib/utils"
|
import { resumeProjectsEqual } from "@client/pages/settings/utils";
|
||||||
import { resumeProjectsEqual } from "@client/pages/settings/utils"
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection"
|
import {
|
||||||
import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection"
|
type UpdateSettingsInput,
|
||||||
import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection"
|
updateSettingsSchema,
|
||||||
import { GradcrackerSection } from "@client/pages/settings/components/GradcrackerSection"
|
} from "@shared/settings-schema";
|
||||||
import { JobspySection } from "@client/pages/settings/components/JobspySection"
|
import type {
|
||||||
import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection"
|
AppSettings,
|
||||||
import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection"
|
JobStatus,
|
||||||
import { SearchTermsSection } from "@client/pages/settings/components/SearchTermsSection"
|
ResumeProjectCatalogItem,
|
||||||
import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection"
|
ResumeProjectsSettings,
|
||||||
import { ReactiveResumeSection } from "@client/pages/settings/components/ReactiveResumeSection"
|
} from "@shared/types";
|
||||||
|
import { Settings } from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { FormProvider, useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Accordion } from "@/components/ui/accordion";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { arraysEqual } from "@/lib/utils";
|
||||||
|
|
||||||
const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
||||||
model: "",
|
model: "",
|
||||||
@@ -51,7 +59,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
|||||||
ukvisajobsPassword: "",
|
ukvisajobsPassword: "",
|
||||||
webhookSecret: "",
|
webhookSecret: "",
|
||||||
enableBasicAuth: false,
|
enableBasicAuth: false,
|
||||||
}
|
};
|
||||||
|
|
||||||
const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
||||||
model: null,
|
model: null,
|
||||||
@@ -81,7 +89,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
|||||||
ukvisajobsPassword: null,
|
ukvisajobsPassword: null,
|
||||||
webhookSecret: null,
|
webhookSecret: null,
|
||||||
enableBasicAuth: undefined,
|
enableBasicAuth: undefined,
|
||||||
}
|
};
|
||||||
|
|
||||||
const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
||||||
model: data.overrideModel ?? "",
|
model: data.overrideModel ?? "",
|
||||||
@@ -111,71 +119,86 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
|||||||
ukvisajobsPassword: "",
|
ukvisajobsPassword: "",
|
||||||
webhookSecret: "",
|
webhookSecret: "",
|
||||||
enableBasicAuth: data.basicAuthActive,
|
enableBasicAuth: data.basicAuthActive,
|
||||||
})
|
});
|
||||||
|
|
||||||
const normalizeString = (value: string | null | undefined) => {
|
const normalizeString = (value: string | null | undefined) => {
|
||||||
const trimmed = value?.trim()
|
const trimmed = value?.trim();
|
||||||
return trimmed ? trimmed : null
|
return trimmed ? trimmed : null;
|
||||||
}
|
};
|
||||||
|
|
||||||
const normalizePrivateInput = (value: string | null | undefined) => {
|
const normalizePrivateInput = (value: string | null | undefined) => {
|
||||||
const trimmed = value?.trim()
|
const trimmed = value?.trim();
|
||||||
if (trimmed === "") return null
|
if (trimmed === "") return null;
|
||||||
return trimmed || undefined
|
return trimmed || undefined;
|
||||||
}
|
};
|
||||||
|
|
||||||
const isSameStringList = (left: string[] | null | undefined, right: string[] | null | undefined) => {
|
const isSameStringList = (
|
||||||
if (!left && !right) return true
|
left: string[] | null | undefined,
|
||||||
if (!left || !right) return false
|
right: string[] | null | undefined,
|
||||||
return arraysEqual(left, right)
|
) => {
|
||||||
}
|
if (!left && !right) return true;
|
||||||
|
if (!left || !right) return false;
|
||||||
|
return arraysEqual(left, right);
|
||||||
|
};
|
||||||
|
|
||||||
const isSameSortedStringList = (left: string[] | null | undefined, right: string[] | null | undefined) => {
|
const isSameSortedStringList = (
|
||||||
if (!left && !right) return true
|
left: string[] | null | undefined,
|
||||||
if (!left || !right) return false
|
right: string[] | null | undefined,
|
||||||
return arraysEqual(left.slice().sort(), right.slice().sort())
|
) => {
|
||||||
}
|
if (!left && !right) return true;
|
||||||
|
if (!left || !right) return false;
|
||||||
|
return arraysEqual(left.slice().sort(), right.slice().sort());
|
||||||
|
};
|
||||||
|
|
||||||
const nullIfSame = <T,>(value: T | null | undefined, defaultValue: T) =>
|
const nullIfSame = <T,>(value: T | null | undefined, defaultValue: T) =>
|
||||||
value === defaultValue ? null : value ?? null
|
value === defaultValue ? null : (value ?? null);
|
||||||
|
|
||||||
const nullIfSameList = (value: string[] | null | undefined, defaultValue: string[]) =>
|
const nullIfSameList = (
|
||||||
isSameStringList(value, defaultValue) ? null : value ?? null
|
value: string[] | null | undefined,
|
||||||
|
defaultValue: string[],
|
||||||
|
) => (isSameStringList(value, defaultValue) ? null : (value ?? null));
|
||||||
|
|
||||||
const normalizeResumeProjectsForCatalog = (
|
const normalizeResumeProjectsForCatalog = (
|
||||||
catalog: ResumeProjectCatalogItem[],
|
catalog: ResumeProjectCatalogItem[],
|
||||||
current: ResumeProjectsSettings | null
|
current: ResumeProjectsSettings | null,
|
||||||
): ResumeProjectsSettings | null => {
|
): ResumeProjectsSettings | null => {
|
||||||
const allowed = new Set(catalog.map((project) => project.id))
|
const allowed = new Set(catalog.map((project) => project.id));
|
||||||
|
|
||||||
const base = current ?? {
|
const base = current ?? {
|
||||||
maxProjects: 0,
|
maxProjects: 0,
|
||||||
lockedProjectIds: catalog.filter((project) => project.isVisibleInBase).map((project) => project.id),
|
lockedProjectIds: catalog
|
||||||
|
.filter((project) => project.isVisibleInBase)
|
||||||
|
.map((project) => project.id),
|
||||||
aiSelectableProjectIds: [],
|
aiSelectableProjectIds: [],
|
||||||
}
|
};
|
||||||
|
|
||||||
const lockedProjectIds = base.lockedProjectIds.filter((id) => allowed.has(id))
|
const lockedProjectIds = base.lockedProjectIds.filter((id) =>
|
||||||
const lockedSet = new Set(lockedProjectIds)
|
allowed.has(id),
|
||||||
const aiSelectableProjectIds = (current
|
);
|
||||||
? base.aiSelectableProjectIds
|
const lockedSet = new Set(lockedProjectIds);
|
||||||
: catalog.map((project) => project.id)
|
const aiSelectableProjectIds = (
|
||||||
|
current ? base.aiSelectableProjectIds : catalog.map((project) => project.id)
|
||||||
)
|
)
|
||||||
.filter((id) => allowed.has(id))
|
.filter((id) => allowed.has(id))
|
||||||
.filter((id) => !lockedSet.has(id))
|
.filter((id) => !lockedSet.has(id));
|
||||||
const maxProjectsRaw = Number.isFinite(base.maxProjects) ? base.maxProjects : 0
|
const maxProjectsRaw = Number.isFinite(base.maxProjects)
|
||||||
const maxProjectsInt = Math.max(0, Math.floor(maxProjectsRaw))
|
? base.maxProjects
|
||||||
|
: 0;
|
||||||
|
const maxProjectsInt = Math.max(0, Math.floor(maxProjectsRaw));
|
||||||
const maxProjects = Math.min(
|
const maxProjects = Math.min(
|
||||||
catalog.length,
|
catalog.length,
|
||||||
Math.max(lockedProjectIds.length, maxProjectsInt, 3)
|
Math.max(lockedProjectIds.length, maxProjectsInt, 3),
|
||||||
)
|
);
|
||||||
return { maxProjects, lockedProjectIds, aiSelectableProjectIds }
|
return { maxProjects, lockedProjectIds, aiSelectableProjectIds };
|
||||||
}
|
};
|
||||||
|
|
||||||
const nullIfSameSortedList = (value: string[] | null | undefined, defaultValue: string[]) =>
|
const nullIfSameSortedList = (
|
||||||
isSameSortedStringList(value, defaultValue) ? null : value ?? null
|
value: string[] | null | undefined,
|
||||||
|
defaultValue: string[],
|
||||||
|
) => (isSameSortedStringList(value, defaultValue) ? null : (value ?? null));
|
||||||
|
|
||||||
const getDerivedSettings = (settings: AppSettings | null) => {
|
const getDerivedSettings = (settings: AppSettings | null) => {
|
||||||
const profileProjects = settings?.profileProjects ?? []
|
const profileProjects = settings?.profileProjects ?? [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
model: {
|
model: {
|
||||||
@@ -254,23 +277,30 @@ const getDerivedSettings = (settings: AppSettings | null) => {
|
|||||||
|
|
||||||
profileProjects,
|
profileProjects,
|
||||||
maxProjectsTotal: profileProjects.length,
|
maxProjectsTotal: profileProjects.length,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
export const SettingsPage: React.FC = () => {
|
export const SettingsPage: React.FC = () => {
|
||||||
const [settings, setSettings] = useState<AppSettings | null>(null)
|
const [settings, setSettings] = useState<AppSettings | null>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>(['discovered'])
|
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>([
|
||||||
const [rxResumeBaseResumeIdDraft, setRxResumeBaseResumeIdDraft] = useState<string | null>(null)
|
"discovered",
|
||||||
const [rxResumeProjectsOverride, setRxResumeProjectsOverride] = useState<ResumeProjectCatalogItem[] | null>(null)
|
]);
|
||||||
const [isFetchingRxResumeProjects, setIsFetchingRxResumeProjects] = useState(false)
|
const [rxResumeBaseResumeIdDraft, setRxResumeBaseResumeIdDraft] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [rxResumeProjectsOverride, setRxResumeProjectsOverride] = useState<
|
||||||
|
ResumeProjectCatalogItem[] | null
|
||||||
|
>(null);
|
||||||
|
const [isFetchingRxResumeProjects, setIsFetchingRxResumeProjects] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
const methods = useForm<UpdateSettingsInput>({
|
const methods = useForm<UpdateSettingsInput>({
|
||||||
resolver: zodResolver(updateSettingsSchema),
|
resolver: zodResolver(updateSettingsSchema),
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
defaultValues: DEFAULT_FORM_VALUES,
|
defaultValues: DEFAULT_FORM_VALUES,
|
||||||
})
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -279,94 +309,99 @@ export const SettingsPage: React.FC = () => {
|
|||||||
setValue,
|
setValue,
|
||||||
getValues,
|
getValues,
|
||||||
watch,
|
watch,
|
||||||
formState: { isDirty, errors, isValid, dirtyFields }
|
formState: { isDirty, errors, isValid, dirtyFields },
|
||||||
} = methods
|
} = methods;
|
||||||
|
|
||||||
const hasRxResumeAccess = Boolean(
|
const hasRxResumeAccess = Boolean(
|
||||||
settings?.rxresumeEmail?.trim() && settings?.rxresumePasswordHint
|
settings?.rxresumeEmail?.trim() && settings?.rxresumePasswordHint,
|
||||||
)
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true
|
let isMounted = true;
|
||||||
setIsLoading(true)
|
setIsLoading(true);
|
||||||
api
|
api
|
||||||
.getSettings()
|
.getSettings()
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!isMounted) return
|
if (!isMounted) return;
|
||||||
setSettings(data)
|
setSettings(data);
|
||||||
reset(mapSettingsToForm(data))
|
reset(mapSettingsToForm(data));
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
const message = error instanceof Error ? error.message : "Failed to load settings"
|
const message =
|
||||||
toast.error(message)
|
error instanceof Error ? error.message : "Failed to load settings";
|
||||||
|
toast.error(message);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!isMounted) return
|
if (!isMounted) return;
|
||||||
setIsLoading(false)
|
setIsLoading(false);
|
||||||
})
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false
|
isMounted = false;
|
||||||
}
|
};
|
||||||
}, [reset])
|
}, [reset]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settings) return
|
if (!settings) return;
|
||||||
const storedId = settings.rxresumeBaseResumeId ?? null
|
const storedId = settings.rxresumeBaseResumeId ?? null;
|
||||||
setRxResumeBaseResumeIdDraft(storedId)
|
setRxResumeBaseResumeIdDraft(storedId);
|
||||||
setValue("rxresumeBaseResumeId", storedId, { shouldDirty: false })
|
setValue("rxresumeBaseResumeId", storedId, { shouldDirty: false });
|
||||||
setRxResumeProjectsOverride(null)
|
setRxResumeProjectsOverride(null);
|
||||||
}, [settings, setValue])
|
}, [settings, setValue]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true
|
let isMounted = true;
|
||||||
const controller = new AbortController()
|
const controller = new AbortController();
|
||||||
|
|
||||||
if (!rxResumeBaseResumeIdDraft) {
|
if (!rxResumeBaseResumeIdDraft) {
|
||||||
setRxResumeProjectsOverride(null)
|
setRxResumeProjectsOverride(null);
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false
|
isMounted = false;
|
||||||
controller.abort()
|
controller.abort();
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasRxResumeAccess) return () => {
|
if (!hasRxResumeAccess)
|
||||||
isMounted = false
|
return () => {
|
||||||
controller.abort()
|
isMounted = false;
|
||||||
}
|
controller.abort();
|
||||||
|
};
|
||||||
|
|
||||||
setIsFetchingRxResumeProjects(true)
|
setIsFetchingRxResumeProjects(true);
|
||||||
api
|
api
|
||||||
.getRxResumeProjects(rxResumeBaseResumeIdDraft, controller.signal)
|
.getRxResumeProjects(rxResumeBaseResumeIdDraft, controller.signal)
|
||||||
.then((projects) => {
|
.then((projects) => {
|
||||||
if (!isMounted) return
|
if (!isMounted) return;
|
||||||
setRxResumeProjectsOverride(projects)
|
setRxResumeProjectsOverride(projects);
|
||||||
const normalized = normalizeResumeProjectsForCatalog(
|
const normalized = normalizeResumeProjectsForCatalog(
|
||||||
projects,
|
projects,
|
||||||
getValues("resumeProjects") ?? null
|
getValues("resumeProjects") ?? null,
|
||||||
)
|
);
|
||||||
if (normalized) {
|
if (normalized) {
|
||||||
setValue("resumeProjects", normalized, { shouldDirty: true })
|
setValue("resumeProjects", normalized, { shouldDirty: true });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (!isMounted || error.name === 'AbortError') return
|
if (!isMounted || error.name === "AbortError") return;
|
||||||
const message = error instanceof Error ? error.message : "Failed to load RxResume projects"
|
const message =
|
||||||
toast.error(message)
|
error instanceof Error
|
||||||
setRxResumeProjectsOverride(null)
|
? error.message
|
||||||
|
: "Failed to load RxResume projects";
|
||||||
|
toast.error(message);
|
||||||
|
setRxResumeProjectsOverride(null);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!isMounted) return
|
if (!isMounted) return;
|
||||||
setIsFetchingRxResumeProjects(false)
|
setIsFetchingRxResumeProjects(false);
|
||||||
})
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false
|
isMounted = false;
|
||||||
controller.abort()
|
controller.abort();
|
||||||
}
|
};
|
||||||
}, [rxResumeBaseResumeIdDraft, hasRxResumeAccess, getValues, setValue])
|
}, [rxResumeBaseResumeIdDraft, hasRxResumeAccess, getValues, setValue]);
|
||||||
|
|
||||||
const derived = getDerivedSettings(settings)
|
const derived = getDerivedSettings(settings);
|
||||||
const {
|
const {
|
||||||
model,
|
model,
|
||||||
pipelineWebhook,
|
pipelineWebhook,
|
||||||
@@ -380,79 +415,87 @@ export const SettingsPage: React.FC = () => {
|
|||||||
defaultResumeProjects,
|
defaultResumeProjects,
|
||||||
profileProjects,
|
profileProjects,
|
||||||
maxProjectsTotal,
|
maxProjectsTotal,
|
||||||
} = derived
|
} = derived;
|
||||||
|
|
||||||
const effectiveProfileProjects = rxResumeProjectsOverride ?? profileProjects
|
const effectiveProfileProjects = rxResumeProjectsOverride ?? profileProjects;
|
||||||
const effectiveMaxProjectsTotal = effectiveProfileProjects.length
|
const effectiveMaxProjectsTotal = effectiveProfileProjects.length;
|
||||||
|
|
||||||
const watchedValues = watch()
|
const watchedValues = watch();
|
||||||
const lockedCount = watchedValues.resumeProjects?.lockedProjectIds.length ?? 0
|
const lockedCount =
|
||||||
|
watchedValues.resumeProjects?.lockedProjectIds.length ?? 0;
|
||||||
|
|
||||||
const canSave = isDirty && isValid
|
const canSave = isDirty && isValid;
|
||||||
|
|
||||||
const onSave = async (data: UpdateSettingsInput) => {
|
const onSave = async (data: UpdateSettingsInput) => {
|
||||||
if (!settings) return
|
if (!settings) return;
|
||||||
if (data.enableBasicAuth && !settings.basicAuthActive) {
|
if (data.enableBasicAuth && !settings.basicAuthActive) {
|
||||||
const password = data.basicAuthPassword?.trim() ?? ""
|
const password = data.basicAuthPassword?.trim() ?? "";
|
||||||
if (!password) {
|
if (!password) {
|
||||||
setError("basicAuthPassword", {
|
setError("basicAuthPassword", {
|
||||||
type: "manual",
|
type: "manual",
|
||||||
message: "Password is required when basic auth is enabled",
|
message: "Password is required when basic auth is enabled",
|
||||||
})
|
});
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setIsSaving(true)
|
setIsSaving(true);
|
||||||
|
|
||||||
// Prepare payload: nullify if equal to default
|
// Prepare payload: nullify if equal to default
|
||||||
const resumeProjectsData = data.resumeProjects
|
const resumeProjectsData = data.resumeProjects;
|
||||||
const resumeProjectsOverride = (resumeProjectsData && defaultResumeProjects && resumeProjectsEqual(resumeProjectsData, defaultResumeProjects))
|
const resumeProjectsOverride =
|
||||||
? null
|
resumeProjectsData &&
|
||||||
: resumeProjectsData
|
defaultResumeProjects &&
|
||||||
|
resumeProjectsEqual(resumeProjectsData, defaultResumeProjects)
|
||||||
|
? null
|
||||||
|
: resumeProjectsData;
|
||||||
|
|
||||||
const envPayload: Partial<UpdateSettingsInput> = {}
|
const envPayload: Partial<UpdateSettingsInput> = {};
|
||||||
|
|
||||||
if (dirtyFields.rxresumeEmail || dirtyFields.rxresumePassword) {
|
if (dirtyFields.rxresumeEmail || dirtyFields.rxresumePassword) {
|
||||||
envPayload.rxresumeEmail = normalizeString(data.rxresumeEmail)
|
envPayload.rxresumeEmail = normalizeString(data.rxresumeEmail);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dirtyFields.ukvisajobsEmail || dirtyFields.ukvisajobsPassword) {
|
if (dirtyFields.ukvisajobsEmail || dirtyFields.ukvisajobsPassword) {
|
||||||
envPayload.ukvisajobsEmail = normalizeString(data.ukvisajobsEmail)
|
envPayload.ukvisajobsEmail = normalizeString(data.ukvisajobsEmail);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.enableBasicAuth === false) {
|
if (data.enableBasicAuth === false) {
|
||||||
envPayload.basicAuthUser = null
|
envPayload.basicAuthUser = null;
|
||||||
envPayload.basicAuthPassword = null
|
envPayload.basicAuthPassword = null;
|
||||||
} else if (dirtyFields.enableBasicAuth || dirtyFields.basicAuthUser || dirtyFields.basicAuthPassword) {
|
} else if (
|
||||||
|
dirtyFields.enableBasicAuth ||
|
||||||
|
dirtyFields.basicAuthUser ||
|
||||||
|
dirtyFields.basicAuthPassword
|
||||||
|
) {
|
||||||
// If enabling basic auth or changing either field, ensure we send at least the username
|
// If enabling basic auth or changing either field, ensure we send at least the username
|
||||||
// to keep the pair consistent in the backend.
|
// to keep the pair consistent in the backend.
|
||||||
envPayload.basicAuthUser = normalizeString(data.basicAuthUser)
|
envPayload.basicAuthUser = normalizeString(data.basicAuthUser);
|
||||||
|
|
||||||
if (dirtyFields.basicAuthPassword) {
|
if (dirtyFields.basicAuthPassword) {
|
||||||
const value = normalizePrivateInput(data.basicAuthPassword)
|
const value = normalizePrivateInput(data.basicAuthPassword);
|
||||||
if (value !== undefined) envPayload.basicAuthPassword = value
|
if (value !== undefined) envPayload.basicAuthPassword = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dirtyFields.openrouterApiKey) {
|
if (dirtyFields.openrouterApiKey) {
|
||||||
const value = normalizePrivateInput(data.openrouterApiKey)
|
const value = normalizePrivateInput(data.openrouterApiKey);
|
||||||
if (value !== undefined) envPayload.openrouterApiKey = value
|
if (value !== undefined) envPayload.openrouterApiKey = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dirtyFields.rxresumePassword) {
|
if (dirtyFields.rxresumePassword) {
|
||||||
const value = normalizePrivateInput(data.rxresumePassword)
|
const value = normalizePrivateInput(data.rxresumePassword);
|
||||||
if (value !== undefined) envPayload.rxresumePassword = value
|
if (value !== undefined) envPayload.rxresumePassword = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dirtyFields.ukvisajobsPassword) {
|
if (dirtyFields.ukvisajobsPassword) {
|
||||||
const value = normalizePrivateInput(data.ukvisajobsPassword)
|
const value = normalizePrivateInput(data.ukvisajobsPassword);
|
||||||
if (value !== undefined) envPayload.ukvisajobsPassword = value
|
if (value !== undefined) envPayload.ukvisajobsPassword = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dirtyFields.webhookSecret) {
|
if (dirtyFields.webhookSecret) {
|
||||||
const value = normalizePrivateInput(data.webhookSecret)
|
const value = normalizePrivateInput(data.webhookSecret);
|
||||||
if (value !== undefined) envPayload.webhookSecret = value
|
if (value !== undefined) envPayload.webhookSecret = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload: UpdateSettingsInput = {
|
const payload: UpdateSettingsInput = {
|
||||||
@@ -464,108 +507,135 @@ export const SettingsPage: React.FC = () => {
|
|||||||
jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl),
|
jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl),
|
||||||
resumeProjects: resumeProjectsOverride,
|
resumeProjects: resumeProjectsOverride,
|
||||||
rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId),
|
rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId),
|
||||||
ukvisajobsMaxJobs: nullIfSame(data.ukvisajobsMaxJobs, ukvisajobs.default),
|
ukvisajobsMaxJobs: nullIfSame(
|
||||||
gradcrackerMaxJobsPerTerm: nullIfSame(data.gradcrackerMaxJobsPerTerm, gradcracker.default),
|
data.ukvisajobsMaxJobs,
|
||||||
|
ukvisajobs.default,
|
||||||
|
),
|
||||||
|
gradcrackerMaxJobsPerTerm: nullIfSame(
|
||||||
|
data.gradcrackerMaxJobsPerTerm,
|
||||||
|
gradcracker.default,
|
||||||
|
),
|
||||||
searchTerms: nullIfSameList(data.searchTerms, searchTerms.default),
|
searchTerms: nullIfSameList(data.searchTerms, searchTerms.default),
|
||||||
jobspyLocation: nullIfSame(data.jobspyLocation, jobspy.location.default),
|
jobspyLocation: nullIfSame(
|
||||||
jobspyResultsWanted: nullIfSame(data.jobspyResultsWanted, jobspy.resultsWanted.default),
|
data.jobspyLocation,
|
||||||
jobspyHoursOld: nullIfSame(data.jobspyHoursOld, jobspy.hoursOld.default),
|
jobspy.location.default,
|
||||||
jobspyCountryIndeed: nullIfSame(data.jobspyCountryIndeed, jobspy.countryIndeed.default),
|
),
|
||||||
jobspySites: nullIfSameSortedList(data.jobspySites, jobspy.sites.default),
|
jobspyResultsWanted: nullIfSame(
|
||||||
|
data.jobspyResultsWanted,
|
||||||
|
jobspy.resultsWanted.default,
|
||||||
|
),
|
||||||
|
jobspyHoursOld: nullIfSame(
|
||||||
|
data.jobspyHoursOld,
|
||||||
|
jobspy.hoursOld.default,
|
||||||
|
),
|
||||||
|
jobspyCountryIndeed: nullIfSame(
|
||||||
|
data.jobspyCountryIndeed,
|
||||||
|
jobspy.countryIndeed.default,
|
||||||
|
),
|
||||||
|
jobspySites: nullIfSameSortedList(
|
||||||
|
data.jobspySites,
|
||||||
|
jobspy.sites.default,
|
||||||
|
),
|
||||||
jobspyLinkedinFetchDescription: nullIfSame(
|
jobspyLinkedinFetchDescription: nullIfSame(
|
||||||
data.jobspyLinkedinFetchDescription,
|
data.jobspyLinkedinFetchDescription,
|
||||||
jobspy.linkedinFetchDescription.default
|
jobspy.linkedinFetchDescription.default,
|
||||||
),
|
),
|
||||||
showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default),
|
showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default),
|
||||||
...envPayload,
|
...envPayload,
|
||||||
}
|
};
|
||||||
|
|
||||||
// Remove virtual field because the backend doesn't expect it
|
// Remove virtual field because the backend doesn't expect it
|
||||||
// this exists only to toggle the UI
|
// this exists only to toggle the UI
|
||||||
// need to track it so that the save button is enabled when it changes
|
// need to track it so that the save button is enabled when it changes
|
||||||
delete payload.enableBasicAuth
|
delete payload.enableBasicAuth;
|
||||||
|
|
||||||
const updated = await api.updateSettings(payload)
|
const updated = await api.updateSettings(payload);
|
||||||
setSettings(updated)
|
setSettings(updated);
|
||||||
reset(mapSettingsToForm(updated))
|
reset(mapSettingsToForm(updated));
|
||||||
toast.success("Settings saved")
|
toast.success("Settings saved");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to save settings"
|
const message =
|
||||||
toast.error(message)
|
error instanceof Error ? error.message : "Failed to save settings";
|
||||||
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false)
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleClearDatabase = async () => {
|
const handleClearDatabase = async () => {
|
||||||
try {
|
try {
|
||||||
setIsSaving(true)
|
setIsSaving(true);
|
||||||
const result = await api.clearDatabase()
|
const result = await api.clearDatabase();
|
||||||
toast.success("Database cleared", { description: `Deleted ${result.jobsDeleted} jobs.` })
|
toast.success("Database cleared", {
|
||||||
|
description: `Deleted ${result.jobsDeleted} jobs.`,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to clear database"
|
const message =
|
||||||
toast.error(message)
|
error instanceof Error ? error.message : "Failed to clear database";
|
||||||
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false)
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleClearByStatuses = async () => {
|
const handleClearByStatuses = async () => {
|
||||||
if (statusesToClear.length === 0) {
|
if (statusesToClear.length === 0) {
|
||||||
toast.error("No statuses selected")
|
toast.error("No statuses selected");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setIsSaving(true)
|
setIsSaving(true);
|
||||||
let totalDeleted = 0
|
let totalDeleted = 0;
|
||||||
const results: string[] = []
|
const results: string[] = [];
|
||||||
|
|
||||||
for (const status of statusesToClear) {
|
for (const status of statusesToClear) {
|
||||||
const result = await api.deleteJobsByStatus(status)
|
const result = await api.deleteJobsByStatus(status);
|
||||||
totalDeleted += result.count
|
totalDeleted += result.count;
|
||||||
if (result.count > 0) {
|
if (result.count > 0) {
|
||||||
results.push(`${result.count} ${status}`)
|
results.push(`${result.count} ${status}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (totalDeleted > 0) {
|
if (totalDeleted > 0) {
|
||||||
toast.success("Jobs cleared", {
|
toast.success("Jobs cleared", {
|
||||||
description: `Deleted ${totalDeleted} jobs: ${results.join(', ')}`,
|
description: `Deleted ${totalDeleted} jobs: ${results.join(", ")}`,
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.info("No jobs found", {
|
toast.info("No jobs found", {
|
||||||
description: `No jobs with selected statuses found`,
|
description: `No jobs with selected statuses found`,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to clear jobs"
|
const message =
|
||||||
toast.error(message)
|
error instanceof Error ? error.message : "Failed to clear jobs";
|
||||||
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false)
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const toggleStatusToClear = (status: JobStatus) => {
|
const toggleStatusToClear = (status: JobStatus) => {
|
||||||
setStatusesToClear(prev =>
|
setStatusesToClear((prev) =>
|
||||||
prev.includes(status)
|
prev.includes(status)
|
||||||
? prev.filter(s => s !== status)
|
? prev.filter((s) => s !== status)
|
||||||
: [...prev, status]
|
: [...prev, status],
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
const handleReset = async () => {
|
const handleReset = async () => {
|
||||||
try {
|
try {
|
||||||
setIsSaving(true)
|
setIsSaving(true);
|
||||||
const updated = await api.updateSettings(NULL_SETTINGS_PAYLOAD)
|
const updated = await api.updateSettings(NULL_SETTINGS_PAYLOAD);
|
||||||
setSettings(updated)
|
setSettings(updated);
|
||||||
reset(mapSettingsToForm(updated))
|
reset(mapSettingsToForm(updated));
|
||||||
toast.success("Reset to default")
|
toast.success("Reset to default");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to reset settings"
|
const message =
|
||||||
toast.error(message)
|
error instanceof Error ? error.message : "Failed to reset settings";
|
||||||
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false)
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
@@ -612,8 +682,8 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<ReactiveResumeSection
|
<ReactiveResumeSection
|
||||||
rxResumeBaseResumeIdDraft={rxResumeBaseResumeIdDraft}
|
rxResumeBaseResumeIdDraft={rxResumeBaseResumeIdDraft}
|
||||||
setRxResumeBaseResumeIdDraft={(value) => {
|
setRxResumeBaseResumeIdDraft={(value) => {
|
||||||
setRxResumeBaseResumeIdDraft(value)
|
setRxResumeBaseResumeIdDraft(value);
|
||||||
setValue("rxresumeBaseResumeId", value, { shouldDirty: true })
|
setValue("rxresumeBaseResumeId", value, { shouldDirty: true });
|
||||||
}}
|
}}
|
||||||
hasRxResumeAccess={hasRxResumeAccess}
|
hasRxResumeAccess={hasRxResumeAccess}
|
||||||
profileProjects={effectiveProfileProjects}
|
profileProjects={effectiveProfileProjects}
|
||||||
@@ -644,10 +714,17 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button onClick={handleSubmit(onSave)} disabled={isLoading || isSaving || !canSave}>
|
<Button
|
||||||
|
onClick={handleSubmit(onSave)}
|
||||||
|
disabled={isLoading || isSaving || !canSave}
|
||||||
|
>
|
||||||
{isSaving ? "Saving..." : "Save"}
|
{isSaving ? "Saving..." : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={handleReset} disabled={isLoading || isSaving || !settings}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={isLoading || isSaving || !settings}
|
||||||
|
>
|
||||||
Reset to default
|
Reset to default
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -658,5 +735,5 @@ export const SettingsPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
* UK Visa Jobs search page.
|
* UK Visa Jobs search page.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
import {
|
||||||
Briefcase,
|
Briefcase,
|
||||||
Calendar,
|
Calendar,
|
||||||
@@ -21,15 +20,17 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
|
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
@@ -38,10 +39,11 @@ import {
|
|||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
import { cn, formatDate, formatDateTime, stripHtml } from "@/lib/utils";
|
import { cn, formatDate, formatDateTime, stripHtml } from "@/lib/utils";
|
||||||
import * as api from "../api";
|
|
||||||
import type { CreateJobInput } from "../../shared/types";
|
import type { CreateJobInput } from "../../shared/types";
|
||||||
|
import * as api from "../api";
|
||||||
|
|
||||||
const clampText = (value: string, max = 160) => (value.length > max ? `${value.slice(0, max).trim()}...` : value);
|
const clampText = (value: string, max = 160) =>
|
||||||
|
value.length > max ? `${value.slice(0, max).trim()}...` : value;
|
||||||
|
|
||||||
const jobKey = (job: CreateJobInput) => job.sourceJobId || job.jobUrl;
|
const jobKey = (job: CreateJobInput) => job.sourceJobId || job.jobUrl;
|
||||||
|
|
||||||
@@ -71,8 +73,10 @@ export const UkVisaJobsPage: React.FC = () => {
|
|||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
|
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
|
||||||
const [isDesktop, setIsDesktop] = useState(
|
const [isDesktop, setIsDesktop] = useState(() =>
|
||||||
() => (typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false),
|
typeof window !== "undefined"
|
||||||
|
? window.matchMedia("(min-width: 1024px)").matches
|
||||||
|
: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -81,7 +85,10 @@ export const UkVisaJobsPage: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const firstKey = jobKey(results[0]);
|
const firstKey = jobKey(results[0]);
|
||||||
if (!selectedJobId || !results.some((job) => jobKey(job) === selectedJobId)) {
|
if (
|
||||||
|
!selectedJobId ||
|
||||||
|
!results.some((job) => jobKey(job) === selectedJobId)
|
||||||
|
) {
|
||||||
setSelectedJobId(firstKey);
|
setSelectedJobId(firstKey);
|
||||||
}
|
}
|
||||||
}, [results, selectedJobId]);
|
}, [results, selectedJobId]);
|
||||||
@@ -116,7 +123,10 @@ export const UkVisaJobsPage: React.FC = () => {
|
|||||||
}, [results]);
|
}, [results]);
|
||||||
|
|
||||||
const selectedJob = useMemo(
|
const selectedJob = useMemo(
|
||||||
() => (selectedJobId ? results.find((job) => jobKey(job) === selectedJobId) ?? null : null),
|
() =>
|
||||||
|
selectedJobId
|
||||||
|
? (results.find((job) => jobKey(job) === selectedJobId) ?? null)
|
||||||
|
: null,
|
||||||
[results, selectedJobId],
|
[results, selectedJobId],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -129,7 +139,13 @@ export const UkVisaJobsPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, [page, pageSize, totalJobs]);
|
}, [page, pageSize, totalJobs]);
|
||||||
|
|
||||||
const runSearch = async ({ term, pageNumber }: { term: string | null; pageNumber: number }) => {
|
const runSearch = async ({
|
||||||
|
term,
|
||||||
|
pageNumber,
|
||||||
|
}: {
|
||||||
|
term: string | null;
|
||||||
|
pageNumber: number;
|
||||||
|
}) => {
|
||||||
try {
|
try {
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
@@ -147,7 +163,8 @@ export const UkVisaJobsPage: React.FC = () => {
|
|||||||
toast.message("No UK Visa Jobs found for this search.");
|
toast.message("No UK Visa Jobs found for this search.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "UK Visa Jobs search failed";
|
const message =
|
||||||
|
error instanceof Error ? error.message : "UK Visa Jobs search failed";
|
||||||
setErrorMessage(message);
|
setErrorMessage(message);
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -184,18 +201,23 @@ export const UkVisaJobsPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleImportSelected = async () => {
|
const handleImportSelected = async () => {
|
||||||
const selectedJobs = results.filter((job) => selectedJobIds.has(jobKey(job)));
|
const selectedJobs = results.filter((job) =>
|
||||||
|
selectedJobIds.has(jobKey(job)),
|
||||||
|
);
|
||||||
if (selectedJobs.length === 0) return;
|
if (selectedJobs.length === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsImporting(true);
|
setIsImporting(true);
|
||||||
const response = await api.importUkVisaJobs({ jobs: selectedJobs });
|
const response = await api.importUkVisaJobs({ jobs: selectedJobs });
|
||||||
toast.success(`Imported ${response.created} jobs`, {
|
toast.success(`Imported ${response.created} jobs`, {
|
||||||
description: response.skipped ? `${response.skipped} skipped (duplicates)` : undefined,
|
description: response.skipped
|
||||||
|
? `${response.skipped} skipped (duplicates)`
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
setSelectedJobIds(new Set());
|
setSelectedJobIds(new Set());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to import jobs";
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to import jobs";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsImporting(false);
|
setIsImporting(false);
|
||||||
@@ -208,12 +230,24 @@ export const UkVisaJobsPage: React.FC = () => {
|
|||||||
return cleaned || "No description available.";
|
return cleaned || "No description available.";
|
||||||
}, [selectedJob]);
|
}, [selectedJob]);
|
||||||
|
|
||||||
const selectedJobLink = selectedJob ? selectedJob.applicationLink || selectedJob.jobUrl : "#";
|
const selectedJobLink = selectedJob
|
||||||
const selectedDeadline = selectedJob ? formatDate(selectedJob.deadline) : null;
|
? selectedJob.applicationLink || selectedJob.jobUrl
|
||||||
const selectedPosted = selectedJob ? formatDate(selectedJob.datePosted) : null;
|
: "#";
|
||||||
|
const selectedDeadline = selectedJob
|
||||||
|
? formatDate(selectedJob.deadline)
|
||||||
|
: null;
|
||||||
|
const selectedPosted = selectedJob
|
||||||
|
? formatDate(selectedJob.datePosted)
|
||||||
|
: null;
|
||||||
const selectedCount = selectedJobIds.size;
|
const selectedCount = selectedJobIds.size;
|
||||||
const allSelected = results.length > 0 && results.every((job) => selectedJobIds.has(jobKey(job)));
|
const allSelected =
|
||||||
const selectAllState = allSelected ? true : selectedCount > 0 ? "indeterminate" : false;
|
results.length > 0 &&
|
||||||
|
results.every((job) => selectedJobIds.has(jobKey(job)));
|
||||||
|
const selectAllState = allSelected
|
||||||
|
? true
|
||||||
|
: selectedCount > 0
|
||||||
|
? "indeterminate"
|
||||||
|
: false;
|
||||||
const canGoPrev = page > 1;
|
const canGoPrev = page > 1;
|
||||||
const canGoNext = page < totalPages;
|
const canGoNext = page < totalPages;
|
||||||
|
|
||||||
@@ -227,14 +261,20 @@ export const UkVisaJobsPage: React.FC = () => {
|
|||||||
const detailPanelContent = !selectedJob ? (
|
const detailPanelContent = !selectedJob ? (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-center">
|
<div className="flex h-full flex-col items-center justify-center gap-2 text-center">
|
||||||
<div className="text-base font-semibold">Select a job</div>
|
<div className="text-base font-semibold">Select a job</div>
|
||||||
<p className="text-sm text-muted-foreground">Pick a job from the list to inspect details.</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Pick a job from the list to inspect details.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-base font-semibold">{selectedJob.title}</div>
|
<div className="truncate text-base font-semibold">
|
||||||
<div className="text-sm text-muted-foreground">{selectedJob.employer}</div>
|
{selectedJob.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{selectedJob.employer}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="uppercase tracking-wide">
|
<Badge variant="outline" className="uppercase tracking-wide">
|
||||||
UK Visa Jobs
|
UK Visa Jobs
|
||||||
@@ -295,7 +335,12 @@ export const UkVisaJobsPage: React.FC = () => {
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<Button asChild size="sm" variant="outline" className="w-full gap-2 sm:w-auto">
|
<Button
|
||||||
|
asChild
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full gap-2 sm:w-auto"
|
||||||
|
>
|
||||||
<a href={selectedJobLink} target="_blank" rel="noopener noreferrer">
|
<a href={selectedJobLink} target="_blank" rel="noopener noreferrer">
|
||||||
<ExternalLink className="h-4 w-4" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
View job
|
View job
|
||||||
@@ -344,9 +389,16 @@ export const UkVisaJobsPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left",
|
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left",
|
||||||
location.pathname === to || (to === "/" && ["/ready", "/discovered", "/applied", "/all"].includes(location.pathname))
|
location.pathname === to ||
|
||||||
|
(to === "/" &&
|
||||||
|
[
|
||||||
|
"/ready",
|
||||||
|
"/discovered",
|
||||||
|
"/applied",
|
||||||
|
"/all",
|
||||||
|
].includes(location.pathname))
|
||||||
? "bg-accent text-accent-foreground"
|
? "bg-accent text-accent-foreground"
|
||||||
: "text-muted-foreground"
|
: "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
@@ -361,8 +413,12 @@ export const UkVisaJobsPage: React.FC = () => {
|
|||||||
<Briefcase className="h-4 w-4 text-muted-foreground" />
|
<Briefcase className="h-4 w-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 leading-tight">
|
<div className="min-w-0 leading-tight">
|
||||||
<div className="text-sm font-semibold tracking-tight">UK Visa Jobs</div>
|
<div className="text-sm font-semibold tracking-tight">
|
||||||
<div className="text-xs text-muted-foreground">Live search console</div>
|
UK Visa Jobs
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Live search console
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -370,7 +426,10 @@ export const UkVisaJobsPage: React.FC = () => {
|
|||||||
|
|
||||||
<main className="container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12">
|
<main className="container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12">
|
||||||
<section className="rounded-xl border border-border/60 bg-card/40 p-4">
|
<section className="rounded-xl border border-border/60 bg-card/40 p-4">
|
||||||
<form className="grid gap-4 md:grid-cols-[minmax(0,1fr)_160px]" onSubmit={handleSearch}>
|
<form
|
||||||
|
className="grid gap-4 md:grid-cols-[minmax(0,1fr)_160px]"
|
||||||
|
onSubmit={handleSearch}
|
||||||
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
Job title search term
|
Job title search term
|
||||||
@@ -382,13 +441,22 @@ export const UkVisaJobsPage: React.FC = () => {
|
|||||||
className="h-10"
|
className="h-10"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Note: Search is limited to job titles only due to API constraints.
|
Note: Search is limited to job titles only due to API
|
||||||
|
constraints.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-end">
|
<div className="flex items-end">
|
||||||
<Button type="submit" className="h-10 w-full gap-2" disabled={isSearching}>
|
<Button
|
||||||
{isSearching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
type="submit"
|
||||||
|
className="h-10 w-full gap-2"
|
||||||
|
disabled={isSearching}
|
||||||
|
>
|
||||||
|
{isSearching ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
)}
|
||||||
{isSearching ? "Searching..." : "Search"}
|
{isSearching ? "Searching..." : "Search"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -404,7 +472,8 @@ export const UkVisaJobsPage: React.FC = () => {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-2 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
Last run: {lastRunAt ? formatDateTime(lastRunAt) : "No searches yet"}
|
Last run:{" "}
|
||||||
|
{lastRunAt ? formatDateTime(lastRunAt) : "No searches yet"}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
@@ -416,7 +485,9 @@ export const UkVisaJobsPage: React.FC = () => {
|
|||||||
<span>
|
<span>
|
||||||
Page {page} of {totalPages}
|
Page {page} of {totalPages}
|
||||||
</span>
|
</span>
|
||||||
{lastSearchTerm && <span className="truncate">Term: {lastSearchTerm}</span>}
|
{lastSearchTerm && (
|
||||||
|
<span className="truncate">Term: {lastSearchTerm}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -441,7 +512,9 @@ export const UkVisaJobsPage: React.FC = () => {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="text-base font-semibold">No results yet</div>
|
<div className="text-base font-semibold">
|
||||||
|
No results yet
|
||||||
|
</div>
|
||||||
<p className="max-w-md text-sm text-muted-foreground">
|
<p className="max-w-md text-sm text-muted-foreground">
|
||||||
Run a search to fetch fresh UK Visa Jobs listings.
|
Run a search to fetch fresh UK Visa Jobs listings.
|
||||||
</p>
|
</p>
|
||||||
@@ -456,7 +529,9 @@ export const UkVisaJobsPage: React.FC = () => {
|
|||||||
checked={selectAllState}
|
checked={selectAllState}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
if (checked === true) {
|
if (checked === true) {
|
||||||
setSelectedJobIds(new Set(results.map((job) => jobKey(job))));
|
setSelectedJobIds(
|
||||||
|
new Set(results.map((job) => jobKey(job))),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setSelectedJobIds(new Set());
|
setSelectedJobIds(new Set());
|
||||||
}
|
}
|
||||||
@@ -474,7 +549,11 @@ export const UkVisaJobsPage: React.FC = () => {
|
|||||||
onClick={handleImportSelected}
|
onClick={handleImportSelected}
|
||||||
disabled={selectedCount === 0 || isImporting}
|
disabled={selectedCount === 0 || isImporting}
|
||||||
>
|
>
|
||||||
{isImporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Database className="h-4 w-4" />}
|
{isImporting ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Database className="h-4 w-4" />
|
||||||
|
)}
|
||||||
{isImporting ? "Importing..." : "Import to DB"}
|
{isImporting ? "Importing..." : "Import to DB"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -483,7 +562,9 @@ export const UkVisaJobsPage: React.FC = () => {
|
|||||||
const key = jobKey(job);
|
const key = jobKey(job);
|
||||||
const isSelected = key === selectedJobId;
|
const isSelected = key === selectedJobId;
|
||||||
const isChecked = selectedJobIds.has(key);
|
const isChecked = selectedJobIds.has(key);
|
||||||
const description = job.jobDescription ? clampText(stripHtml(job.jobDescription)) : "No description.";
|
const description = job.jobDescription
|
||||||
|
? clampText(stripHtml(job.jobDescription))
|
||||||
|
: "No description.";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -530,10 +611,16 @@ export const UkVisaJobsPage: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="truncate text-sm font-semibold">{job.title}</div>
|
<div className="truncate text-sm font-semibold">
|
||||||
<div className="text-xs text-muted-foreground">{job.employer}</div>
|
{job.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{job.employer}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{description}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">{description}</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
{job.location && (
|
{job.location && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
@@ -556,12 +643,18 @@ export const UkVisaJobsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{job.jobType && (
|
{job.jobType && (
|
||||||
<Badge variant="outline" className="text-[11px] uppercase tracking-wide">
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[11px] uppercase tracking-wide"
|
||||||
|
>
|
||||||
{job.jobType}
|
{job.jobType}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{job.jobLevel && (
|
{job.jobLevel && (
|
||||||
<Badge variant="outline" className="text-[11px] uppercase tracking-wide">
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[11px] uppercase tracking-wide"
|
||||||
|
>
|
||||||
{job.jobLevel}
|
{job.jobLevel}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -573,7 +666,8 @@ export const UkVisaJobsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border/60 px-4 py-3 text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border/60 px-4 py-3 text-xs text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
Showing {summaryCounts.startIndex}-{summaryCounts.endIndex} of {totalJobs}
|
Showing {summaryCounts.startIndex}-{summaryCounts.endIndex}{" "}
|
||||||
|
of {totalJobs}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -614,7 +708,9 @@ export const UkVisaJobsPage: React.FC = () => {
|
|||||||
<Drawer open={isDetailDrawerOpen} onOpenChange={setIsDetailDrawerOpen}>
|
<Drawer open={isDetailDrawerOpen} onOpenChange={setIsDetailDrawerOpen}>
|
||||||
<DrawerContent className="max-h-[90vh]">
|
<DrawerContent className="max-h-[90vh]">
|
||||||
<div className="flex items-center justify-between px-4 pt-2">
|
<div className="flex items-center justify-between px-4 pt-2">
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Job details</div>
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Job details
|
||||||
|
</div>
|
||||||
<DrawerClose asChild>
|
<DrawerClose asChild>
|
||||||
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
|
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
|
||||||
Close
|
Close
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
* Allows searching the government's list of licensed visa sponsors.
|
* Allows searching the government's list of licensed visa sponsors.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useState, useCallback, useMemo } from "react";
|
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Building2,
|
Building2,
|
||||||
@@ -18,34 +17,38 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
|
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { cn, formatDateTime } from "@/lib/utils";
|
import { cn, formatDateTime } from "@/lib/utils";
|
||||||
import {
|
|
||||||
PageHeader,
|
|
||||||
StatusIndicator,
|
|
||||||
ListItem,
|
|
||||||
EmptyState,
|
|
||||||
ScoreMeter,
|
|
||||||
SplitLayout,
|
|
||||||
ListPanel,
|
|
||||||
DetailPanel,
|
|
||||||
PageMain,
|
|
||||||
} from "../components";
|
|
||||||
import * as api from "../api";
|
|
||||||
import type {
|
import type {
|
||||||
VisaSponsor,
|
VisaSponsor,
|
||||||
VisaSponsorSearchResult,
|
VisaSponsorSearchResult,
|
||||||
VisaSponsorStatusResponse,
|
VisaSponsorStatusResponse,
|
||||||
} from "../../shared/types";
|
} from "../../shared/types";
|
||||||
|
import * as api from "../api";
|
||||||
|
import {
|
||||||
|
DetailPanel,
|
||||||
|
EmptyState,
|
||||||
|
ListItem,
|
||||||
|
ListPanel,
|
||||||
|
PageHeader,
|
||||||
|
PageMain,
|
||||||
|
ScoreMeter,
|
||||||
|
SplitLayout,
|
||||||
|
StatusIndicator,
|
||||||
|
} from "../components";
|
||||||
|
|
||||||
const getScoreTokens = (score: number) => {
|
const getScoreTokens = (score: number) => {
|
||||||
if (score >= 90)
|
if (score >= 90)
|
||||||
return { badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200" };
|
return {
|
||||||
|
badge: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200",
|
||||||
|
};
|
||||||
if (score >= 70)
|
if (score >= 70)
|
||||||
return { badge: "border-amber-500/30 bg-amber-500/10 text-amber-200" };
|
return { badge: "border-amber-500/30 bg-amber-500/10 text-amber-200" };
|
||||||
if (score >= 50)
|
if (score >= 50)
|
||||||
@@ -67,8 +70,10 @@ export const VisaSponsorsPage: React.FC = () => {
|
|||||||
const [isUpdating, setIsUpdating] = useState(false);
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
|
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
|
||||||
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
|
const [isDetailDrawerOpen, setIsDetailDrawerOpen] = useState(false);
|
||||||
const [isDesktop, setIsDesktop] = useState(
|
const [isDesktop, setIsDesktop] = useState(() =>
|
||||||
() => (typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false),
|
typeof window !== "undefined"
|
||||||
|
? window.matchMedia("(min-width: 1024px)").matches
|
||||||
|
: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch status on mount
|
// Fetch status on mount
|
||||||
@@ -82,7 +87,8 @@ export const VisaSponsorsPage: React.FC = () => {
|
|||||||
const data = await api.getVisaSponsorStatus();
|
const data = await api.getVisaSponsorStatus();
|
||||||
setStatus(data);
|
setStatus(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Failed to fetch status";
|
const message =
|
||||||
|
err instanceof Error ? err.message : "Failed to fetch status";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingStatus(false);
|
setIsLoadingStatus(false);
|
||||||
@@ -129,7 +135,10 @@ export const VisaSponsorsPage: React.FC = () => {
|
|||||||
setOrgDetails([]);
|
setOrgDetails([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!selectedOrg || !results.some((r) => r.sponsor.organisationName === selectedOrg)) {
|
if (
|
||||||
|
!selectedOrg ||
|
||||||
|
!results.some((r) => r.sponsor.organisationName === selectedOrg)
|
||||||
|
) {
|
||||||
const firstOrg = results[0].sponsor.organisationName;
|
const firstOrg = results[0].sponsor.organisationName;
|
||||||
setSelectedOrg(firstOrg);
|
setSelectedOrg(firstOrg);
|
||||||
fetchOrgDetails(firstOrg);
|
fetchOrgDetails(firstOrg);
|
||||||
@@ -169,7 +178,8 @@ export const VisaSponsorsPage: React.FC = () => {
|
|||||||
const details = await api.getVisaSponsorOrganization(orgName);
|
const details = await api.getVisaSponsorOrganization(orgName);
|
||||||
setOrgDetails(details);
|
setOrgDetails(details);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Failed to fetch details";
|
const message =
|
||||||
|
err instanceof Error ? err.message : "Failed to fetch details";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
setOrgDetails([]);
|
setOrgDetails([]);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -203,8 +213,9 @@ export const VisaSponsorsPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectedResult = useMemo(
|
const selectedResult = useMemo(
|
||||||
() => results.find((r) => r.sponsor.organisationName === selectedOrg) ?? null,
|
() =>
|
||||||
[results, selectedOrg]
|
results.find((r) => r.sponsor.organisationName === selectedOrg) ?? null,
|
||||||
|
[results, selectedOrg],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isUpdateInProgress = isUpdating || status?.isUpdating;
|
const isUpdateInProgress = isUpdating || status?.isUpdating;
|
||||||
@@ -233,7 +244,7 @@ export const VisaSponsorsPage: React.FC = () => {
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide",
|
"inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide",
|
||||||
getScoreTokens(selectedResult.score).badge
|
getScoreTokens(selectedResult.score).badge,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{selectedResult.score}% Match
|
{selectedResult.score}% Match
|
||||||
@@ -244,17 +255,20 @@ export const VisaSponsorsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Location */}
|
{/* Location */}
|
||||||
{orgDetails.length > 0 && (orgDetails[0].townCity || orgDetails[0].county) && (
|
{orgDetails.length > 0 &&
|
||||||
<div>
|
(orgDetails[0].townCity || orgDetails[0].county) && (
|
||||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-1">
|
<div>
|
||||||
Location
|
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-1">
|
||||||
|
Location
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<MapPin className="h-4 w-4 text-muted-foreground" />
|
||||||
|
{[orgDetails[0].townCity, orgDetails[0].county]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ")}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm text-foreground">
|
)}
|
||||||
<MapPin className="h-4 w-4 text-muted-foreground" />
|
|
||||||
{[orgDetails[0].townCity, orgDetails[0].county].filter(Boolean).join(", ")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Licence types / routes */}
|
{/* Licence types / routes */}
|
||||||
<div>
|
<div>
|
||||||
@@ -273,7 +287,9 @@ export const VisaSponsorsPage: React.FC = () => {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
<span className="font-medium text-foreground">Type & Rating:</span>{" "}
|
<span className="font-medium text-foreground">
|
||||||
|
Type & Rating:
|
||||||
|
</span>{" "}
|
||||||
{entry.typeRating}
|
{entry.typeRating}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -283,10 +299,13 @@ export const VisaSponsorsPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Info box */}
|
{/* Info box */}
|
||||||
<div className="rounded-lg border border-sky-500/30 bg-sky-500/10 p-3 text-sm">
|
<div className="rounded-lg border border-sky-500/30 bg-sky-500/10 p-3 text-sm">
|
||||||
<div className="font-medium text-sky-200 mb-1">What does this mean?</div>
|
<div className="font-medium text-sky-200 mb-1">
|
||||||
|
What does this mean?
|
||||||
|
</div>
|
||||||
<p className="text-xs text-sky-300/80">
|
<p className="text-xs text-sky-300/80">
|
||||||
This organisation is licensed by the UK Home Office to sponsor workers on the
|
This organisation is licensed by the UK Home Office to sponsor workers
|
||||||
routes listed above. An "A rating" means they're fully compliant.
|
on the routes listed above. An "A rating" means they're fully
|
||||||
|
compliant.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -298,7 +317,9 @@ export const VisaSponsorsPage: React.FC = () => {
|
|||||||
icon={Shield}
|
icon={Shield}
|
||||||
title="Visa Sponsors"
|
title="Visa Sponsors"
|
||||||
subtitle="UK Register Search"
|
subtitle="UK Register Search"
|
||||||
statusIndicator={isUpdateInProgress ? <StatusIndicator label="Updating" /> : undefined}
|
statusIndicator={
|
||||||
|
isUpdateInProgress ? <StatusIndicator label="Updating" /> : undefined
|
||||||
|
}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
{status && (
|
{status && (
|
||||||
@@ -356,7 +377,8 @@ export const VisaSponsorsPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Enter a company name to check if they're a licensed UK visa sponsor.
|
Enter a company name to check if they're a licensed UK visa
|
||||||
|
sponsor.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -383,7 +405,11 @@ export const VisaSponsorsPage: React.FC = () => {
|
|||||||
title="No sponsor data available"
|
title="No sponsor data available"
|
||||||
description="The visa sponsor list hasn't been downloaded yet."
|
description="The visa sponsor list hasn't been downloaded yet."
|
||||||
action={
|
action={
|
||||||
<Button size="sm" onClick={handleUpdate} disabled={isUpdating}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleUpdate}
|
||||||
|
disabled={isUpdating}
|
||||||
|
>
|
||||||
{isUpdating ? (
|
{isUpdating ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
@@ -421,7 +447,9 @@ export const VisaSponsorsPage: React.FC = () => {
|
|||||||
<ListItem
|
<ListItem
|
||||||
key={`${result.sponsor.organisationName}-${index}`}
|
key={`${result.sponsor.organisationName}-${index}`}
|
||||||
selected={selectedOrg === result.sponsor.organisationName}
|
selected={selectedOrg === result.sponsor.organisationName}
|
||||||
onClick={() => handleSelectOrg(result.sponsor.organisationName)}
|
onClick={() =>
|
||||||
|
handleSelectOrg(result.sponsor.organisationName)
|
||||||
|
}
|
||||||
className="gap-3"
|
className="gap-3"
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -458,7 +486,9 @@ export const VisaSponsorsPage: React.FC = () => {
|
|||||||
<Drawer open={isDetailDrawerOpen} onOpenChange={setIsDetailDrawerOpen}>
|
<Drawer open={isDetailDrawerOpen} onOpenChange={setIsDetailDrawerOpen}>
|
||||||
<DrawerContent className="max-h-[90vh]">
|
<DrawerContent className="max-h-[90vh]">
|
||||||
<div className="flex items-center justify-between px-4 pt-2">
|
<div className="flex items-center justify-between px-4 pt-2">
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Sponsor details</div>
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Sponsor details
|
||||||
|
</div>
|
||||||
<DrawerClose asChild>
|
<DrawerClose asChild>
|
||||||
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
|
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
|
||||||
Close
|
Close
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import React from "react";
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import type React from "react";
|
||||||
import { JobDetailPanel } from "./JobDetailPanel";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { Job } from "../../../shared/types";
|
import type { Job } from "../../../shared/types";
|
||||||
import * as api from "../../api";
|
import * as api from "../../api";
|
||||||
|
import { JobDetailPanel } from "./JobDetailPanel";
|
||||||
|
|
||||||
vi.mock("@/components/ui/dropdown-menu", () => {
|
vi.mock("@/components/ui/dropdown-menu", () => {
|
||||||
return {
|
return {
|
||||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
||||||
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
<div>{children}</div>
|
||||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>,
|
),
|
||||||
|
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<>{children}</>
|
||||||
|
),
|
||||||
|
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div role="menu">{children}</div>
|
||||||
|
),
|
||||||
DropdownMenuItem: ({
|
DropdownMenuItem: ({
|
||||||
children,
|
children,
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -19,7 +24,12 @@ vi.mock("@/components/ui/dropdown-menu", () => {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
onSelect?: () => void;
|
onSelect?: () => void;
|
||||||
}) => (
|
}) => (
|
||||||
<button type="button" role="menuitem" onClick={() => onSelect?.()} {...props}>
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => onSelect?.()}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
@@ -154,14 +164,12 @@ describe("JobDetailPanel", () => {
|
|||||||
onSelectJobId={vi.fn()}
|
onSelectJobId={vi.fn()}
|
||||||
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
|
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
|
||||||
onSetActiveTab={vi.fn()}
|
onSetActiveTab={vi.fn()}
|
||||||
/>
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId("discovered-panel")).toHaveTextContent("job-99");
|
expect(screen.getByTestId("discovered-panel")).toHaveTextContent("job-99");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
it("shows an empty state when no job is selected", () => {
|
it("shows an empty state when no job is selected", () => {
|
||||||
render(
|
render(
|
||||||
<JobDetailPanel
|
<JobDetailPanel
|
||||||
@@ -171,7 +179,7 @@ describe("JobDetailPanel", () => {
|
|||||||
onSelectJobId={vi.fn()}
|
onSelectJobId={vi.fn()}
|
||||||
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
|
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
|
||||||
onSetActiveTab={vi.fn()}
|
onSetActiveTab={vi.fn()}
|
||||||
/>
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("No job selected")).toBeInTheDocument();
|
expect(screen.getByText("No job selected")).toBeInTheDocument();
|
||||||
@@ -182,11 +190,13 @@ describe("JobDetailPanel", () => {
|
|||||||
<JobDetailPanel
|
<JobDetailPanel
|
||||||
activeTab="all"
|
activeTab="all"
|
||||||
activeJobs={[]}
|
activeJobs={[]}
|
||||||
selectedJob={createJob({ jobDescription: "<p>Hello <strong>world</strong></p>" })}
|
selectedJob={createJob({
|
||||||
|
jobDescription: "<p>Hello <strong>world</strong></p>",
|
||||||
|
})}
|
||||||
onSelectJobId={vi.fn()}
|
onSelectJobId={vi.fn()}
|
||||||
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
|
onJobUpdated={vi.fn().mockResolvedValue(undefined)}
|
||||||
onSetActiveTab={vi.fn()}
|
onSetActiveTab={vi.fn()}
|
||||||
/>
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("Hello world")).toBeInTheDocument();
|
expect(screen.getByText("Hello world")).toBeInTheDocument();
|
||||||
@@ -204,7 +214,7 @@ describe("JobDetailPanel", () => {
|
|||||||
onSelectJobId={vi.fn()}
|
onSelectJobId={vi.fn()}
|
||||||
onJobUpdated={onJobUpdated}
|
onJobUpdated={onJobUpdated}
|
||||||
onSetActiveTab={vi.fn()}
|
onSetActiveTab={vi.fn()}
|
||||||
/>
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.mouseDown(screen.getByRole("tab", { name: /description/i }));
|
fireEvent.mouseDown(screen.getByRole("tab", { name: /description/i }));
|
||||||
@@ -217,7 +227,9 @@ describe("JobDetailPanel", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: /save changes/i }));
|
fireEvent.click(screen.getByRole("button", { name: /save changes/i }));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(api.updateJob).toHaveBeenCalledWith("job-1", { jobDescription: "Updated description" })
|
expect(api.updateJob).toHaveBeenCalledWith("job-1", {
|
||||||
|
jobDescription: "Updated description",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
expect(onJobUpdated).toHaveBeenCalled();
|
expect(onJobUpdated).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -234,12 +246,14 @@ describe("JobDetailPanel", () => {
|
|||||||
onSelectJobId={vi.fn()}
|
onSelectJobId={vi.fn()}
|
||||||
onJobUpdated={onJobUpdated}
|
onJobUpdated={onJobUpdated}
|
||||||
onSetActiveTab={vi.fn()}
|
onSetActiveTab={vi.fn()}
|
||||||
/>
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /applied/i }));
|
fireEvent.click(screen.getByRole("button", { name: /applied/i }));
|
||||||
|
|
||||||
await waitFor(() => expect(api.markAsApplied).toHaveBeenCalledWith("job-1"));
|
await waitFor(() =>
|
||||||
|
expect(api.markAsApplied).toHaveBeenCalledWith("job-1"),
|
||||||
|
);
|
||||||
expect(onJobUpdated).toHaveBeenCalled();
|
expect(onJobUpdated).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -255,10 +269,12 @@ describe("JobDetailPanel", () => {
|
|||||||
onSelectJobId={vi.fn()}
|
onSelectJobId={vi.fn()}
|
||||||
onJobUpdated={onJobUpdated}
|
onJobUpdated={onJobUpdated}
|
||||||
onSetActiveTab={vi.fn()}
|
onSetActiveTab={vi.fn()}
|
||||||
/>
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.pointerDown(screen.getByRole("button", { name: /more actions/i }));
|
fireEvent.pointerDown(
|
||||||
|
screen.getByRole("button", { name: /more actions/i }),
|
||||||
|
);
|
||||||
const skipItem = await screen.findByRole("menuitem", { name: /skip job/i });
|
const skipItem = await screen.findByRole("menuitem", { name: /skip job/i });
|
||||||
fireEvent.click(skipItem);
|
fireEvent.click(skipItem);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Copy,
|
Copy,
|
||||||
@@ -11,6 +10,8 @@ import {
|
|||||||
Save,
|
Save,
|
||||||
XCircle,
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -25,14 +26,23 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { copyTextToClipboard, formatJobForWebhook, safeFilenamePart, stripHtml } from "@/lib/utils";
|
import {
|
||||||
|
copyTextToClipboard,
|
||||||
import { DiscoveredPanel, FitAssessment, JobHeader, TailoredSummary } from "../../components";
|
formatJobForWebhook,
|
||||||
|
safeFilenamePart,
|
||||||
|
stripHtml,
|
||||||
|
} from "@/lib/utils";
|
||||||
|
import type { Job } from "../../../shared/types";
|
||||||
|
import * as api from "../../api";
|
||||||
|
import {
|
||||||
|
DiscoveredPanel,
|
||||||
|
FitAssessment,
|
||||||
|
JobHeader,
|
||||||
|
TailoredSummary,
|
||||||
|
} from "../../components";
|
||||||
import { ReadyPanel } from "../../components/ReadyPanel";
|
import { ReadyPanel } from "../../components/ReadyPanel";
|
||||||
import { TailoringEditor } from "../../components/TailoringEditor";
|
import { TailoringEditor } from "../../components/TailoringEditor";
|
||||||
import { useProfile } from "../../hooks/useProfile";
|
import { useProfile } from "../../hooks/useProfile";
|
||||||
import * as api from "../../api";
|
|
||||||
import type { Job } from "../../../shared/types";
|
|
||||||
import type { FilterTab } from "./constants";
|
import type { FilterTab } from "./constants";
|
||||||
|
|
||||||
interface JobDetailPanelProps {
|
interface JobDetailPanelProps {
|
||||||
@@ -52,7 +62,9 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
onJobUpdated,
|
onJobUpdated,
|
||||||
onSetActiveTab,
|
onSetActiveTab,
|
||||||
}) => {
|
}) => {
|
||||||
const [detailTab, setDetailTab] = useState<"overview" | "tailoring" | "description">("overview");
|
const [detailTab, setDetailTab] = useState<
|
||||||
|
"overview" | "tailoring" | "description"
|
||||||
|
>("overview");
|
||||||
const [isEditingDescription, setIsEditingDescription] = useState(false);
|
const [isEditingDescription, setIsEditingDescription] = useState(false);
|
||||||
const [editedDescription, setEditedDescription] = useState("");
|
const [editedDescription, setEditedDescription] = useState("");
|
||||||
const [isSavingDescription, setIsSavingDescription] = useState(false);
|
const [isSavingDescription, setIsSavingDescription] = useState(false);
|
||||||
@@ -95,12 +107,15 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
if (!selectedJob) return;
|
if (!selectedJob) return;
|
||||||
try {
|
try {
|
||||||
setIsSavingDescription(true);
|
setIsSavingDescription(true);
|
||||||
await api.updateJob(selectedJob.id, { jobDescription: editedDescription });
|
await api.updateJob(selectedJob.id, {
|
||||||
|
jobDescription: editedDescription,
|
||||||
|
});
|
||||||
toast.success("Job description updated");
|
toast.success("Job description updated");
|
||||||
setIsEditingDescription(false);
|
setIsEditingDescription(false);
|
||||||
await onJobUpdated();
|
await onJobUpdated();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to update description";
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to update description";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingDescription(false);
|
setIsSavingDescription(false);
|
||||||
@@ -113,7 +128,11 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
editedDescription !== (selectedJob.jobDescription || "");
|
editedDescription !== (selectedJob.jobDescription || "");
|
||||||
|
|
||||||
const confirmAndSaveEdits = useCallback(
|
const confirmAndSaveEdits = useCallback(
|
||||||
async ({ includeTailoring = true }: { includeTailoring?: boolean } = {}) => {
|
async ({
|
||||||
|
includeTailoring = true,
|
||||||
|
}: {
|
||||||
|
includeTailoring?: boolean;
|
||||||
|
} = {}) => {
|
||||||
const pendingDescription = hasUnsavedDescription;
|
const pendingDescription = hasUnsavedDescription;
|
||||||
const pendingTailoring = includeTailoring && hasUnsavedTailoring;
|
const pendingTailoring = includeTailoring && hasUnsavedTailoring;
|
||||||
|
|
||||||
@@ -128,7 +147,9 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (pendingDescription && selectedJob) {
|
if (pendingDescription && selectedJob) {
|
||||||
await api.updateJob(selectedJob.id, { jobDescription: editedDescription });
|
await api.updateJob(selectedJob.id, {
|
||||||
|
jobDescription: editedDescription,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingTailoring) {
|
if (pendingTailoring) {
|
||||||
@@ -140,20 +161,28 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
await saveTailoring();
|
await saveTailoring();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "Failed to save changes";
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "Failed to save changes";
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[editedDescription, hasUnsavedDescription, hasUnsavedTailoring, selectedJob],
|
[
|
||||||
|
editedDescription,
|
||||||
|
hasUnsavedDescription,
|
||||||
|
hasUnsavedTailoring,
|
||||||
|
selectedJob,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleProcess = async () => {
|
const handleProcess = async () => {
|
||||||
if (!selectedJob) return;
|
if (!selectedJob) return;
|
||||||
try {
|
try {
|
||||||
const shouldProceed = await confirmAndSaveEdits({ includeTailoring: true });
|
const shouldProceed = await confirmAndSaveEdits({
|
||||||
|
includeTailoring: true,
|
||||||
|
});
|
||||||
if (!shouldProceed) return;
|
if (!shouldProceed) return;
|
||||||
|
|
||||||
setProcessingJobId(selectedJob.id);
|
setProcessingJobId(selectedJob.id);
|
||||||
@@ -167,7 +196,8 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
}
|
}
|
||||||
await onJobUpdated();
|
await onJobUpdated();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to process job";
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to process job";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setProcessingJobId(null);
|
setProcessingJobId(null);
|
||||||
@@ -181,7 +211,8 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
toast.success("Marked as applied");
|
toast.success("Marked as applied");
|
||||||
await onJobUpdated();
|
await onJobUpdated();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to mark as applied";
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to mark as applied";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -193,7 +224,8 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
toast.message("Job skipped");
|
toast.message("Job skipped");
|
||||||
await onJobUpdated();
|
await onJobUpdated();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to skip job";
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to skip job";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -202,7 +234,9 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
if (!selectedJob) return;
|
if (!selectedJob) return;
|
||||||
try {
|
try {
|
||||||
await copyTextToClipboard(formatJobForWebhook(selectedJob));
|
await copyTextToClipboard(formatJobForWebhook(selectedJob));
|
||||||
toast.success("Copied job info", { description: "Webhook payload copied to clipboard." });
|
toast.success("Copied job info", {
|
||||||
|
description: "Webhook payload copied to clipboard.",
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Could not copy job info");
|
toast.error("Could not copy job info");
|
||||||
}
|
}
|
||||||
@@ -211,24 +245,32 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
const handleJobMoved = useCallback(
|
const handleJobMoved = useCallback(
|
||||||
(jobId: string) => {
|
(jobId: string) => {
|
||||||
const currentIndex = activeJobs.findIndex((job) => job.id === jobId);
|
const currentIndex = activeJobs.findIndex((job) => job.id === jobId);
|
||||||
const nextJob = activeJobs[currentIndex + 1] || activeJobs[currentIndex - 1];
|
const nextJob =
|
||||||
|
activeJobs[currentIndex + 1] || activeJobs[currentIndex - 1];
|
||||||
onSelectJobId(nextJob?.id ?? null);
|
onSelectJobId(nextJob?.id ?? null);
|
||||||
},
|
},
|
||||||
[activeJobs, onSelectJobId],
|
[activeJobs, onSelectJobId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedHasPdf = !!selectedJob?.pdfPath;
|
const selectedHasPdf = !!selectedJob?.pdfPath;
|
||||||
const selectedJobLink = selectedJob ? selectedJob.applicationLink || selectedJob.jobUrl : "#";
|
const selectedJobLink = selectedJob
|
||||||
|
? selectedJob.applicationLink || selectedJob.jobUrl
|
||||||
|
: "#";
|
||||||
const selectedPdfHref = selectedJob
|
const selectedPdfHref = selectedJob
|
||||||
? `/pdfs/resume_${selectedJob.id}.pdf?v=${encodeURIComponent(selectedJob.updatedAt)}`
|
? `/pdfs/resume_${selectedJob.id}.pdf?v=${encodeURIComponent(selectedJob.updatedAt)}`
|
||||||
: "#";
|
: "#";
|
||||||
const canApply = selectedJob?.status === "ready";
|
const canApply = selectedJob?.status === "ready";
|
||||||
const canProcess = selectedJob ? ["discovered", "ready"].includes(selectedJob.status) : false;
|
const canProcess = selectedJob
|
||||||
const canSkip = selectedJob ? ["discovered", "ready"].includes(selectedJob.status) : false;
|
? ["discovered", "ready"].includes(selectedJob.status)
|
||||||
|
: false;
|
||||||
|
const canSkip = selectedJob
|
||||||
|
? ["discovered", "ready"].includes(selectedJob.status)
|
||||||
|
: false;
|
||||||
const showReadyPdf = activeTab === "ready";
|
const showReadyPdf = activeTab === "ready";
|
||||||
const showGeneratePdf = activeTab === "discovered";
|
const showGeneratePdf = activeTab === "discovered";
|
||||||
const isProcessingSelected =
|
const isProcessingSelected = selectedJob
|
||||||
selectedJob ? processingJobId === selectedJob.id || selectedJob.status === "processing" : false;
|
? processingJobId === selectedJob.id || selectedJob.status === "processing"
|
||||||
|
: false;
|
||||||
|
|
||||||
if (activeTab === "discovered") {
|
if (activeTab === "discovered") {
|
||||||
return (
|
return (
|
||||||
@@ -253,8 +295,12 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
if (!selectedJob) {
|
if (!selectedJob) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-1 text-center">
|
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-1 text-center">
|
||||||
<div className="text-sm font-medium text-muted-foreground">No job selected</div>
|
<div className="text-sm font-medium text-muted-foreground">
|
||||||
<p className="text-xs text-muted-foreground/70">Select a job to view details</p>
|
No job selected
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground/70">
|
||||||
|
Select a job to view details
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -270,7 +316,12 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<Button asChild size="sm" variant="ghost" className="h-8 gap-1.5 text-xs">
|
<Button
|
||||||
|
asChild
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 gap-1.5 text-xs"
|
||||||
|
>
|
||||||
<a href={selectedJobLink} target="_blank" rel="noopener noreferrer">
|
<a href={selectedJobLink} target="_blank" rel="noopener noreferrer">
|
||||||
<ExternalLink className="h-3.5 w-3.5" />
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
View
|
View
|
||||||
@@ -279,14 +330,28 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
|
|
||||||
{showReadyPdf &&
|
{showReadyPdf &&
|
||||||
(selectedHasPdf ? (
|
(selectedHasPdf ? (
|
||||||
<Button asChild size="sm" variant="ghost" className="h-8 gap-1.5 text-xs">
|
<Button
|
||||||
<a href={selectedPdfHref} target="_blank" rel="noopener noreferrer">
|
asChild
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 gap-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={selectedPdfHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
<FileText className="h-3.5 w-3.5" />
|
<FileText className="h-3.5 w-3.5" />
|
||||||
PDF
|
PDF
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button size="sm" variant="ghost" className="h-8 gap-1.5 text-xs" disabled>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 gap-1.5 text-xs"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
<FileText className="h-3.5 w-3.5" />
|
<FileText className="h-3.5 w-3.5" />
|
||||||
PDF
|
PDF
|
||||||
</Button>
|
</Button>
|
||||||
@@ -357,7 +422,11 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
<>
|
<>
|
||||||
{!showReadyPdf && (
|
{!showReadyPdf && (
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<a href={selectedPdfHref} target="_blank" rel="noopener noreferrer">
|
<a
|
||||||
|
href={selectedPdfHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
<ExternalLink className="mr-2 h-4 w-4" />
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
View PDF
|
View PDF
|
||||||
</a>
|
</a>
|
||||||
@@ -366,7 +435,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<a
|
<a
|
||||||
href={selectedPdfHref}
|
href={selectedPdfHref}
|
||||||
download={`${personName.replace(/\s+/g, '_')}_${safeFilenamePart(selectedJob.employer)}.pdf`}
|
download={`${personName.replace(/\s+/g, "_")}_${safeFilenamePart(selectedJob.employer)}.pdf`}
|
||||||
>
|
>
|
||||||
<FileText className="mr-2 h-4 w-4" />
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
Download PDF
|
Download PDF
|
||||||
@@ -389,11 +458,20 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
<Tabs value={detailTab} onValueChange={(value) => setDetailTab(value as typeof detailTab)}>
|
<Tabs
|
||||||
|
value={detailTab}
|
||||||
|
onValueChange={(value) => setDetailTab(value as typeof detailTab)}
|
||||||
|
>
|
||||||
<TabsList className="h-auto flex-wrap justify-start gap-1 text-xs">
|
<TabsList className="h-auto flex-wrap justify-start gap-1 text-xs">
|
||||||
<TabsTrigger value="overview" className="text-xs">Overview</TabsTrigger>
|
<TabsTrigger value="overview" className="text-xs">
|
||||||
<TabsTrigger value="tailoring" className="text-xs">Tailoring</TabsTrigger>
|
Overview
|
||||||
<TabsTrigger value="description" className="text-xs">Description</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="tailoring" className="text-xs">
|
||||||
|
Tailoring
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="description" className="text-xs">
|
||||||
|
Description
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="overview" className="space-y-3 pt-2">
|
<TabsContent value="overview" className="space-y-3 pt-2">
|
||||||
@@ -402,20 +480,36 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
|
|
||||||
<div className="grid gap-2 text-xs sm:grid-cols-2">
|
<div className="grid gap-2 text-xs sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Discipline</div>
|
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">
|
||||||
<div className="text-foreground/80">{selectedJob.disciplines || "-"}</div>
|
Discipline
|
||||||
|
</div>
|
||||||
|
<div className="text-foreground/80">
|
||||||
|
{selectedJob.disciplines || "-"}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Function</div>
|
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">
|
||||||
<div className="text-foreground/80">{selectedJob.jobFunction || "-"}</div>
|
Function
|
||||||
|
</div>
|
||||||
|
<div className="text-foreground/80">
|
||||||
|
{selectedJob.jobFunction || "-"}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Level</div>
|
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">
|
||||||
<div className="text-foreground/80">{selectedJob.jobLevel || "-"}</div>
|
Level
|
||||||
|
</div>
|
||||||
|
<div className="text-foreground/80">
|
||||||
|
{selectedJob.jobLevel || "-"}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">Type</div>
|
<div className="text-[10px] text-muted-foreground/70 uppercase tracking-wide">
|
||||||
<div className="text-foreground/80">{selectedJob.jobType || "-"}</div>
|
Type
|
||||||
|
</div>
|
||||||
|
<div className="text-foreground/80">
|
||||||
|
{selectedJob.jobType || "-"}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -433,7 +527,7 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
className="text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
className="text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||||
onClick={() => setDetailTab("description")}
|
onClick={() => setDetailTab("description")}
|
||||||
>
|
>
|
||||||
View full description
|
View full description
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -447,7 +541,9 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
onRegisterSave={(save) => {
|
onRegisterSave={(save) => {
|
||||||
saveTailoringRef.current = save;
|
saveTailoringRef.current = save;
|
||||||
}}
|
}}
|
||||||
onBeforeGenerate={() => confirmAndSaveEdits({ includeTailoring: false })}
|
onBeforeGenerate={() =>
|
||||||
|
confirmAndSaveEdits({ includeTailoring: false })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -499,14 +595,21 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
)}
|
)}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button size="icon" variant="ghost" className="h-8 w-8" aria-label="Description actions">
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8"
|
||||||
|
aria-label="Description actions"
|
||||||
|
>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
void copyTextToClipboard(selectedJob.jobDescription || "");
|
void copyTextToClipboard(
|
||||||
|
selectedJob.jobDescription || "",
|
||||||
|
);
|
||||||
toast.success("Copied raw description");
|
toast.success("Copied raw description");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -555,7 +658,9 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="whitespace-pre-wrap leading-relaxed">
|
<div className="whitespace-pre-wrap leading-relaxed">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{description}</ReactMarkdown>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{description}
|
||||||
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { JobListPanel } from "./JobListPanel";
|
|
||||||
import type { Job } from "../../../shared/types";
|
import type { Job } from "../../../shared/types";
|
||||||
|
import { JobListPanel } from "./JobListPanel";
|
||||||
|
|
||||||
const createJob = (overrides: Partial<Job> = {}): Job => ({
|
const createJob = (overrides: Partial<Job> = {}): Job => ({
|
||||||
id: "job-1",
|
id: "job-1",
|
||||||
@@ -76,7 +75,7 @@ describe("JobListPanel", () => {
|
|||||||
activeTab="ready"
|
activeTab="ready"
|
||||||
searchQuery=""
|
searchQuery=""
|
||||||
onSelectJob={vi.fn()}
|
onSelectJob={vi.fn()}
|
||||||
/>
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("Loading jobs...")).toBeInTheDocument();
|
expect(screen.getByText("Loading jobs...")).toBeInTheDocument();
|
||||||
@@ -92,11 +91,13 @@ describe("JobListPanel", () => {
|
|||||||
activeTab="ready"
|
activeTab="ready"
|
||||||
searchQuery=""
|
searchQuery=""
|
||||||
onSelectJob={vi.fn()}
|
onSelectJob={vi.fn()}
|
||||||
/>
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("No jobs found")).toBeInTheDocument();
|
expect(screen.getByText("No jobs found")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Run the pipeline to discover and process new jobs.")).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByText("Run the pipeline to discover and process new jobs."),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the query-specific empty state when searching", () => {
|
it("shows the query-specific empty state when searching", () => {
|
||||||
@@ -109,7 +110,7 @@ describe("JobListPanel", () => {
|
|||||||
activeTab="ready"
|
activeTab="ready"
|
||||||
searchQuery="iOS"
|
searchQuery="iOS"
|
||||||
onSelectJob={vi.fn()}
|
onSelectJob={vi.fn()}
|
||||||
/>
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText('No jobs match "iOS".')).toBeInTheDocument();
|
expect(screen.getByText('No jobs match "iOS".')).toBeInTheDocument();
|
||||||
@@ -119,7 +120,11 @@ describe("JobListPanel", () => {
|
|||||||
const onSelectJob = vi.fn();
|
const onSelectJob = vi.fn();
|
||||||
const jobs = [
|
const jobs = [
|
||||||
createJob({ id: "job-1", title: "Backend Engineer" }),
|
createJob({ id: "job-1", title: "Backend Engineer" }),
|
||||||
createJob({ id: "job-2", title: "Frontend Engineer", employer: "Globex" }),
|
createJob({
|
||||||
|
id: "job-2",
|
||||||
|
title: "Frontend Engineer",
|
||||||
|
employer: "Globex",
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -131,10 +136,12 @@ describe("JobListPanel", () => {
|
|||||||
activeTab="ready"
|
activeTab="ready"
|
||||||
searchQuery=""
|
searchQuery=""
|
||||||
onSelectJob={onSelectJob}
|
onSelectJob={onSelectJob}
|
||||||
/>
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: /Backend Engineer/i })).toHaveAttribute("aria-pressed", "true");
|
expect(
|
||||||
|
screen.getByRole("button", { name: /Backend Engineer/i }),
|
||||||
|
).toHaveAttribute("aria-pressed", "true");
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /Frontend Engineer/i }));
|
fireEvent.click(screen.getByRole("button", { name: /Frontend Engineer/i }));
|
||||||
expect(onSelectJob).toHaveBeenCalledWith("job-2");
|
expect(onSelectJob).toHaveBeenCalledWith("job-2");
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React from "react";
|
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import type { Job } from "../../../shared/types";
|
import type { Job } from "../../../shared/types";
|
||||||
import { defaultStatusToken, emptyStateCopy, statusTokens } from "./constants";
|
|
||||||
import type { FilterTab } from "./constants";
|
import type { FilterTab } from "./constants";
|
||||||
|
import { defaultStatusToken, emptyStateCopy, statusTokens } from "./constants";
|
||||||
|
|
||||||
interface JobListPanelProps {
|
interface JobListPanelProps {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -36,7 +36,9 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
|
|||||||
<div className="flex flex-col items-center justify-center gap-2 px-6 py-12 text-center">
|
<div className="flex flex-col items-center justify-center gap-2 px-6 py-12 text-center">
|
||||||
<div className="text-base font-semibold">No jobs found</div>
|
<div className="text-base font-semibold">No jobs found</div>
|
||||||
<p className="max-w-md text-sm text-muted-foreground">
|
<p className="max-w-md text-sm text-muted-foreground">
|
||||||
{searchQuery.trim() ? `No jobs match "${searchQuery.trim()}".` : emptyStateCopy[activeTab]}
|
{searchQuery.trim()
|
||||||
|
? `No jobs match "${searchQuery.trim()}".`
|
||||||
|
: emptyStateCopy[activeTab]}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -81,7 +83,11 @@ export const JobListPanel: React.FC<JobListPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="truncate text-xs text-muted-foreground mt-0.5">
|
<div className="truncate text-xs text-muted-foreground mt-0.5">
|
||||||
{job.employer}
|
{job.employer}
|
||||||
{job.location && <span className="before:content-['_in_']">{job.location}</span>}
|
{job.location && (
|
||||||
|
<span className="before:content-['_in_']">
|
||||||
|
{job.location}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
import React from "react";
|
|
||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps } from "react";
|
||||||
|
import React from "react";
|
||||||
import { OrchestratorFilters } from "./OrchestratorFilters";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { FilterTab, JobSort } from "./constants";
|
import type { FilterTab, JobSort } from "./constants";
|
||||||
|
import { OrchestratorFilters } from "./OrchestratorFilters";
|
||||||
|
|
||||||
vi.mock("@/components/ui/dropdown-menu", () => {
|
vi.mock("@/components/ui/dropdown-menu", () => {
|
||||||
const React = require("react") as typeof import("react");
|
const React = require("react") as typeof import("react");
|
||||||
const RadioGroupContext = React.createContext<((value: string) => void) | null>(null);
|
const RadioGroupContext = React.createContext<
|
||||||
|
((value: string) => void) | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
||||||
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
<div>{children}</div>
|
||||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>,
|
),
|
||||||
|
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<>{children}</>
|
||||||
|
),
|
||||||
|
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div role="menu">{children}</div>
|
||||||
|
),
|
||||||
DropdownMenuItem: ({
|
DropdownMenuItem: ({
|
||||||
children,
|
children,
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -23,11 +29,18 @@ vi.mock("@/components/ui/dropdown-menu", () => {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
onSelect?: () => void;
|
onSelect?: () => void;
|
||||||
}) => (
|
}) => (
|
||||||
<button type="button" role="menuitem" onClick={() => onSelect?.()} {...props}>
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => onSelect?.()}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
DropdownMenuSeparator: () => <div role="separator" />,
|
DropdownMenuSeparator: () => <div role="separator" />,
|
||||||
DropdownMenuRadioGroup: ({
|
DropdownMenuRadioGroup: ({
|
||||||
children,
|
children,
|
||||||
@@ -49,7 +62,11 @@ vi.mock("@/components/ui/dropdown-menu", () => {
|
|||||||
}) => {
|
}) => {
|
||||||
const onValueChange = React.useContext(RadioGroupContext);
|
const onValueChange = React.useContext(RadioGroupContext);
|
||||||
return (
|
return (
|
||||||
<button type="button" role="menuitemradio" onClick={() => onValueChange?.(value)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitemradio"
|
||||||
|
onClick={() => onValueChange?.(value)}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -57,7 +74,9 @@ vi.mock("@/components/ui/dropdown-menu", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderFilters = (overrides?: Partial<ComponentProps<typeof OrchestratorFilters>>) => {
|
const renderFilters = (
|
||||||
|
overrides?: Partial<ComponentProps<typeof OrchestratorFilters>>,
|
||||||
|
) => {
|
||||||
const props = {
|
const props = {
|
||||||
activeTab: "ready" as FilterTab,
|
activeTab: "ready" as FilterTab,
|
||||||
onTabChange: vi.fn(),
|
onTabChange: vi.fn(),
|
||||||
@@ -90,7 +109,9 @@ describe("OrchestratorFilters", () => {
|
|||||||
fireEvent.mouseDown(screen.getByRole("tab", { name: /applied/i }));
|
fireEvent.mouseDown(screen.getByRole("tab", { name: /applied/i }));
|
||||||
expect(props.onTabChange).toHaveBeenCalledWith("applied");
|
expect(props.onTabChange).toHaveBeenCalledWith("applied");
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("Search..."), { target: { value: "Design" } });
|
fireEvent.change(screen.getByPlaceholderText("Search..."), {
|
||||||
|
target: { value: "Design" },
|
||||||
|
});
|
||||||
expect(props.onSearchQueryChange).toHaveBeenCalledWith("Design");
|
expect(props.onSearchQueryChange).toHaveBeenCalledWith("Design");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,12 +119,19 @@ describe("OrchestratorFilters", () => {
|
|||||||
const { props } = renderFilters();
|
const { props } = renderFilters();
|
||||||
|
|
||||||
fireEvent.pointerDown(screen.getByRole("button", { name: /all sources/i }));
|
fireEvent.pointerDown(screen.getByRole("button", { name: /all sources/i }));
|
||||||
fireEvent.click(await screen.findByRole("menuitemradio", { name: /LinkedIn/i }));
|
fireEvent.click(
|
||||||
|
await screen.findByRole("menuitemradio", { name: /LinkedIn/i }),
|
||||||
|
);
|
||||||
expect(props.onSourceFilterChange).toHaveBeenCalledWith("linkedin");
|
expect(props.onSourceFilterChange).toHaveBeenCalledWith("linkedin");
|
||||||
|
|
||||||
fireEvent.pointerDown(screen.getByRole("button", { name: /score/i }));
|
fireEvent.pointerDown(screen.getByRole("button", { name: /score/i }));
|
||||||
fireEvent.click(await screen.findByRole("menuitem", { name: /Direction:/i }));
|
fireEvent.click(
|
||||||
expect(props.onSortChange).toHaveBeenCalledWith({ key: "score", direction: "asc" });
|
await screen.findByRole("menuitem", { name: /Direction:/i }),
|
||||||
|
);
|
||||||
|
expect(props.onSortChange).toHaveBeenCalledWith({
|
||||||
|
key: "score",
|
||||||
|
direction: "asc",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("only shows sources that exist in jobs", async () => {
|
it("only shows sources that exist in jobs", async () => {
|
||||||
@@ -111,9 +139,17 @@ describe("OrchestratorFilters", () => {
|
|||||||
|
|
||||||
fireEvent.pointerDown(screen.getByRole("button", { name: /all sources/i }));
|
fireEvent.pointerDown(screen.getByRole("button", { name: /all sources/i }));
|
||||||
|
|
||||||
expect(await screen.findByRole("menuitemradio", { name: /Gradcracker/i })).toBeInTheDocument();
|
expect(
|
||||||
expect(screen.queryByRole("menuitemradio", { name: /LinkedIn/i })).not.toBeInTheDocument();
|
await screen.findByRole("menuitemradio", { name: /Gradcracker/i }),
|
||||||
expect(screen.queryByRole("menuitemradio", { name: /UK Visa Jobs/i })).not.toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByRole("menuitemradio", { name: /Manual/i })).toBeInTheDocument();
|
expect(
|
||||||
|
screen.queryByRole("menuitemradio", { name: /LinkedIn/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("menuitemradio", { name: /UK Visa Jobs/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("menuitemradio", { name: /Manual/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
|
||||||
import { ArrowUpDown, Filter, Search } from "lucide-react";
|
import { ArrowUpDown, Filter, Search } from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -17,8 +17,13 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||||||
|
|
||||||
import { sourceLabel } from "@/lib/utils";
|
import { sourceLabel } from "@/lib/utils";
|
||||||
import type { JobSource } from "../../../shared/types";
|
import type { JobSource } from "../../../shared/types";
|
||||||
import { defaultSortDirection, orderedFilterSources, sortLabels, tabs } from "./constants";
|
|
||||||
import type { FilterTab, JobSort } from "./constants";
|
import type { FilterTab, JobSort } from "./constants";
|
||||||
|
import {
|
||||||
|
defaultSortDirection,
|
||||||
|
orderedFilterSources,
|
||||||
|
sortLabels,
|
||||||
|
tabs,
|
||||||
|
} from "./constants";
|
||||||
|
|
||||||
interface OrchestratorFiltersProps {
|
interface OrchestratorFiltersProps {
|
||||||
activeTab: FilterTab;
|
activeTab: FilterTab;
|
||||||
@@ -45,104 +50,124 @@ export const OrchestratorFilters: React.FC<OrchestratorFiltersProps> = ({
|
|||||||
sort,
|
sort,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
}) => {
|
}) => {
|
||||||
const visibleSources = orderedFilterSources.filter((source) => sourcesWithJobs.includes(source));
|
const visibleSources = orderedFilterSources.filter((source) =>
|
||||||
|
sourcesWithJobs.includes(source),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs value={activeTab} onValueChange={(value) => onTabChange(value as FilterTab)}>
|
<Tabs
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
value={activeTab}
|
||||||
<TabsList className="h-auto w-full flex-wrap justify-start gap-1 lg:w-auto">
|
onValueChange={(value) => onTabChange(value as FilterTab)}
|
||||||
{tabs.map((tab) => (
|
>
|
||||||
<TabsTrigger key={tab.id} value={tab.id} className="flex-1 flex items-center lg:flex-none gap-1.5">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<span>{tab.label}</span>
|
<TabsList className="h-auto w-full flex-wrap justify-start gap-1 lg:w-auto">
|
||||||
{counts[tab.id] > 0 && (
|
{tabs.map((tab) => (
|
||||||
<span className="text-[10px] mt-[2px] tabular-nums opacity-60">{counts[tab.id]}</span>
|
<TabsTrigger
|
||||||
)}
|
key={tab.id}
|
||||||
</TabsTrigger>
|
value={tab.id}
|
||||||
))}
|
className="flex-1 flex items-center lg:flex-none gap-1.5"
|
||||||
</TabsList>
|
>
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
{counts[tab.id] > 0 && (
|
||||||
|
<span className="text-[10px] mt-[2px] tabular-nums opacity-60">
|
||||||
|
{counts[tab.id]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
<div className="flex lg:flex-nowrap flex-wrap items-center justify-end gap-2">
|
<div className="flex lg:flex-nowrap flex-wrap items-center justify-end gap-2">
|
||||||
<div className="relative w-full flex-1 min-w-[180px] lg:max-w-[240px] lg:flex-none">
|
<div className="relative w-full flex-1 min-w-[180px] lg:max-w-[240px] lg:flex-none">
|
||||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" />
|
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/60" />
|
||||||
<Input
|
<Input
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(event) => onSearchQueryChange(event.target.value)}
|
onChange={(event) => onSearchQueryChange(event.target.value)}
|
||||||
placeholder="Search..."
|
placeholder="Search..."
|
||||||
className="h-8 pl-8 text-sm"
|
className="h-8 pl-8 text-sm"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground w-auto"
|
||||||
|
>
|
||||||
|
<Filter className="h-3.5 w-3.5" />
|
||||||
|
{sourceFilter === "all"
|
||||||
|
? "All sources"
|
||||||
|
: sourceLabel[sourceFilter]}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
<DropdownMenuLabel>Filter by source</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={sourceFilter}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onSourceFilterChange(value as JobSource | "all")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenuRadioItem value="all">
|
||||||
|
All Sources
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
{visibleSources.map((source) => (
|
||||||
|
<DropdownMenuRadioItem key={source} value={source}>
|
||||||
|
{sourceLabel[source]}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground w-auto"
|
||||||
|
>
|
||||||
|
<ArrowUpDown className="h-3.5 w-3.5" />
|
||||||
|
{sortLabels[sort.key]}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={sort.key}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onSortChange({
|
||||||
|
key: value as JobSort["key"],
|
||||||
|
direction: defaultSortDirection[value as JobSort["key"]],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(Object.keys(sortLabels) as Array<JobSort["key"]>).map(
|
||||||
|
(key) => (
|
||||||
|
<DropdownMenuRadioItem key={key} value={key}>
|
||||||
|
{sortLabels[key]}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() =>
|
||||||
|
onSortChange({
|
||||||
|
...sort,
|
||||||
|
direction: sort.direction === "asc" ? "desc" : "asc",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Direction:{" "}
|
||||||
|
{sort.direction === "asc" ? "Ascending" : "Descending"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground w-auto"
|
|
||||||
>
|
|
||||||
<Filter className="h-3.5 w-3.5" />
|
|
||||||
{sourceFilter === "all" ? "All sources" : sourceLabel[sourceFilter]}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
|
||||||
<DropdownMenuLabel>Filter by source</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuRadioGroup
|
|
||||||
value={sourceFilter}
|
|
||||||
onValueChange={(value) => onSourceFilterChange(value as JobSource | "all")}
|
|
||||||
>
|
|
||||||
<DropdownMenuRadioItem value="all">All Sources</DropdownMenuRadioItem>
|
|
||||||
{visibleSources.map((source) => (
|
|
||||||
<DropdownMenuRadioItem key={source} value={source}>
|
|
||||||
{sourceLabel[source]}
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuRadioGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground w-auto"
|
|
||||||
>
|
|
||||||
<ArrowUpDown className="h-3.5 w-3.5" />
|
|
||||||
{sortLabels[sort.key]}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
|
||||||
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuRadioGroup
|
|
||||||
value={sort.key}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
onSortChange({
|
|
||||||
key: value as JobSort["key"],
|
|
||||||
direction: defaultSortDirection[value as JobSort["key"]],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(Object.keys(sortLabels) as Array<JobSort["key"]>).map((key) => (
|
|
||||||
<DropdownMenuRadioItem key={key} value={key}>
|
|
||||||
{sortLabels[key]}
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuRadioGroup>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() =>
|
|
||||||
onSortChange({
|
|
||||||
...sort,
|
|
||||||
direction: sort.direction === "asc" ? "desc" : "asc",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Direction: {sort.direction === "asc" ? "Ascending" : "Descending"}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Tabs>
|
||||||
</Tabs>
|
);
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
|
||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import type React from "react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { OrchestratorHeader } from "./OrchestratorHeader";
|
import { OrchestratorHeader } from "./OrchestratorHeader";
|
||||||
|
|
||||||
@@ -9,16 +9,32 @@ vi.mock("@/components/ui/dropdown-menu", () => {
|
|||||||
const React = require("react") as typeof import("react");
|
const React = require("react") as typeof import("react");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
DropdownMenu: ({ children }: { children: React.ReactNode }) => (
|
||||||
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
<div>{children}</div>
|
||||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div role="menu">{children}</div>,
|
),
|
||||||
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<>{children}</>
|
||||||
|
),
|
||||||
|
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div role="menu">{children}</div>
|
||||||
|
),
|
||||||
|
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
DropdownMenuSeparator: () => <div role="separator" />,
|
DropdownMenuSeparator: () => <div role="separator" />,
|
||||||
DropdownMenuItem: ({ children, onSelect }: { children: React.ReactNode; onSelect?: (event: Event) => void }) => (
|
DropdownMenuItem: ({
|
||||||
|
children,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onSelect?: (event: Event) => void;
|
||||||
|
}) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
onClick={() => onSelect?.({ preventDefault: () => {} } as unknown as Event)}
|
onClick={() =>
|
||||||
|
onSelect?.({ preventDefault: () => {} } as unknown as Event)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
@@ -30,7 +46,11 @@ vi.mock("@/components/ui/dropdown-menu", () => {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
onCheckedChange?: (checked: boolean) => void;
|
onCheckedChange?: (checked: boolean) => void;
|
||||||
}) => (
|
}) => (
|
||||||
<button type="button" role="menuitemcheckbox" onClick={() => onCheckedChange?.(true)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitemcheckbox"
|
||||||
|
onClick={() => onCheckedChange?.(true)}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
@@ -39,13 +59,23 @@ vi.mock("@/components/ui/dropdown-menu", () => {
|
|||||||
|
|
||||||
vi.mock("@/components/ui/sheet", () => ({
|
vi.mock("@/components/ui/sheet", () => ({
|
||||||
Sheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
Sheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
SheetTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
SheetTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||||
SheetContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
<div>{children}</div>
|
||||||
SheetHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
),
|
||||||
SheetTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
SheetContent: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
SheetHeader: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
SheetTitle: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const renderHeader = (overrides: Partial<React.ComponentProps<typeof OrchestratorHeader>> = {}) => {
|
const renderHeader = (
|
||||||
|
overrides: Partial<React.ComponentProps<typeof OrchestratorHeader>> = {},
|
||||||
|
) => {
|
||||||
const props: React.ComponentProps<typeof OrchestratorHeader> = {
|
const props: React.ComponentProps<typeof OrchestratorHeader> = {
|
||||||
navOpen: false,
|
navOpen: false,
|
||||||
onNavOpenChange: vi.fn(),
|
onNavOpenChange: vi.fn(),
|
||||||
@@ -64,32 +94,52 @@ const renderHeader = (overrides: Partial<React.ComponentProps<typeof Orchestrato
|
|||||||
...render(
|
...render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<OrchestratorHeader {...props} />
|
<OrchestratorHeader {...props} />
|
||||||
</MemoryRouter>
|
</MemoryRouter>,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("OrchestratorHeader", () => {
|
describe("OrchestratorHeader", () => {
|
||||||
it("renders only enabled sources", () => {
|
it("renders only enabled sources", () => {
|
||||||
renderHeader({ enabledSources: ["gradcracker", "linkedin"], pipelineSources: ["linkedin"] });
|
renderHeader({
|
||||||
|
enabledSources: ["gradcracker", "linkedin"],
|
||||||
|
pipelineSources: ["linkedin"],
|
||||||
|
});
|
||||||
|
|
||||||
expect(screen.getByRole("menuitemcheckbox", { name: /Gradcracker/i })).toBeInTheDocument();
|
expect(
|
||||||
expect(screen.getByRole("menuitemcheckbox", { name: /LinkedIn/i })).toBeInTheDocument();
|
screen.getByRole("menuitemcheckbox", { name: /Gradcracker/i }),
|
||||||
expect(screen.queryByRole("menuitemcheckbox", { name: /UK Visa Jobs/i })).not.toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("menuitemcheckbox", { name: /LinkedIn/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("menuitemcheckbox", { name: /UK Visa Jobs/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses enabled sources for the all sources action", () => {
|
it("uses enabled sources for the all sources action", () => {
|
||||||
const { props } = renderHeader({ enabledSources: ["gradcracker", "linkedin"] });
|
const { props } = renderHeader({
|
||||||
|
enabledSources: ["gradcracker", "linkedin"],
|
||||||
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("menuitemcheckbox", { name: /Select all sources/i }));
|
fireEvent.click(
|
||||||
|
screen.getByRole("menuitemcheckbox", { name: /Select all sources/i }),
|
||||||
|
);
|
||||||
|
|
||||||
expect(props.onSetPipelineSources).toHaveBeenCalledWith(["gradcracker", "linkedin"]);
|
expect(props.onSetPipelineSources).toHaveBeenCalledWith([
|
||||||
|
"gradcracker",
|
||||||
|
"linkedin",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not show source presets", () => {
|
it("does not show source presets", () => {
|
||||||
renderHeader({ enabledSources: ["gradcracker", "linkedin"] });
|
renderHeader({ enabledSources: ["gradcracker", "linkedin"] });
|
||||||
|
|
||||||
expect(screen.queryByRole("menuitem", { name: /Gradcracker only/i })).not.toBeInTheDocument();
|
expect(
|
||||||
expect(screen.queryByRole("menuitem", { name: /Indeed \+ LinkedIn only/i })).not.toBeInTheDocument();
|
screen.queryByRole("menuitem", { name: /Gradcracker only/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("menuitem", { name: /Indeed \+ LinkedIn only/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
import {
|
||||||
Briefcase,
|
Briefcase,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -11,6 +10,7 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -66,9 +66,12 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const visibleSources = orderedSources.filter((source) => enabledSources.includes(source));
|
const visibleSources = orderedSources.filter((source) =>
|
||||||
|
enabledSources.includes(source),
|
||||||
|
);
|
||||||
const allSourcesSelected =
|
const allSourcesSelected =
|
||||||
visibleSources.length > 0 && visibleSources.every((source) => pipelineSources.includes(source));
|
visibleSources.length > 0 &&
|
||||||
|
visibleSources.every((source) => pipelineSources.includes(source));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
@@ -99,7 +102,16 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
|
|||||||
setTimeout(() => navigate(to), 150);
|
setTimeout(() => navigate(to), 150);
|
||||||
}}
|
}}
|
||||||
className={`flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left ${
|
className={`flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left ${
|
||||||
location.pathname === to || (to === "/" && ["/ready", "/discovered", "/applied", "/all"].includes(location.pathname))
|
location.pathname === to ||
|
||||||
|
(
|
||||||
|
to === "/" &&
|
||||||
|
[
|
||||||
|
"/ready",
|
||||||
|
"/discovered",
|
||||||
|
"/applied",
|
||||||
|
"/all",
|
||||||
|
].includes(location.pathname)
|
||||||
|
)
|
||||||
? "bg-accent text-accent-foreground"
|
? "bg-accent text-accent-foreground"
|
||||||
: "text-muted-foreground"
|
: "text-muted-foreground"
|
||||||
}`}
|
}`}
|
||||||
@@ -117,7 +129,9 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
|
|||||||
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 leading-tight">
|
<div className="min-w-0 leading-tight">
|
||||||
<div className="text-sm font-semibold tracking-tight">Job Ops</div>
|
<div className="text-sm font-semibold tracking-tight">
|
||||||
|
Job Ops
|
||||||
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">Orchestrator</div>
|
<div className="text-xs text-muted-foreground">Orchestrator</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,9 +161,15 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
|
|||||||
disabled={isPipelineRunning}
|
disabled={isPipelineRunning}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
{isPipelineRunning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
|
{isPipelineRunning ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
)}
|
||||||
<span className="hidden sm:inline">
|
<span className="hidden sm:inline">
|
||||||
{isPipelineRunning ? `Running (${pipelineSources.length})` : `Run pipeline (${pipelineSources.length})`}
|
{isPipelineRunning
|
||||||
|
? `Running (${pipelineSources.length})`
|
||||||
|
: `Run pipeline (${pipelineSources.length})`}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -172,7 +192,9 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
|
|||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
key={source}
|
key={source}
|
||||||
checked={pipelineSources.includes(source)}
|
checked={pipelineSources.includes(source)}
|
||||||
onCheckedChange={(checked) => onToggleSource(source, Boolean(checked))}
|
onCheckedChange={(checked) =>
|
||||||
|
onToggleSource(source, Boolean(checked))
|
||||||
|
}
|
||||||
onSelect={(event) => event.preventDefault()}
|
onSelect={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
{sourceLabel[source]}
|
{sourceLabel[source]}
|
||||||
@@ -182,7 +204,9 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
|
|||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
checked={allSourcesSelected}
|
checked={allSourcesSelected}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
onSetPipelineSources(checked ? visibleSources : visibleSources.slice(0, 1));
|
onSetPipelineSources(
|
||||||
|
checked ? visibleSources : visibleSources.slice(0, 1),
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onSelect={(event) => event.preventDefault()}
|
onSelect={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from "react";
|
import type React from "react";
|
||||||
|
|
||||||
import { PipelineProgress } from "../../components";
|
|
||||||
import type { JobStatus } from "../../../shared/types";
|
import type { JobStatus } from "../../../shared/types";
|
||||||
|
import { PipelineProgress } from "../../components";
|
||||||
|
|
||||||
interface OrchestratorSummaryProps {
|
interface OrchestratorSummaryProps {
|
||||||
stats: Record<JobStatus, number>;
|
stats: Record<JobStatus, number>;
|
||||||
@@ -28,17 +27,33 @@ export const OrchestratorSummary: React.FC<OrchestratorSummaryProps> = ({
|
|||||||
|
|
||||||
{/* Compact metrics summary - demoted visual weight */}
|
{/* Compact metrics summary - demoted visual weight */}
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground/80">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground/80">
|
||||||
<span><span className="tabular-nums">{stats.ready}</span> ready</span>
|
<span>
|
||||||
|
<span className="tabular-nums">{stats.ready}</span> ready
|
||||||
|
</span>
|
||||||
<span className="text-border">•</span>
|
<span className="text-border">•</span>
|
||||||
<span><span className="tabular-nums">{stats.discovered + stats.processing}</span> discovered</span>
|
<span>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{stats.discovered + stats.processing}
|
||||||
|
</span>{" "}
|
||||||
|
discovered
|
||||||
|
</span>
|
||||||
<span className="text-border">•</span>
|
<span className="text-border">•</span>
|
||||||
<span><span className="tabular-nums">{stats.applied}</span> applied</span>
|
<span>
|
||||||
|
<span className="tabular-nums">{stats.applied}</span> applied
|
||||||
|
</span>
|
||||||
<span className="text-border">•</span>
|
<span className="text-border">•</span>
|
||||||
<span className="font-medium text-foreground/60">{totalJobs} jobs total</span>
|
<span className="font-medium text-foreground/60">
|
||||||
|
{totalJobs} jobs total
|
||||||
|
</span>
|
||||||
{(stats.skipped > 0 || stats.expired > 0) && (
|
{(stats.skipped > 0 || stats.expired > 0) && (
|
||||||
<>
|
<>
|
||||||
<span className="text-border">•</span>
|
<span className="text-border">•</span>
|
||||||
<span className="text-muted-foreground/60"><span className="tabular-nums">{stats.skipped + stats.expired}</span> skipped</span>
|
<span className="text-muted-foreground/60">
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{stats.skipped + stats.expired}
|
||||||
|
</span>{" "}
|
||||||
|
skipped
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
import type { JobSource, JobStatus } from "../../../shared/types";
|
import type { JobSource, JobStatus } from "../../../shared/types";
|
||||||
|
|
||||||
export const DEFAULT_PIPELINE_SOURCES: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"];
|
export const DEFAULT_PIPELINE_SOURCES: JobSource[] = [
|
||||||
|
"gradcracker",
|
||||||
|
"indeed",
|
||||||
|
"linkedin",
|
||||||
|
"ukvisajobs",
|
||||||
|
];
|
||||||
export const PIPELINE_SOURCES_STORAGE_KEY = "jobops.pipeline.sources";
|
export const PIPELINE_SOURCES_STORAGE_KEY = "jobops.pipeline.sources";
|
||||||
|
|
||||||
export const orderedSources: JobSource[] = ["gradcracker", "indeed", "linkedin", "ukvisajobs"];
|
export const orderedSources: JobSource[] = [
|
||||||
|
"gradcracker",
|
||||||
|
"indeed",
|
||||||
|
"linkedin",
|
||||||
|
"ukvisajobs",
|
||||||
|
];
|
||||||
export const orderedFilterSources: JobSource[] = [...orderedSources, "manual"];
|
export const orderedFilterSources: JobSource[] = [...orderedSources, "manual"];
|
||||||
|
|
||||||
export const statusTokens: Record<JobStatus, { label: string; badge: string; dot: string }> = {
|
export const statusTokens: Record<
|
||||||
|
JobStatus,
|
||||||
|
{ label: string; badge: string; dot: string }
|
||||||
|
> = {
|
||||||
discovered: {
|
discovered: {
|
||||||
label: "Discovered",
|
label: "Discovered",
|
||||||
badge: "border-sky-500/30 bg-sky-500/10 text-sky-200",
|
badge: "border-sky-500/30 bg-sky-500/10 text-sky-200",
|
||||||
@@ -71,9 +84,17 @@ export const defaultSortDirection: Record<JobSort["key"], SortDirection> = {
|
|||||||
employer: "asc",
|
employer: "asc",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tabs: Array<{ id: FilterTab; label: string; statuses: JobStatus[] }> = [
|
export const tabs: Array<{
|
||||||
|
id: FilterTab;
|
||||||
|
label: string;
|
||||||
|
statuses: JobStatus[];
|
||||||
|
}> = [
|
||||||
{ id: "ready", label: "Ready", statuses: ["ready"] },
|
{ id: "ready", label: "Ready", statuses: ["ready"] },
|
||||||
{ id: "discovered", label: "Discovered", statuses: ["discovered", "processing"] },
|
{
|
||||||
|
id: "discovered",
|
||||||
|
label: "Discovered",
|
||||||
|
statuses: ["discovered", "processing"],
|
||||||
|
},
|
||||||
{ id: "applied", label: "Applied", statuses: ["applied"] },
|
{ id: "applied", label: "Applied", statuses: ["applied"] },
|
||||||
{ id: "all", label: "All Jobs", statuses: [] },
|
{ id: "all", label: "All Jobs", statuses: [] },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ export const useFilteredJobs = (
|
|||||||
if (activeTab === "ready") {
|
if (activeTab === "ready") {
|
||||||
filtered = filtered.filter((job) => job.status === "ready");
|
filtered = filtered.filter((job) => job.status === "ready");
|
||||||
} else if (activeTab === "discovered") {
|
} else if (activeTab === "discovered") {
|
||||||
filtered = filtered.filter((job) => job.status === "discovered" || job.status === "processing");
|
filtered = filtered.filter(
|
||||||
|
(job) => job.status === "discovered" || job.status === "processing",
|
||||||
|
);
|
||||||
} else if (activeTab === "applied") {
|
} else if (activeTab === "applied") {
|
||||||
filtered = filtered.filter((job) => job.status === "applied");
|
filtered = filtered.filter((job) => job.status === "applied");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ export const useOrchestratorData = () => {
|
|||||||
setJobs(data.jobs);
|
setJobs(data.jobs);
|
||||||
setStats(data.byStatus);
|
setStats(data.byStatus);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to load jobs";
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Failed to load jobs";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -54,5 +55,13 @@ export const useOrchestratorData = () => {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [loadJobs, checkPipelineStatus]);
|
}, [loadJobs, checkPipelineStatus]);
|
||||||
|
|
||||||
return { jobs, stats, isLoading, isPipelineRunning, setIsPipelineRunning, loadJobs, checkPipelineStatus };
|
return {
|
||||||
|
jobs,
|
||||||
|
stats,
|
||||||
|
isLoading,
|
||||||
|
isPipelineRunning,
|
||||||
|
setIsPipelineRunning,
|
||||||
|
loadJobs,
|
||||||
|
checkPipelineStatus,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
import { renderHook, act } from "@testing-library/react";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { PIPELINE_SOURCES_STORAGE_KEY } from "./constants";
|
import { PIPELINE_SOURCES_STORAGE_KEY } from "./constants";
|
||||||
import { usePipelineSources } from "./usePipelineSources";
|
import { usePipelineSources } from "./usePipelineSources";
|
||||||
@@ -10,7 +10,10 @@ describe("usePipelineSources", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("filters stored sources to enabled sources", () => {
|
it("filters stored sources to enabled sources", () => {
|
||||||
localStorage.setItem(PIPELINE_SOURCES_STORAGE_KEY, JSON.stringify(["gradcracker", "ukvisajobs"]));
|
localStorage.setItem(
|
||||||
|
PIPELINE_SOURCES_STORAGE_KEY,
|
||||||
|
JSON.stringify(["gradcracker", "ukvisajobs"]),
|
||||||
|
);
|
||||||
|
|
||||||
const enabledSources = ["gradcracker"] as const;
|
const enabledSources = ["gradcracker"] as const;
|
||||||
|
|
||||||
@@ -20,7 +23,10 @@ describe("usePipelineSources", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the first enabled source", () => {
|
it("falls back to the first enabled source", () => {
|
||||||
localStorage.setItem(PIPELINE_SOURCES_STORAGE_KEY, JSON.stringify(["ukvisajobs"]));
|
localStorage.setItem(
|
||||||
|
PIPELINE_SOURCES_STORAGE_KEY,
|
||||||
|
JSON.stringify(["ukvisajobs"]),
|
||||||
|
);
|
||||||
|
|
||||||
const enabledSources = ["gradcracker", "linkedin"] as const;
|
const enabledSources = ["gradcracker", "linkedin"] as const;
|
||||||
|
|
||||||
@@ -30,7 +36,10 @@ describe("usePipelineSources", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("ignores toggles for disabled sources", () => {
|
it("ignores toggles for disabled sources", () => {
|
||||||
localStorage.setItem(PIPELINE_SOURCES_STORAGE_KEY, JSON.stringify(["gradcracker"]));
|
localStorage.setItem(
|
||||||
|
PIPELINE_SOURCES_STORAGE_KEY,
|
||||||
|
JSON.stringify(["gradcracker"]),
|
||||||
|
);
|
||||||
|
|
||||||
const enabledSources = ["gradcracker"] as const;
|
const enabledSources = ["gradcracker"] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -3,30 +3,42 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import type { JobSource } from "../../../shared/types";
|
import type { JobSource } from "../../../shared/types";
|
||||||
import {
|
import {
|
||||||
DEFAULT_PIPELINE_SOURCES,
|
DEFAULT_PIPELINE_SOURCES,
|
||||||
PIPELINE_SOURCES_STORAGE_KEY,
|
|
||||||
orderedSources,
|
orderedSources,
|
||||||
|
PIPELINE_SOURCES_STORAGE_KEY,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
|
|
||||||
const resolveAllowedSources = (enabledSources?: JobSource[]) =>
|
const resolveAllowedSources = (enabledSources?: JobSource[]) =>
|
||||||
enabledSources && enabledSources.length > 0 ? enabledSources : DEFAULT_PIPELINE_SOURCES;
|
enabledSources && enabledSources.length > 0
|
||||||
|
? enabledSources
|
||||||
|
: DEFAULT_PIPELINE_SOURCES;
|
||||||
|
|
||||||
const normalizeSources = (sources: JobSource[], allowedSources: JobSource[]) => {
|
const normalizeSources = (
|
||||||
|
sources: JobSource[],
|
||||||
|
allowedSources: JobSource[],
|
||||||
|
) => {
|
||||||
const filtered = sources.filter((value) => allowedSources.includes(value));
|
const filtered = sources.filter((value) => allowedSources.includes(value));
|
||||||
return filtered.length > 0 ? filtered : allowedSources.slice(0, 1);
|
return filtered.length > 0 ? filtered : allowedSources.slice(0, 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sourcesMatch = (left: JobSource[], right: JobSource[]) =>
|
const sourcesMatch = (left: JobSource[], right: JobSource[]) =>
|
||||||
left.length === right.length && left.every((value, index) => value === right[index]);
|
left.length === right.length &&
|
||||||
|
left.every((value, index) => value === right[index]);
|
||||||
|
|
||||||
export const usePipelineSources = (enabledSources?: JobSource[]) => {
|
export const usePipelineSources = (enabledSources?: JobSource[]) => {
|
||||||
const allowedSources = useMemo(() => resolveAllowedSources(enabledSources), [enabledSources]);
|
const allowedSources = useMemo(
|
||||||
|
() => resolveAllowedSources(enabledSources),
|
||||||
|
[enabledSources],
|
||||||
|
);
|
||||||
const [pipelineSources, setPipelineSources] = useState<JobSource[]>(() => {
|
const [pipelineSources, setPipelineSources] = useState<JobSource[]>(() => {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(PIPELINE_SOURCES_STORAGE_KEY);
|
const raw = localStorage.getItem(PIPELINE_SOURCES_STORAGE_KEY);
|
||||||
if (!raw) return normalizeSources(allowedSources, allowedSources);
|
if (!raw) return normalizeSources(allowedSources, allowedSources);
|
||||||
const parsed = JSON.parse(raw) as unknown;
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
if (!Array.isArray(parsed)) return normalizeSources(allowedSources, allowedSources);
|
if (!Array.isArray(parsed))
|
||||||
const next = parsed.filter((value): value is JobSource => orderedSources.includes(value as JobSource));
|
return normalizeSources(allowedSources, allowedSources);
|
||||||
|
const next = parsed.filter((value): value is JobSource =>
|
||||||
|
orderedSources.includes(value as JobSource),
|
||||||
|
);
|
||||||
return normalizeSources(next, allowedSources);
|
return normalizeSources(next, allowedSources);
|
||||||
} catch {
|
} catch {
|
||||||
return normalizeSources(allowedSources, allowedSources);
|
return normalizeSources(allowedSources, allowedSources);
|
||||||
@@ -42,22 +54,28 @@ export const usePipelineSources = (enabledSources?: JobSource[]) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(PIPELINE_SOURCES_STORAGE_KEY, JSON.stringify(pipelineSources));
|
localStorage.setItem(
|
||||||
|
PIPELINE_SOURCES_STORAGE_KEY,
|
||||||
|
JSON.stringify(pipelineSources),
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore localStorage errors
|
// Ignore localStorage errors
|
||||||
}
|
}
|
||||||
}, [pipelineSources]);
|
}, [pipelineSources]);
|
||||||
|
|
||||||
const toggleSource = useCallback((source: JobSource, checked: boolean) => {
|
const toggleSource = useCallback(
|
||||||
if (!allowedSources.includes(source)) return;
|
(source: JobSource, checked: boolean) => {
|
||||||
setPipelineSources((current) => {
|
if (!allowedSources.includes(source)) return;
|
||||||
const next = checked
|
setPipelineSources((current) => {
|
||||||
? Array.from(new Set([...current, source]))
|
const next = checked
|
||||||
: current.filter((value) => value !== source);
|
? Array.from(new Set([...current, source]))
|
||||||
|
: current.filter((value) => value !== source);
|
||||||
|
|
||||||
return next.length === 0 ? current : next;
|
return next.length === 0 ? current : next;
|
||||||
});
|
});
|
||||||
}, [allowedSources]);
|
},
|
||||||
|
[allowedSources],
|
||||||
|
);
|
||||||
|
|
||||||
return { pipelineSources, setPipelineSources, toggleSource };
|
return { pipelineSources, setPipelineSources, toggleSource };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { AppSettings, Job, JobSource } from "../../../shared/types";
|
import type { AppSettings, Job, JobSource } from "../../../shared/types";
|
||||||
import { orderedFilterSources, orderedSources } from "./constants";
|
|
||||||
import type { FilterTab, JobSort } from "./constants";
|
import type { FilterTab, JobSort } from "./constants";
|
||||||
|
import { orderedFilterSources, orderedSources } from "./constants";
|
||||||
|
|
||||||
const dateValue = (value: string | null) => {
|
const dateValue = (value: string | null) => {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
@@ -8,7 +8,8 @@ const dateValue = (value: string | null) => {
|
|||||||
return Number.isFinite(parsed) ? parsed : null;
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const compareString = (a: string, b: string) => a.localeCompare(b, undefined, { sensitivity: "base" });
|
const compareString = (a: string, b: string) =>
|
||||||
|
a.localeCompare(b, undefined, { sensitivity: "base" });
|
||||||
const compareNumber = (a: number, b: number) => a - b;
|
const compareNumber = (a: number, b: number) => a - b;
|
||||||
|
|
||||||
export const compareJobs = (a: Job, b: Job, sort: JobSort) => {
|
export const compareJobs = (a: Job, b: Job, sort: JobSort) => {
|
||||||
@@ -83,7 +84,8 @@ export const getJobCounts = (jobs: Job[]): Record<FilterTab, number> => {
|
|||||||
for (const job of jobs) {
|
for (const job of jobs) {
|
||||||
if (job.status === "ready") byTab.ready += 1;
|
if (job.status === "ready") byTab.ready += 1;
|
||||||
if (job.status === "applied") byTab.applied += 1;
|
if (job.status === "applied") byTab.applied += 1;
|
||||||
if (job.status === "discovered" || job.status === "processing") byTab.discovered += 1;
|
if (job.status === "discovered" || job.status === "processing")
|
||||||
|
byTab.discovered += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return byTab;
|
return byTab;
|
||||||
@@ -97,13 +99,15 @@ export const getSourcesWithJobs = (jobs: Job[]): JobSource[] => {
|
|||||||
return orderedFilterSources.filter((source) => seen.has(source));
|
return orderedFilterSources.filter((source) => seen.has(source));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getEnabledSources = (settings: AppSettings | null): JobSource[] => {
|
export const getEnabledSources = (
|
||||||
|
settings: AppSettings | null,
|
||||||
|
): JobSource[] => {
|
||||||
if (!settings) return [...orderedSources];
|
if (!settings) return [...orderedSources];
|
||||||
|
|
||||||
const enabled: JobSource[] = [];
|
const enabled: JobSource[] = [];
|
||||||
const jobspySites = settings.jobspySites ?? [];
|
const jobspySites = settings.jobspySites ?? [];
|
||||||
const hasUkVisaJobsAuth = Boolean(
|
const hasUkVisaJobsAuth = Boolean(
|
||||||
settings.ukvisajobsEmail?.trim() && settings.ukvisajobsPasswordHint
|
settings.ukvisajobsEmail?.trim() && settings.ukvisajobsPasswordHint,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const source of orderedSources) {
|
for (const source of orderedSources) {
|
||||||
|
|||||||
@@ -1,107 +1,122 @@
|
|||||||
import React, { useEffect, useState } from "react"
|
import * as api from "@client/api";
|
||||||
import { RefreshCw } from "lucide-react"
|
import { RefreshCw } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button"
|
import type React from "react";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { useEffect, useState } from "react";
|
||||||
import * as api from "@client/api"
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
type BaseResumeSelectionProps = {
|
type BaseResumeSelectionProps = {
|
||||||
value: string | null
|
value: string | null;
|
||||||
onValueChange: (value: string | null) => void
|
onValueChange: (value: string | null) => void;
|
||||||
hasRxResumeAccess: boolean
|
hasRxResumeAccess: boolean;
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
isLoading?: boolean
|
isLoading?: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const BaseResumeSelection: React.FC<BaseResumeSelectionProps> = ({
|
export const BaseResumeSelection: React.FC<BaseResumeSelectionProps> = ({
|
||||||
value,
|
value,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
hasRxResumeAccess,
|
hasRxResumeAccess,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [resumes, setResumes] = useState<{ id: string; name: string }[]>([])
|
const [resumes, setResumes] = useState<{ id: string; name: string }[]>([]);
|
||||||
const [isFetchingResumes, setIsFetchingResumes] = useState(false)
|
const [isFetchingResumes, setIsFetchingResumes] = useState(false);
|
||||||
const [fetchError, setFetchError] = useState<string | null>(null)
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchResumes = async () => {
|
const fetchResumes = async () => {
|
||||||
if (!hasRxResumeAccess) return
|
if (!hasRxResumeAccess) return;
|
||||||
|
|
||||||
setIsFetchingResumes(true)
|
setIsFetchingResumes(true);
|
||||||
setFetchError(null)
|
setFetchError(null);
|
||||||
try {
|
try {
|
||||||
const data = await api.getRxResumes()
|
const data = await api.getRxResumes();
|
||||||
setResumes(data)
|
setResumes(data);
|
||||||
|
|
||||||
// Preselect if only one option is available and no value is currently set
|
// Preselect if only one option is available and no value is currently set
|
||||||
if (data.length === 1 && !value) {
|
if (data.length === 1 && !value) {
|
||||||
onValueChange(data[0].id)
|
onValueChange(data[0].id);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFetchError(error instanceof Error ? error.message : "Failed to fetch resumes")
|
setFetchError(
|
||||||
} finally {
|
error instanceof Error ? error.message : "Failed to fetch resumes",
|
||||||
setIsFetchingResumes(false)
|
);
|
||||||
}
|
} finally {
|
||||||
|
setIsFetchingResumes(false);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasRxResumeAccess) {
|
if (hasRxResumeAccess) {
|
||||||
fetchResumes()
|
fetchResumes();
|
||||||
}
|
}
|
||||||
}, [hasRxResumeAccess])
|
}, [hasRxResumeAccess]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-sm font-medium">Template Resume</div>
|
<div className="text-sm font-medium">Template Resume</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={fetchResumes}
|
onClick={fetchResumes}
|
||||||
disabled={isFetchingResumes || isLoading || disabled}
|
disabled={isFetchingResumes || isLoading || disabled}
|
||||||
className="h-8 px-2"
|
className="h-8 px-2"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-3 w-3 mr-1 ${isFetchingResumes ? 'animate-spin' : ''}`} />
|
<RefreshCw
|
||||||
Refresh
|
className={`h-3 w-3 mr-1 ${isFetchingResumes ? "animate-spin" : ""}`}
|
||||||
</Button>
|
/>
|
||||||
</div>
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
value={value || ""}
|
value={value || ""}
|
||||||
onValueChange={(val: string) => onValueChange(val || null)}
|
onValueChange={(val: string) => onValueChange(val || null)}
|
||||||
disabled={disabled || isLoading || isFetchingResumes}
|
disabled={disabled || isLoading || isFetchingResumes}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={resumes.length > 0 ? "Select a template resume..." : "No resumes found"} />
|
<SelectValue
|
||||||
</SelectTrigger>
|
placeholder={
|
||||||
<SelectContent>
|
resumes.length > 0
|
||||||
{resumes.map((resume) => (
|
? "Select a template resume..."
|
||||||
<SelectItem key={resume.id} value={resume.id}>
|
: "No resumes found"
|
||||||
{resume.name}
|
}
|
||||||
</SelectItem>
|
/>
|
||||||
))}
|
</SelectTrigger>
|
||||||
</SelectContent>
|
<SelectContent>
|
||||||
</Select>
|
{resumes.map((resume) => (
|
||||||
|
<SelectItem key={resume.id} value={resume.id}>
|
||||||
|
{resume.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
{resumes.length === 0 && !isFetchingResumes && !fetchError && (
|
{resumes.length === 0 && !isFetchingResumes && !fetchError && (
|
||||||
<div className="text-xs text-amber-600 dark:text-amber-400 mt-2">
|
<div className="text-xs text-amber-600 dark:text-amber-400 mt-2">
|
||||||
No resumes found in your account. Please create a resume on the{" "}
|
No resumes found in your account. Please create a resume on the{" "}
|
||||||
<a
|
<a
|
||||||
href="https://rxresu.me"
|
href="https://rxresu.me"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="font-semibold underline underline-offset-2"
|
className="font-semibold underline underline-offset-2"
|
||||||
>
|
>
|
||||||
Reactive Resume website
|
Reactive Resume website
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
first.
|
first.
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{fetchError && (
|
|
||||||
<div className="text-xs text-destructive mt-1">
|
|
||||||
{fetchError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
}
|
|
||||||
|
{fetchError && (
|
||||||
|
<div className="text-xs text-destructive mt-1">{fetchError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
import { describe, it, expect, vi } from "vitest"
|
import type { JobStatus } from "@shared/types";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react"
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { useState } from "react"
|
import { useState } from "react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { Accordion } from "@/components/ui/accordion";
|
||||||
|
import { DangerZoneSection } from "./DangerZoneSection";
|
||||||
|
|
||||||
import { Accordion } from "@/components/ui/accordion"
|
const DangerZoneHarness = ({
|
||||||
import { DangerZoneSection } from "./DangerZoneSection"
|
initialStatuses = [] as JobStatus[],
|
||||||
import type { JobStatus } from "@shared/types"
|
onClear,
|
||||||
|
}: {
|
||||||
const DangerZoneHarness = ({ initialStatuses = [] as JobStatus[], onClear }: { initialStatuses?: JobStatus[]; onClear?: () => void }) => {
|
initialStatuses?: JobStatus[];
|
||||||
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>(initialStatuses)
|
onClear?: () => void;
|
||||||
|
}) => {
|
||||||
|
const [statusesToClear, setStatusesToClear] =
|
||||||
|
useState<JobStatus[]>(initialStatuses);
|
||||||
|
|
||||||
const toggleStatusToClear = (status: JobStatus) => {
|
const toggleStatusToClear = (status: JobStatus) => {
|
||||||
setStatusesToClear((prev) =>
|
setStatusesToClear((prev) =>
|
||||||
prev.includes(status) ? prev.filter((s) => s !== status) : [...prev, status]
|
prev.includes(status)
|
||||||
)
|
? prev.filter((s) => s !== status)
|
||||||
}
|
: [...prev, status],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Accordion type="multiple" defaultValue={["danger-zone"]}>
|
<Accordion type="multiple" defaultValue={["danger-zone"]}>
|
||||||
@@ -26,33 +34,37 @@ const DangerZoneHarness = ({ initialStatuses = [] as JobStatus[], onClear }: { i
|
|||||||
isSaving={false}
|
isSaving={false}
|
||||||
/>
|
/>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
describe("DangerZoneSection", () => {
|
describe("DangerZoneSection", () => {
|
||||||
it("disables clear when no statuses are selected", () => {
|
it("disables clear when no statuses are selected", () => {
|
||||||
render(<DangerZoneHarness initialStatuses={[]} />)
|
render(<DangerZoneHarness initialStatuses={[]} />);
|
||||||
|
|
||||||
const clearButton = screen.getByRole("button", { name: /clear selected/i })
|
const clearButton = screen.getByRole("button", { name: /clear selected/i });
|
||||||
expect(clearButton).toBeDisabled()
|
expect(clearButton).toBeDisabled();
|
||||||
})
|
});
|
||||||
|
|
||||||
it("toggles status selection and confirms clear", async () => {
|
it("toggles status selection and confirms clear", async () => {
|
||||||
const onClear = vi.fn()
|
const onClear = vi.fn();
|
||||||
render(<DangerZoneHarness initialStatuses={["applied"]} onClear={onClear} />)
|
render(
|
||||||
|
<DangerZoneHarness initialStatuses={["applied"]} onClear={onClear} />,
|
||||||
|
);
|
||||||
|
|
||||||
const appliedButton = screen.getByRole("button", { name: /applied/i })
|
const appliedButton = screen.getByRole("button", { name: /applied/i });
|
||||||
const clearButton = screen.getByRole("button", { name: /clear selected/i })
|
const clearButton = screen.getByRole("button", { name: /clear selected/i });
|
||||||
|
|
||||||
expect(clearButton).toBeEnabled()
|
expect(clearButton).toBeEnabled();
|
||||||
|
|
||||||
fireEvent.click(clearButton)
|
fireEvent.click(clearButton);
|
||||||
const confirmButton = await screen.findByRole("button", { name: /clear 1 status/i })
|
const confirmButton = await screen.findByRole("button", {
|
||||||
fireEvent.click(confirmButton)
|
name: /clear 1 status/i,
|
||||||
|
});
|
||||||
|
fireEvent.click(confirmButton);
|
||||||
|
|
||||||
expect(onClear).toHaveBeenCalledTimes(1)
|
expect(onClear).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
fireEvent.click(appliedButton)
|
fireEvent.click(appliedButton);
|
||||||
expect(clearButton).toBeDisabled()
|
expect(clearButton).toBeDisabled();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import React from "react"
|
import {
|
||||||
import { AlertTriangle, Trash2 } from "lucide-react"
|
ALL_JOB_STATUSES,
|
||||||
|
STATUS_DESCRIPTIONS,
|
||||||
|
} from "@client/pages/settings/constants";
|
||||||
|
import type { JobStatus } from "@shared/types";
|
||||||
|
import { AlertTriangle, Trash2 } from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
import {
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -11,21 +20,18 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog";
|
||||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
import { Button } from "@/components/ui/button";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
import type { JobStatus } from "@shared/types"
|
|
||||||
import { ALL_JOB_STATUSES, STATUS_DESCRIPTIONS } from "@client/pages/settings/constants"
|
|
||||||
|
|
||||||
type DangerZoneSectionProps = {
|
type DangerZoneSectionProps = {
|
||||||
statusesToClear: JobStatus[]
|
statusesToClear: JobStatus[];
|
||||||
toggleStatusToClear: (status: JobStatus) => void
|
toggleStatusToClear: (status: JobStatus) => void;
|
||||||
handleClearByStatuses: () => void
|
handleClearByStatuses: () => void;
|
||||||
handleClearDatabase: () => void
|
handleClearDatabase: () => void;
|
||||||
isLoading: boolean
|
isLoading: boolean;
|
||||||
isSaving: boolean
|
isSaving: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
|
export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
|
||||||
statusesToClear,
|
statusesToClear,
|
||||||
@@ -36,18 +42,25 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
|
|||||||
isSaving,
|
isSaving,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<AccordionItem value="danger-zone" className="border rounded-lg px-4 border-destructive/30 mt-4">
|
<AccordionItem
|
||||||
|
value="danger-zone"
|
||||||
|
className="border rounded-lg px-4 border-destructive/30 mt-4"
|
||||||
|
>
|
||||||
<AccordionTrigger className="hover:no-underline py-4">
|
<AccordionTrigger className="hover:no-underline py-4">
|
||||||
<div className="flex items-center gap-2 text-destructive">
|
<div className="flex items-center gap-2 text-destructive">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<span className="text-base font-semibold tracking-wider">Danger Zone</span>
|
<span className="text-base font-semibold tracking-wider">
|
||||||
|
Danger Zone
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="pb-4">
|
<AccordionContent className="pb-4">
|
||||||
<div className="space-y-4 pt-2">
|
<div className="space-y-4 pt-2">
|
||||||
<div className="p-3 rounded-md space-y-4">
|
<div className="p-3 rounded-md space-y-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<div className="text-sm font-semibold text-destructive">Clear Jobs by Status</div>
|
<div className="text-sm font-semibold text-destructive">
|
||||||
|
Clear Jobs by Status
|
||||||
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Select which job statuses you want to clear.
|
Select which job statuses you want to clear.
|
||||||
</div>
|
</div>
|
||||||
@@ -55,7 +68,7 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||||
{ALL_JOB_STATUSES.map((status) => {
|
{ALL_JOB_STATUSES.map((status) => {
|
||||||
const isSelected = statusesToClear.includes(status)
|
const isSelected = statusesToClear.includes(status);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={status}
|
key={status}
|
||||||
@@ -63,22 +76,32 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
|
|||||||
onClick={() => toggleStatusToClear(status)}
|
onClick={() => toggleStatusToClear(status)}
|
||||||
disabled={isLoading || isSaving}
|
disabled={isLoading || isSaving}
|
||||||
className={`flex items-start gap-3 rounded-lg border p-3 text-left transition-colors hover:bg-destructive/20 disabled:cursor-not-allowed disabled:opacity-50 ${
|
className={`flex items-start gap-3 rounded-lg border p-3 text-left transition-colors hover:bg-destructive/20 disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||||
isSelected ? 'border-destructive bg-destructive/10' : 'border-border'
|
isSelected
|
||||||
|
? "border-destructive bg-destructive/10"
|
||||||
|
: "border-border"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className={`mt-0.5 h-4 w-4 rounded-full border-2 flex items-center justify-center ${
|
<div
|
||||||
isSelected ? 'border-destructive' : 'border-muted-foreground'
|
className={`mt-0.5 h-4 w-4 rounded-full border-2 flex items-center justify-center ${
|
||||||
}`}>
|
isSelected
|
||||||
{isSelected && <div className="h-2 w-2 rounded-full bg-destructive" />}
|
? "border-destructive"
|
||||||
|
: "border-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<div className="h-2 w-2 rounded-full bg-destructive" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-0.5">
|
<div className="grid gap-0.5">
|
||||||
<span className="text-sm font-medium capitalize">{status}</span>
|
<span className="text-sm font-medium capitalize">
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{STATUS_DESCRIPTIONS[status]}
|
{STATUS_DESCRIPTIONS[status]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -87,7 +110,9 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={isLoading || isSaving || statusesToClear.length === 0}
|
disabled={
|
||||||
|
isLoading || isSaving || statusesToClear.length === 0
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Clear Selected ({statusesToClear.length})
|
Clear Selected ({statusesToClear.length})
|
||||||
@@ -97,14 +122,18 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Clear jobs by status?</AlertDialogTitle>
|
<AlertDialogTitle>Clear jobs by status?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This will delete all jobs with the following statuses: {statusesToClear.join(', ')}.
|
This will delete all jobs with the following statuses:{" "}
|
||||||
This action cannot be undone.
|
{statusesToClear.join(", ")}. This action cannot be undone.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleClearByStatuses} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
<AlertDialogAction
|
||||||
Clear {statusesToClear.length} status{statusesToClear.length !== 1 ? 'es' : ''}
|
onClick={handleClearByStatuses}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Clear {statusesToClear.length} status
|
||||||
|
{statusesToClear.length !== 1 ? "es" : ""}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
@@ -115,14 +144,20 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
|
|||||||
|
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between p-3 rounded-md">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between p-3 rounded-md">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<div className="text-sm font-semibold text-destructive">Clear Entire Database</div>
|
<div className="text-sm font-semibold text-destructive">
|
||||||
|
Clear Entire Database
|
||||||
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Delete all jobs and pipeline runs from the database.
|
Delete all jobs and pipeline runs from the database.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="destructive" size="sm" disabled={isLoading || isSaving}>
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={isLoading || isSaving}
|
||||||
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Clear Database
|
Clear Database
|
||||||
</Button>
|
</Button>
|
||||||
@@ -131,12 +166,16 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Clear all jobs?</AlertDialogTitle>
|
<AlertDialogTitle>Clear all jobs?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This deletes all jobs and pipeline runs from the database. This action cannot be undone.
|
This deletes all jobs and pipeline runs from the database.
|
||||||
|
This action cannot be undone.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleClearDatabase} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
<AlertDialogAction
|
||||||
|
onClick={handleClearDatabase}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
Clear database
|
Clear database
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
@@ -146,5 +185,5 @@ export const DangerZoneSection: React.FC<DangerZoneSectionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,81 +1,89 @@
|
|||||||
import React from "react"
|
import type { DisplayValues } from "@client/pages/settings/types";
|
||||||
import { useFormContext, Controller } from "react-hook-form"
|
import type { UpdateSettingsInput } from "@shared/settings-schema";
|
||||||
|
import type React from "react";
|
||||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import {
|
||||||
import { Separator } from "@/components/ui/separator"
|
AccordionContent,
|
||||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
AccordionItem,
|
||||||
import type { DisplayValues } from "@client/pages/settings/types"
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
type DisplaySettingsSectionProps = {
|
type DisplaySettingsSectionProps = {
|
||||||
values: DisplayValues
|
values: DisplayValues;
|
||||||
isLoading: boolean
|
isLoading: boolean;
|
||||||
isSaving: boolean
|
isSaving: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const DisplaySettingsSection: React.FC<DisplaySettingsSectionProps> = ({
|
export const DisplaySettingsSection: React.FC<DisplaySettingsSectionProps> = ({
|
||||||
values,
|
values,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSaving,
|
isSaving,
|
||||||
}) => {
|
}) => {
|
||||||
const { default: defaultShowSponsorInfo, effective: effectiveShowSponsorInfo } = values
|
const {
|
||||||
const { control } = useFormContext<UpdateSettingsInput>()
|
default: defaultShowSponsorInfo,
|
||||||
|
effective: effectiveShowSponsorInfo,
|
||||||
|
} = values;
|
||||||
|
const { control } = useFormContext<UpdateSettingsInput>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordionItem value="display" className="border rounded-lg px-4">
|
<AccordionItem value="display" className="border rounded-lg px-4">
|
||||||
<AccordionTrigger className="hover:no-underline py-4">
|
<AccordionTrigger className="hover:no-underline py-4">
|
||||||
<span className="text-base font-semibold">Display Settings</span>
|
<span className="text-base font-semibold">Display Settings</span>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="pb-4">
|
<AccordionContent className="pb-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<Controller
|
<Controller
|
||||||
name="showSponsorInfo"
|
name="showSponsorInfo"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="showSponsorInfo"
|
id="showSponsorInfo"
|
||||||
checked={field.value ?? defaultShowSponsorInfo}
|
checked={field.value ?? defaultShowSponsorInfo}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
field.onChange(checked === "indeterminate" ? null : checked === true)
|
field.onChange(
|
||||||
}}
|
checked === "indeterminate" ? null : checked === true,
|
||||||
disabled={isLoading || isSaving}
|
);
|
||||||
/>
|
}}
|
||||||
)}
|
disabled={isLoading || isSaving}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-1.5">
|
)}
|
||||||
<label
|
/>
|
||||||
htmlFor="showSponsorInfo"
|
<div className="flex flex-col gap-1.5">
|
||||||
className="text-sm font-medium leading-none cursor-pointer"
|
<label
|
||||||
>
|
htmlFor="showSponsorInfo"
|
||||||
Show visa sponsor information
|
className="text-sm font-medium leading-none cursor-pointer"
|
||||||
</label>
|
>
|
||||||
<p className="text-xs text-muted-foreground">
|
Show visa sponsor information
|
||||||
Display a badge next to the employer name showing the match
|
</label>
|
||||||
percentage with the UK visa sponsor list. This helps identify
|
<p className="text-xs text-muted-foreground">
|
||||||
employers that are licensed to sponsor work visas.
|
Display a badge next to the employer name showing the match
|
||||||
</p>
|
percentage with the UK visa sponsor list. This helps identify
|
||||||
</div>
|
employers that are licensed to sponsor work visas.
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-muted-foreground">Effective</div>
|
<div className="text-xs text-muted-foreground">Effective</div>
|
||||||
<div className="break-words font-mono text-xs">
|
<div className="break-words font-mono text-xs">
|
||||||
{effectiveShowSponsorInfo ? "Enabled" : "Disabled"}
|
{effectiveShowSponsorInfo ? "Enabled" : "Disabled"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-muted-foreground">Default</div>
|
<div className="text-xs text-muted-foreground">Default</div>
|
||||||
<div className="break-words font-mono text-xs font-semibold">
|
<div className="break-words font-mono text-xs font-semibold">
|
||||||
{defaultShowSponsorInfo ? "Enabled" : "Disabled"}
|
{defaultShowSponsorInfo ? "Enabled" : "Disabled"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
+23
-24
@@ -1,9 +1,8 @@
|
|||||||
import { render, screen } from "@testing-library/react"
|
import type { UpdateSettingsInput } from "@shared/settings-schema";
|
||||||
import { useForm, FormProvider } from "react-hook-form"
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { FormProvider, useForm } from "react-hook-form";
|
||||||
import { Accordion } from "@/components/ui/accordion"
|
import { Accordion } from "@/components/ui/accordion";
|
||||||
import { EnvironmentSettingsSection } from "./EnvironmentSettingsSection"
|
import { EnvironmentSettingsSection } from "./EnvironmentSettingsSection";
|
||||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
|
||||||
|
|
||||||
const EnvironmentSettingsHarness = () => {
|
const EnvironmentSettingsHarness = () => {
|
||||||
const methods = useForm<UpdateSettingsInput>({
|
const methods = useForm<UpdateSettingsInput>({
|
||||||
@@ -17,8 +16,8 @@ const EnvironmentSettingsHarness = () => {
|
|||||||
basicAuthPassword: "",
|
basicAuthPassword: "",
|
||||||
webhookSecret: "",
|
webhookSecret: "",
|
||||||
enableBasicAuth: true,
|
enableBasicAuth: true,
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
@@ -44,28 +43,28 @@ const EnvironmentSettingsHarness = () => {
|
|||||||
/>
|
/>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
describe("EnvironmentSettingsSection", () => {
|
describe("EnvironmentSettingsSection", () => {
|
||||||
it("renders values grouped logically and masks private secrets with hints", () => {
|
it("renders values grouped logically and masks private secrets with hints", () => {
|
||||||
render(<EnvironmentSettingsHarness />)
|
render(<EnvironmentSettingsHarness />);
|
||||||
|
|
||||||
expect(screen.getByDisplayValue("resume@example.com")).toBeInTheDocument()
|
expect(screen.getByDisplayValue("resume@example.com")).toBeInTheDocument();
|
||||||
expect(screen.getByDisplayValue("visa@example.com")).toBeInTheDocument()
|
expect(screen.getByDisplayValue("visa@example.com")).toBeInTheDocument();
|
||||||
|
|
||||||
expect(screen.getByText(/sk-1\*{8}/)).toBeInTheDocument()
|
expect(screen.getByText(/sk-1\*{8}/)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/pass\*{8}/)).toBeInTheDocument()
|
expect(screen.getByText(/pass\*{8}/)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/abcd\*{8}/)).toBeInTheDocument()
|
expect(screen.getByText(/abcd\*{8}/)).toBeInTheDocument();
|
||||||
expect(screen.getByText("Not set")).toBeInTheDocument()
|
expect(screen.getByText("Not set")).toBeInTheDocument();
|
||||||
|
|
||||||
// Basic Auth
|
// Basic Auth
|
||||||
expect(screen.getByLabelText("Enable basic authentication")).toBeChecked()
|
expect(screen.getByLabelText("Enable basic authentication")).toBeChecked();
|
||||||
expect(screen.getByDisplayValue("admin")).toBeInTheDocument()
|
expect(screen.getByDisplayValue("admin")).toBeInTheDocument();
|
||||||
|
|
||||||
// Sections
|
// Sections
|
||||||
expect(screen.getByText("External Services")).toBeInTheDocument()
|
expect(screen.getByText("External Services")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Service Accounts")).toBeInTheDocument()
|
expect(screen.getByText("Service Accounts")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Security")).toBeInTheDocument()
|
expect(screen.getByText("Security")).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,29 +1,35 @@
|
|||||||
import React from "react"
|
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
|
||||||
import { useFormContext, Controller } from "react-hook-form"
|
import type { EnvSettingsValues } from "@client/pages/settings/types";
|
||||||
|
import { formatSecretHint } from "@client/pages/settings/utils";
|
||||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
import type { UpdateSettingsInput } from "@shared/settings-schema";
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import type React from "react";
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
import {
|
||||||
import type { EnvSettingsValues } from "@client/pages/settings/types"
|
AccordionContent,
|
||||||
import { formatSecretHint } from "@client/pages/settings/utils"
|
AccordionItem,
|
||||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
type EnvironmentSettingsSectionProps = {
|
type EnvironmentSettingsSectionProps = {
|
||||||
values: EnvSettingsValues
|
values: EnvSettingsValues;
|
||||||
isLoading: boolean
|
isLoading: boolean;
|
||||||
isSaving: boolean
|
isSaving: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProps> = ({
|
export const EnvironmentSettingsSection: React.FC<
|
||||||
values,
|
EnvironmentSettingsSectionProps
|
||||||
isLoading,
|
> = ({ values, isLoading, isSaving }) => {
|
||||||
isSaving,
|
const {
|
||||||
}) => {
|
register,
|
||||||
const { register, control, watch, formState: { errors } } = useFormContext<UpdateSettingsInput>()
|
control,
|
||||||
const { private: privateValues } = values
|
watch,
|
||||||
|
formState: { errors },
|
||||||
|
} = useFormContext<UpdateSettingsInput>();
|
||||||
|
const { private: privateValues } = values;
|
||||||
|
|
||||||
const isBasicAuthEnabled = watch("enableBasicAuth")
|
const isBasicAuthEnabled = watch("enableBasicAuth");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordionItem value="environment" className="border rounded-lg px-4">
|
<AccordionItem value="environment" className="border rounded-lg px-4">
|
||||||
@@ -34,7 +40,9 @@ export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProp
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* External Services */}
|
{/* External Services */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">External Services</div>
|
<div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">
|
||||||
|
External Services
|
||||||
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<SettingsInput
|
<SettingsInput
|
||||||
label="OpenRouter API key"
|
label="OpenRouter API key"
|
||||||
@@ -52,7 +60,9 @@ export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProp
|
|||||||
|
|
||||||
{/* Service Accounts */}
|
{/* Service Accounts */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">Service Accounts</div>
|
<div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Service Accounts
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-sm font-semibold">RxResume</div>
|
<div className="text-sm font-semibold">RxResume</div>
|
||||||
@@ -92,8 +102,12 @@ export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProp
|
|||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter new password"
|
placeholder="Enter new password"
|
||||||
disabled={isLoading || isSaving}
|
disabled={isLoading || isSaving}
|
||||||
error={errors.ukvisajobsPassword?.message as string | undefined}
|
error={
|
||||||
current={formatSecretHint(privateValues.ukvisajobsPasswordHint)}
|
errors.ukvisajobsPassword?.message as string | undefined
|
||||||
|
}
|
||||||
|
current={formatSecretHint(
|
||||||
|
privateValues.ukvisajobsPasswordHint,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +117,9 @@ export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProp
|
|||||||
|
|
||||||
{/* Security */}
|
{/* Security */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">Security</div>
|
<div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Security
|
||||||
|
</div>
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<Controller
|
<Controller
|
||||||
name="enableBasicAuth"
|
name="enableBasicAuth"
|
||||||
@@ -146,8 +162,12 @@ export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProp
|
|||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter new password"
|
placeholder="Enter new password"
|
||||||
disabled={isLoading || isSaving}
|
disabled={isLoading || isSaving}
|
||||||
error={errors.basicAuthPassword?.message as string | undefined}
|
error={
|
||||||
current={formatSecretHint(privateValues.basicAuthPasswordHint)}
|
errors.basicAuthPassword?.message as string | undefined
|
||||||
|
}
|
||||||
|
current={formatSecretHint(
|
||||||
|
privateValues.basicAuthPasswordHint,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -155,5 +175,5 @@ export const EnvironmentSettingsSection: React.FC<EnvironmentSettingsSectionProp
|
|||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,24 +1,33 @@
|
|||||||
import React from "react"
|
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
|
||||||
import { useFormContext, Controller } from "react-hook-form"
|
import type { NumericSettingValues } from "@client/pages/settings/types";
|
||||||
|
import type { UpdateSettingsInput } from "@shared/settings-schema";
|
||||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
import type React from "react";
|
||||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
import type { NumericSettingValues } from "@client/pages/settings/types"
|
import {
|
||||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
|
||||||
type GradcrackerSectionProps = {
|
type GradcrackerSectionProps = {
|
||||||
values: NumericSettingValues
|
values: NumericSettingValues;
|
||||||
isLoading: boolean
|
isLoading: boolean;
|
||||||
isSaving: boolean
|
isSaving: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
|
export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
|
||||||
values,
|
values,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSaving,
|
isSaving,
|
||||||
}) => {
|
}) => {
|
||||||
const { effective: effectiveGradcrackerMaxJobsPerTerm, default: defaultGradcrackerMaxJobsPerTerm } = values
|
const {
|
||||||
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
|
effective: effectiveGradcrackerMaxJobsPerTerm,
|
||||||
|
default: defaultGradcrackerMaxJobsPerTerm,
|
||||||
|
} = values;
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
formState: { errors },
|
||||||
|
} = useFormContext<UpdateSettingsInput>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordionItem value="gradcracker" className="border rounded-lg px-4">
|
<AccordionItem value="gradcracker" className="border rounded-lg px-4">
|
||||||
@@ -41,16 +50,20 @@ export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
|
|||||||
max: 1000,
|
max: 1000,
|
||||||
value: field.value ?? defaultGradcrackerMaxJobsPerTerm,
|
value: field.value ?? defaultGradcrackerMaxJobsPerTerm,
|
||||||
onChange: (event) => {
|
onChange: (event) => {
|
||||||
const value = parseInt(event.target.value, 10)
|
const value = parseInt(event.target.value, 10);
|
||||||
if (Number.isNaN(value)) {
|
if (Number.isNaN(value)) {
|
||||||
field.onChange(null)
|
field.onChange(null);
|
||||||
} else {
|
} else {
|
||||||
field.onChange(Math.min(1000, Math.max(1, value)))
|
field.onChange(Math.min(1000, Math.max(1, value)));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
disabled={isLoading || isSaving}
|
disabled={isLoading || isSaving}
|
||||||
error={errors.gradcrackerMaxJobsPerTerm?.message as string | undefined}
|
error={
|
||||||
|
errors.gradcrackerMaxJobsPerTerm?.message as
|
||||||
|
| string
|
||||||
|
| undefined
|
||||||
|
}
|
||||||
helper={`Maximum number of jobs to fetch for EACH search term from Gradcracker. Default: ${defaultGradcrackerMaxJobsPerTerm}. Range: 1-1000.`}
|
helper={`Maximum number of jobs to fetch for EACH search term from Gradcracker. Default: ${defaultGradcrackerMaxJobsPerTerm}. Range: 1-1000.`}
|
||||||
current={String(effectiveGradcrackerMaxJobsPerTerm)}
|
current={String(effectiveGradcrackerMaxJobsPerTerm)}
|
||||||
/>
|
/>
|
||||||
@@ -59,5 +72,5 @@ export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { describe, it, expect } from "vitest"
|
import type { UpdateSettingsInput } from "@shared/settings-schema";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react"
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { useForm, FormProvider } from "react-hook-form"
|
import { FormProvider, useForm } from "react-hook-form";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
import { Accordion } from "@/components/ui/accordion"
|
import { Accordion } from "@/components/ui/accordion";
|
||||||
import { JobspySection } from "./JobspySection"
|
import { JobspySection } from "./JobspySection";
|
||||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
|
||||||
|
|
||||||
const JobspyHarness = () => {
|
const JobspyHarness = () => {
|
||||||
const methods = useForm<UpdateSettingsInput>({
|
const methods = useForm<UpdateSettingsInput>({
|
||||||
@@ -15,15 +14,18 @@ const JobspyHarness = () => {
|
|||||||
jobspyHoursOld: 72,
|
jobspyHoursOld: 72,
|
||||||
jobspyCountryIndeed: "UK",
|
jobspyCountryIndeed: "UK",
|
||||||
jobspyLinkedinFetchDescription: true,
|
jobspyLinkedinFetchDescription: true,
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<Accordion type="multiple" defaultValue={["jobspy"]}>
|
<Accordion type="multiple" defaultValue={["jobspy"]}>
|
||||||
<JobspySection
|
<JobspySection
|
||||||
values={{
|
values={{
|
||||||
sites: { default: ["indeed", "linkedin"], effective: ["indeed", "linkedin"] },
|
sites: {
|
||||||
|
default: ["indeed", "linkedin"],
|
||||||
|
effective: ["indeed", "linkedin"],
|
||||||
|
},
|
||||||
location: { default: "UK", effective: "UK" },
|
location: { default: "UK", effective: "UK" },
|
||||||
resultsWanted: { default: 200, effective: 200 },
|
resultsWanted: { default: 200, effective: 200 },
|
||||||
hoursOld: { default: 72, effective: 72 },
|
hoursOld: { default: 72, effective: 72 },
|
||||||
@@ -35,39 +37,38 @@ const JobspyHarness = () => {
|
|||||||
/>
|
/>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
describe("JobspySection", () => {
|
describe("JobspySection", () => {
|
||||||
it("toggles scraped sites and keeps checkboxes in sync", () => {
|
it("toggles scraped sites and keeps checkboxes in sync", () => {
|
||||||
render(<JobspyHarness />)
|
render(<JobspyHarness />);
|
||||||
|
|
||||||
const indeedCheckbox = screen.getByLabelText("Indeed")
|
const indeedCheckbox = screen.getByLabelText("Indeed");
|
||||||
const linkedinCheckbox = screen.getByLabelText("LinkedIn")
|
const linkedinCheckbox = screen.getByLabelText("LinkedIn");
|
||||||
|
|
||||||
expect(indeedCheckbox).toBeChecked()
|
expect(indeedCheckbox).toBeChecked();
|
||||||
expect(linkedinCheckbox).toBeChecked()
|
expect(linkedinCheckbox).toBeChecked();
|
||||||
|
|
||||||
fireEvent.click(indeedCheckbox)
|
fireEvent.click(indeedCheckbox);
|
||||||
expect(indeedCheckbox).not.toBeChecked()
|
expect(indeedCheckbox).not.toBeChecked();
|
||||||
expect(linkedinCheckbox).toBeChecked()
|
expect(linkedinCheckbox).toBeChecked();
|
||||||
|
|
||||||
fireEvent.click(indeedCheckbox)
|
fireEvent.click(indeedCheckbox);
|
||||||
expect(indeedCheckbox).toBeChecked()
|
expect(indeedCheckbox).toBeChecked();
|
||||||
})
|
});
|
||||||
|
|
||||||
it("clamps numeric inputs to allowed ranges", () => {
|
it("clamps numeric inputs to allowed ranges", () => {
|
||||||
render(<JobspyHarness />)
|
render(<JobspyHarness />);
|
||||||
|
|
||||||
const numericInputs = screen.getAllByRole("spinbutton")
|
const numericInputs = screen.getAllByRole("spinbutton");
|
||||||
const resultsWantedInput = numericInputs[0]
|
const resultsWantedInput = numericInputs[0];
|
||||||
const hoursOldInput = numericInputs[1]
|
const hoursOldInput = numericInputs[1];
|
||||||
|
|
||||||
fireEvent.change(resultsWantedInput, { target: { value: "1001" } })
|
fireEvent.change(resultsWantedInput, { target: { value: "1001" } });
|
||||||
expect(resultsWantedInput).toHaveValue(1000)
|
expect(resultsWantedInput).toHaveValue(1000);
|
||||||
|
|
||||||
fireEvent.change(hoursOldInput, { target: { value: "0" } })
|
fireEvent.change(hoursOldInput, { target: { value: "0" } });
|
||||||
expect(hoursOldInput).toHaveValue(1)
|
expect(hoursOldInput).toHaveValue(1);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
import React from "react"
|
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
|
||||||
import { useFormContext, Controller } from "react-hook-form"
|
import type { JobspyValues } from "@client/pages/settings/types";
|
||||||
|
import type { UpdateSettingsInput } from "@shared/settings-schema";
|
||||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
import type React from "react";
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import {
|
||||||
import { Separator } from "@/components/ui/separator"
|
AccordionContent,
|
||||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
AccordionItem,
|
||||||
import type { JobspyValues } from "@client/pages/settings/types"
|
AccordionTrigger,
|
||||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
} from "@/components/ui/accordion";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
type JobspySectionProps = {
|
type JobspySectionProps = {
|
||||||
values: JobspyValues
|
values: JobspyValues;
|
||||||
isLoading: boolean
|
isLoading: boolean;
|
||||||
isSaving: boolean
|
isSaving: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
const JOBSPY_INDEED_COUNTRIES = [
|
const JOBSPY_INDEED_COUNTRIES = [
|
||||||
"argentina",
|
"argentina",
|
||||||
@@ -95,15 +104,15 @@ const JOBSPY_INDEED_COUNTRIES = [
|
|||||||
"vietnam",
|
"vietnam",
|
||||||
"usa/ca",
|
"usa/ca",
|
||||||
"worldwide",
|
"worldwide",
|
||||||
]
|
];
|
||||||
|
|
||||||
const COUNTRY_ALIASES: Record<string, string> = {
|
const COUNTRY_ALIASES: Record<string, string> = {
|
||||||
uk: "united kingdom",
|
uk: "united kingdom",
|
||||||
us: "united states",
|
us: "united states",
|
||||||
usa: "united states",
|
usa: "united states",
|
||||||
"türkiye": "turkey",
|
türkiye: "turkey",
|
||||||
"czech republic": "czechia",
|
"czech republic": "czechia",
|
||||||
}
|
};
|
||||||
|
|
||||||
const COUNTRY_LABELS: Record<string, string> = {
|
const COUNTRY_LABELS: Record<string, string> = {
|
||||||
"united kingdom": "United Kingdom",
|
"united kingdom": "United Kingdom",
|
||||||
@@ -111,21 +120,22 @@ const COUNTRY_LABELS: Record<string, string> = {
|
|||||||
"usa/ca": "USA/CA",
|
"usa/ca": "USA/CA",
|
||||||
turkey: "Turkey",
|
turkey: "Turkey",
|
||||||
czechia: "Czechia",
|
czechia: "Czechia",
|
||||||
}
|
};
|
||||||
|
|
||||||
const normalizeCountryValue = (value: string) => COUNTRY_ALIASES[value] ?? value
|
const normalizeCountryValue = (value: string) =>
|
||||||
|
COUNTRY_ALIASES[value] ?? value;
|
||||||
|
|
||||||
const formatCountryLabel = (value: string) =>
|
const formatCountryLabel = (value: string) =>
|
||||||
COUNTRY_LABELS[value] || value.replace(/\b\w/g, (char) => char.toUpperCase())
|
COUNTRY_LABELS[value] || value.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||||
|
|
||||||
const JOBSPY_INDEED_COUNTRY_OPTIONS = Array.from(
|
const JOBSPY_INDEED_COUNTRY_OPTIONS = Array.from(
|
||||||
new Map(
|
new Map(
|
||||||
JOBSPY_INDEED_COUNTRIES.map((country) => {
|
JOBSPY_INDEED_COUNTRIES.map((country) => {
|
||||||
const normalized = normalizeCountryValue(country)
|
const normalized = normalizeCountryValue(country);
|
||||||
return [normalized, normalized]
|
return [normalized, normalized];
|
||||||
})
|
}),
|
||||||
).values()
|
).values(),
|
||||||
)
|
);
|
||||||
|
|
||||||
export const JobspySection: React.FC<JobspySectionProps> = ({
|
export const JobspySection: React.FC<JobspySectionProps> = ({
|
||||||
values,
|
values,
|
||||||
@@ -139,8 +149,12 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
|
|||||||
hoursOld,
|
hoursOld,
|
||||||
countryIndeed,
|
countryIndeed,
|
||||||
linkedinFetchDescription,
|
linkedinFetchDescription,
|
||||||
} = values
|
} = values;
|
||||||
const { control, register, formState: { errors } } = useFormContext<UpdateSettingsInput>()
|
const {
|
||||||
|
control,
|
||||||
|
register,
|
||||||
|
formState: { errors },
|
||||||
|
} = useFormContext<UpdateSettingsInput>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordionItem value="jobspy" className="border rounded-lg px-4">
|
<AccordionItem value="jobspy" className="border rounded-lg px-4">
|
||||||
@@ -159,22 +173,30 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="site-indeed"
|
id="site-indeed"
|
||||||
checked={field.value?.includes('indeed') ?? sites.default.includes('indeed')}
|
checked={
|
||||||
|
field.value?.includes("indeed") ??
|
||||||
|
sites.default.includes("indeed")
|
||||||
|
}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
const current = field.value ?? sites.default
|
const current = field.value ?? sites.default;
|
||||||
let next = [...current]
|
let next = [...current];
|
||||||
if (checked) {
|
if (checked) {
|
||||||
if (!next.includes('indeed')) next.push('indeed')
|
if (!next.includes("indeed")) next.push("indeed");
|
||||||
} else {
|
} else {
|
||||||
next = next.filter(s => s !== 'indeed')
|
next = next.filter((s) => s !== "indeed");
|
||||||
}
|
}
|
||||||
field.onChange(next)
|
field.onChange(next);
|
||||||
}}
|
}}
|
||||||
disabled={isLoading || isSaving}
|
disabled={isLoading || isSaving}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="site-indeed" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">Indeed</label>
|
<label
|
||||||
|
htmlFor="site-indeed"
|
||||||
|
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
Indeed
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Controller
|
<Controller
|
||||||
@@ -183,31 +205,45 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="site-linkedin"
|
id="site-linkedin"
|
||||||
checked={field.value?.includes('linkedin') ?? sites.default.includes('linkedin')}
|
checked={
|
||||||
|
field.value?.includes("linkedin") ??
|
||||||
|
sites.default.includes("linkedin")
|
||||||
|
}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
const current = field.value ?? sites.default
|
const current = field.value ?? sites.default;
|
||||||
let next = [...current]
|
let next = [...current];
|
||||||
if (checked) {
|
if (checked) {
|
||||||
if (!next.includes('linkedin')) next.push('linkedin')
|
if (!next.includes("linkedin")) next.push("linkedin");
|
||||||
} else {
|
} else {
|
||||||
next = next.filter(s => s !== 'linkedin')
|
next = next.filter((s) => s !== "linkedin");
|
||||||
}
|
}
|
||||||
field.onChange(next)
|
field.onChange(next);
|
||||||
}}
|
}}
|
||||||
disabled={isLoading || isSaving}
|
disabled={isLoading || isSaving}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="site-linkedin" className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">LinkedIn</label>
|
<label
|
||||||
|
htmlFor="site-linkedin"
|
||||||
|
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
LinkedIn
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{errors.jobspySites && <p className="text-xs text-destructive">{errors.jobspySites.message}</p>}
|
{errors.jobspySites && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{errors.jobspySites.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Select which sites JobSpy should scrape.
|
Select which sites JobSpy should scrape.
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||||
<span>Effective: {(sites.effective || []).join(', ') || "None"}</span>
|
<span>
|
||||||
<span>Default: {(sites.default || []).join(', ')}</span>
|
Effective: {(sites.effective || []).join(", ") || "None"}
|
||||||
|
</span>
|
||||||
|
<span>Default: {(sites.default || []).join(", ")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -218,7 +254,9 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
|
|||||||
placeholder={location.default || "UK"}
|
placeholder={location.default || "UK"}
|
||||||
disabled={isLoading || isSaving}
|
disabled={isLoading || isSaving}
|
||||||
error={errors.jobspyLocation?.message as string | undefined}
|
error={errors.jobspyLocation?.message as string | undefined}
|
||||||
helper={'Location to search for jobs (e.g. "UK", "London", "Remote").'}
|
helper={
|
||||||
|
'Location to search for jobs (e.g. "UK", "London", "Remote").'
|
||||||
|
}
|
||||||
current={`Effective: ${location.effective || "—"} | Default: ${location.default || "—"}`}
|
current={`Effective: ${location.effective || "—"} | Default: ${location.default || "—"}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -236,16 +274,18 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
|
|||||||
max: 1000,
|
max: 1000,
|
||||||
value: field.value ?? resultsWanted.default,
|
value: field.value ?? resultsWanted.default,
|
||||||
onChange: (event) => {
|
onChange: (event) => {
|
||||||
const value = parseInt(event.target.value, 10)
|
const value = parseInt(event.target.value, 10);
|
||||||
if (Number.isNaN(value)) {
|
if (Number.isNaN(value)) {
|
||||||
field.onChange(null)
|
field.onChange(null);
|
||||||
} else {
|
} else {
|
||||||
field.onChange(Math.min(1000, Math.max(1, value)))
|
field.onChange(Math.min(1000, Math.max(1, value)));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
disabled={isLoading || isSaving}
|
disabled={isLoading || isSaving}
|
||||||
error={errors.jobspyResultsWanted?.message as string | undefined}
|
error={
|
||||||
|
errors.jobspyResultsWanted?.message as string | undefined
|
||||||
|
}
|
||||||
helper={`Number of results to fetch per term per site. Default: ${resultsWanted.default}. Max 1000.`}
|
helper={`Number of results to fetch per term per site. Default: ${resultsWanted.default}. Max 1000.`}
|
||||||
current={`Effective: ${resultsWanted.effective} | Default: ${resultsWanted.default}`}
|
current={`Effective: ${resultsWanted.effective} | Default: ${resultsWanted.default}`}
|
||||||
/>
|
/>
|
||||||
@@ -266,11 +306,11 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
|
|||||||
max: 720,
|
max: 720,
|
||||||
value: field.value ?? hoursOld.default,
|
value: field.value ?? hoursOld.default,
|
||||||
onChange: (event) => {
|
onChange: (event) => {
|
||||||
const value = parseInt(event.target.value, 10)
|
const value = parseInt(event.target.value, 10);
|
||||||
if (Number.isNaN(value)) {
|
if (Number.isNaN(value)) {
|
||||||
field.onChange(null)
|
field.onChange(null);
|
||||||
} else {
|
} else {
|
||||||
field.onChange(Math.min(720, Math.max(1, value)))
|
field.onChange(Math.min(720, Math.max(1, value)));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -286,24 +326,33 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
|
|||||||
name="jobspyCountryIndeed"
|
name="jobspyCountryIndeed"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
const currentValue = (field.value ?? countryIndeed.default ?? "").toLowerCase()
|
const currentValue = (
|
||||||
const normalizedValue = normalizeCountryValue(currentValue)
|
field.value ??
|
||||||
const displayValue = JOBSPY_INDEED_COUNTRY_OPTIONS.includes(normalizedValue)
|
countryIndeed.default ??
|
||||||
|
""
|
||||||
|
).toLowerCase();
|
||||||
|
const normalizedValue = normalizeCountryValue(currentValue);
|
||||||
|
const displayValue = JOBSPY_INDEED_COUNTRY_OPTIONS.includes(
|
||||||
|
normalizedValue,
|
||||||
|
)
|
||||||
? normalizedValue
|
? normalizedValue
|
||||||
: "__default__"
|
: "__default__";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="jobspyCountryIndeed" className="text-sm font-medium">
|
<label
|
||||||
|
htmlFor="jobspyCountryIndeed"
|
||||||
|
className="text-sm font-medium"
|
||||||
|
>
|
||||||
Indeed Country
|
Indeed Country
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
value={displayValue}
|
value={displayValue}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (value === "__default__") {
|
if (value === "__default__") {
|
||||||
field.onChange(null)
|
field.onChange(null);
|
||||||
} else {
|
} else {
|
||||||
field.onChange(value)
|
field.onChange(value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={isLoading || isSaving}
|
disabled={isLoading || isSaving}
|
||||||
@@ -323,7 +372,9 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{errors.jobspyCountryIndeed && (
|
{errors.jobspyCountryIndeed && (
|
||||||
<p className="text-xs text-destructive">{errors.jobspyCountryIndeed.message}</p>
|
<p className="text-xs text-destructive">
|
||||||
|
{errors.jobspyCountryIndeed.message}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Select one of JobSpy's supported Indeed country values.
|
Select one of JobSpy's supported Indeed country values.
|
||||||
@@ -332,7 +383,7 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
|
|||||||
{`Effective: ${countryIndeed.effective || "—"} | Default: ${countryIndeed.default || "—"}`}
|
{`Effective: ${countryIndeed.effective || "—"} | Default: ${countryIndeed.default || "—"}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -360,16 +411,21 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
|
|||||||
Fetch LinkedIn Description
|
Fetch LinkedIn Description
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
If enabled, JobSpy will make extra requests to fetch full descriptions. Slower but better data.
|
If enabled, JobSpy will make extra requests to fetch full
|
||||||
|
descriptions. Slower but better data.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||||
<span>Effective: {linkedinFetchDescription.effective ? "Yes" : "No"}</span>
|
<span>
|
||||||
<span>Default: {linkedinFetchDescription.default ? "Yes" : "No"}</span>
|
Effective: {linkedinFetchDescription.effective ? "Yes" : "No"}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Default: {linkedinFetchDescription.default ? "Yes" : "No"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,25 +1,37 @@
|
|||||||
import React from "react"
|
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
|
||||||
import { useFormContext } from "react-hook-form"
|
import type { ModelValues } from "@client/pages/settings/types";
|
||||||
|
import type { UpdateSettingsInput } from "@shared/settings-schema";
|
||||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
import type React from "react";
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { useFormContext } from "react-hook-form";
|
||||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
import {
|
||||||
import type { ModelValues } from "@client/pages/settings/types"
|
AccordionContent,
|
||||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
type ModelSettingsSectionProps = {
|
type ModelSettingsSectionProps = {
|
||||||
values: ModelValues
|
values: ModelValues;
|
||||||
isLoading: boolean
|
isLoading: boolean;
|
||||||
isSaving: boolean
|
isSaving: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
||||||
values,
|
values,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSaving,
|
isSaving,
|
||||||
}) => {
|
}) => {
|
||||||
const { effective, default: defaultModel, scorer, tailoring, projectSelection } = values
|
const {
|
||||||
const { register, formState: { errors } } = useFormContext<UpdateSettingsInput>()
|
effective,
|
||||||
|
default: defaultModel,
|
||||||
|
scorer,
|
||||||
|
tailoring,
|
||||||
|
projectSelection,
|
||||||
|
} = values;
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
formState: { errors },
|
||||||
|
} = useFormContext<UpdateSettingsInput>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordionItem value="model" className="border rounded-lg px-4">
|
<AccordionItem value="model" className="border rounded-lg px-4">
|
||||||
@@ -67,7 +79,9 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
|||||||
inputProps={register("modelProjectSelection")}
|
inputProps={register("modelProjectSelection")}
|
||||||
placeholder={effective || "inherit"}
|
placeholder={effective || "inherit"}
|
||||||
disabled={isLoading || isSaving}
|
disabled={isLoading || isSaving}
|
||||||
error={errors.modelProjectSelection?.message as string | undefined}
|
error={
|
||||||
|
errors.modelProjectSelection?.message as string | undefined
|
||||||
|
}
|
||||||
current={projectSelection || effective || "—"}
|
current={projectSelection || effective || "—"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,16 +91,22 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
|||||||
|
|
||||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-muted-foreground">Global Effective</div>
|
<div className="text-xs text-muted-foreground">
|
||||||
<div className="break-words font-mono text-xs">{effective || "—"}</div>
|
Global Effective
|
||||||
|
</div>
|
||||||
|
<div className="break-words font-mono text-xs">
|
||||||
|
{effective || "—"}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-muted-foreground">Default (env)</div>
|
<div className="text-xs text-muted-foreground">Default (env)</div>
|
||||||
<div className="break-words font-mono text-xs">{defaultModel || "—"}</div>
|
<div className="break-words font-mono text-xs">
|
||||||
|
{defaultModel || "—"}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,211 +1,296 @@
|
|||||||
import React from "react"
|
import type { UpdateSettingsInput } from "@shared/settings-schema";
|
||||||
import { Controller, useFormContext } from "react-hook-form"
|
import type { ResumeProjectCatalogItem } from "@shared/types";
|
||||||
import { AlertCircle, CheckCircle2 } from "lucide-react"
|
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import {
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
AccordionContent,
|
||||||
import { Input } from "@/components/ui/input"
|
AccordionItem,
|
||||||
import { Separator } from "@/components/ui/separator"
|
AccordionTrigger,
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
} from "@/components/ui/accordion";
|
||||||
import { clampInt } from "@/lib/utils"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import type { ResumeProjectCatalogItem } from "@shared/types"
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
import { Input } from "@/components/ui/input";
|
||||||
import { BaseResumeSelection } from "./BaseResumeSelection"
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { clampInt } from "@/lib/utils";
|
||||||
|
import { BaseResumeSelection } from "./BaseResumeSelection";
|
||||||
|
|
||||||
type ReactiveResumeSectionProps = {
|
type ReactiveResumeSectionProps = {
|
||||||
rxResumeBaseResumeIdDraft: string | null
|
rxResumeBaseResumeIdDraft: string | null;
|
||||||
setRxResumeBaseResumeIdDraft: (value: string | null) => void
|
setRxResumeBaseResumeIdDraft: (value: string | null) => void;
|
||||||
// True when v4 credentials or v5 API key are configured.
|
// True when v4 credentials or v5 API key are configured.
|
||||||
hasRxResumeAccess: boolean
|
hasRxResumeAccess: boolean;
|
||||||
profileProjects: ResumeProjectCatalogItem[]
|
profileProjects: ResumeProjectCatalogItem[];
|
||||||
lockedCount: number
|
lockedCount: number;
|
||||||
maxProjectsTotal: number
|
maxProjectsTotal: number;
|
||||||
isProjectsLoading: boolean
|
isProjectsLoading: boolean;
|
||||||
isLoading: boolean
|
isLoading: boolean;
|
||||||
isSaving: boolean
|
isSaving: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
|
export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
|
||||||
rxResumeBaseResumeIdDraft,
|
rxResumeBaseResumeIdDraft,
|
||||||
setRxResumeBaseResumeIdDraft,
|
setRxResumeBaseResumeIdDraft,
|
||||||
hasRxResumeAccess,
|
hasRxResumeAccess,
|
||||||
profileProjects,
|
profileProjects,
|
||||||
lockedCount,
|
lockedCount,
|
||||||
maxProjectsTotal,
|
maxProjectsTotal,
|
||||||
isProjectsLoading,
|
isProjectsLoading,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSaving,
|
isSaving,
|
||||||
}) => {
|
}) => {
|
||||||
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
|
const {
|
||||||
|
control,
|
||||||
|
formState: { errors },
|
||||||
|
} = useFormContext<UpdateSettingsInput>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordionItem value="reactive-resume" className="border rounded-lg px-4">
|
<AccordionItem value="reactive-resume" className="border rounded-lg px-4">
|
||||||
<AccordionTrigger className="hover:no-underline py-4">
|
<AccordionTrigger className="hover:no-underline py-4">
|
||||||
<span className="text-base font-semibold">Reactive Resume</span>
|
<span className="text-base font-semibold">Reactive Resume</span>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="pb-4">
|
<AccordionContent className="pb-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{!hasRxResumeAccess ? (
|
{!hasRxResumeAccess ? (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertTitle>RxResume Access Missing</AlertTitle>
|
<AlertTitle>RxResume Access Missing</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Configure RxResume credentials in settings (email + password) or set <code>RXRESUME_API_KEY</code> to enable access.
|
Configure RxResume credentials in settings (email + password) or
|
||||||
</AlertDescription>
|
set <code>RXRESUME_API_KEY</code> to enable access.
|
||||||
</Alert>
|
</AlertDescription>
|
||||||
) : (
|
</Alert>
|
||||||
<>
|
) : (
|
||||||
<Alert className="bg-green-50 border-green-200 dark:bg-green-900/10 dark:border-green-900/20">
|
<>
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" />
|
<Alert className="bg-green-50 border-green-200 dark:bg-green-900/10 dark:border-green-900/20">
|
||||||
<AlertTitle className="text-green-800 dark:text-green-300">RxResume Access Ready</AlertTitle>
|
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||||
<AlertDescription className="text-green-700 dark:text-green-400">
|
<AlertTitle className="text-green-800 dark:text-green-300">
|
||||||
Reactive Resume access is active.
|
RxResume Access Ready
|
||||||
</AlertDescription>
|
</AlertTitle>
|
||||||
</Alert>
|
<AlertDescription className="text-green-700 dark:text-green-400">
|
||||||
|
Reactive Resume access is active.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<BaseResumeSelection
|
<BaseResumeSelection
|
||||||
value={rxResumeBaseResumeIdDraft}
|
value={rxResumeBaseResumeIdDraft}
|
||||||
onValueChange={setRxResumeBaseResumeIdDraft}
|
onValueChange={setRxResumeBaseResumeIdDraft}
|
||||||
hasRxResumeAccess={hasRxResumeAccess}
|
hasRxResumeAccess={hasRxResumeAccess}
|
||||||
disabled={isLoading || isSaving}
|
disabled={isLoading || isSaving}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{!rxResumeBaseResumeIdDraft ? (
|
{!rxResumeBaseResumeIdDraft ? (
|
||||||
<div className="rounded-md border border-dashed border-muted-foreground/40 bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
<div className="rounded-md border border-dashed border-muted-foreground/40 bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||||||
Choose a PDF to configure resume projects.
|
Choose a PDF to configure resume projects.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
Max projects to choose
|
||||||
|
</div>
|
||||||
|
<Controller
|
||||||
|
name="resumeProjects"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={lockedCount}
|
||||||
|
max={maxProjectsTotal}
|
||||||
|
value={field.value?.maxProjects ?? 0}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (!field.value) return;
|
||||||
|
const next = Number(event.target.value);
|
||||||
|
const clamped = clampInt(
|
||||||
|
next,
|
||||||
|
lockedCount,
|
||||||
|
maxProjectsTotal,
|
||||||
|
);
|
||||||
|
field.onChange({
|
||||||
|
...field.value,
|
||||||
|
maxProjects: clamped,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
isLoading ||
|
||||||
|
isSaving ||
|
||||||
|
isProjectsLoading ||
|
||||||
|
!field.value
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.resumeProjects?.maxProjects && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{errors.resumeProjects.maxProjects.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="resumeProjects"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
|
||||||
|
Project
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
|
||||||
|
Visible in template
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
|
||||||
|
Must Include
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">
|
||||||
|
AI selectable
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{profileProjects.map((project) => {
|
||||||
|
const locked = Boolean(
|
||||||
|
field.value?.lockedProjectIds.includes(
|
||||||
|
project.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const aiSelectable = Boolean(
|
||||||
|
field.value?.aiSelectableProjectIds.includes(
|
||||||
|
project.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={project.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="font-medium">
|
||||||
|
{project.name || project.id}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{[project.description, project.date]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" - ")}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</TableCell>
|
||||||
<>
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
<div className="space-y-2">
|
{project.isVisibleInBase ? "Yes" : "No"}
|
||||||
<div className="text-sm font-medium">
|
</TableCell>
|
||||||
Max projects to choose
|
<TableCell>
|
||||||
</div>
|
<Checkbox
|
||||||
<Controller
|
checked={locked}
|
||||||
name="resumeProjects"
|
disabled={
|
||||||
control={control}
|
isLoading ||
|
||||||
render={({ field }) => (
|
isSaving ||
|
||||||
<Input
|
isProjectsLoading ||
|
||||||
type="number"
|
!field.value
|
||||||
inputMode="numeric"
|
}
|
||||||
min={lockedCount}
|
onCheckedChange={(checked) => {
|
||||||
max={maxProjectsTotal}
|
if (!field.value) return;
|
||||||
value={field.value?.maxProjects ?? 0}
|
const isChecked = checked === true;
|
||||||
onChange={(event) => {
|
const lockedIds =
|
||||||
if (!field.value) return
|
field.value.lockedProjectIds.slice();
|
||||||
const next = Number(event.target.value)
|
const selectableIds =
|
||||||
const clamped = clampInt(next, lockedCount, maxProjectsTotal)
|
field.value.aiSelectableProjectIds.slice();
|
||||||
field.onChange({ ...field.value, maxProjects: clamped })
|
|
||||||
}}
|
|
||||||
disabled={isLoading || isSaving || isProjectsLoading || !field.value}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{errors.resumeProjects?.maxProjects && (
|
|
||||||
<p className="text-xs text-destructive">
|
|
||||||
{errors.resumeProjects.maxProjects.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Controller
|
if (isChecked) {
|
||||||
name="resumeProjects"
|
if (!lockedIds.includes(project.id))
|
||||||
control={control}
|
lockedIds.push(project.id);
|
||||||
render={({ field }) => (
|
const nextSelectable =
|
||||||
<Table>
|
selectableIds.filter(
|
||||||
<TableHeader>
|
(id) => id !== project.id,
|
||||||
<TableRow>
|
);
|
||||||
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">Project</TableHead>
|
const minCap = lockedIds.length;
|
||||||
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">Visible in template</TableHead>
|
field.onChange({
|
||||||
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">Must Include</TableHead>
|
...field.value,
|
||||||
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">AI selectable</TableHead>
|
lockedProjectIds: lockedIds,
|
||||||
</TableRow>
|
aiSelectableProjectIds:
|
||||||
</TableHeader>
|
nextSelectable,
|
||||||
|
maxProjects: Math.max(
|
||||||
|
field.value.maxProjects,
|
||||||
|
minCap,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
<TableBody>
|
const nextLocked = lockedIds.filter(
|
||||||
{profileProjects.map((project) => {
|
(id) => id !== project.id,
|
||||||
const locked = Boolean(field.value?.lockedProjectIds.includes(project.id))
|
);
|
||||||
const aiSelectable = Boolean(field.value?.aiSelectableProjectIds.includes(project.id))
|
if (!selectableIds.includes(project.id))
|
||||||
|
selectableIds.push(project.id);
|
||||||
return (
|
field.onChange({
|
||||||
<TableRow key={project.id}>
|
...field.value,
|
||||||
<TableCell>
|
lockedProjectIds: nextLocked,
|
||||||
<div className="space-y-0.5">
|
aiSelectableProjectIds: selectableIds,
|
||||||
<div className="font-medium">{project.name || project.id}</div>
|
maxProjects: clampInt(
|
||||||
<div className="text-xs text-muted-foreground">
|
field.value.maxProjects,
|
||||||
{[project.description, project.date].filter(Boolean).join(" - ")}
|
nextLocked.length,
|
||||||
</div>
|
maxProjectsTotal,
|
||||||
</div>
|
),
|
||||||
</TableCell>
|
});
|
||||||
<TableCell className="text-xs text-muted-foreground">{project.isVisibleInBase ? "Yes" : "No"}</TableCell>
|
}}
|
||||||
<TableCell>
|
/>
|
||||||
<Checkbox
|
</TableCell>
|
||||||
checked={locked}
|
<TableCell>
|
||||||
disabled={isLoading || isSaving || isProjectsLoading || !field.value}
|
<Checkbox
|
||||||
onCheckedChange={(checked) => {
|
checked={locked ? true : aiSelectable}
|
||||||
if (!field.value) return
|
disabled={
|
||||||
const isChecked = checked === true
|
locked ||
|
||||||
const lockedIds = field.value.lockedProjectIds.slice()
|
isLoading ||
|
||||||
const selectableIds = field.value.aiSelectableProjectIds.slice()
|
isSaving ||
|
||||||
|
isProjectsLoading ||
|
||||||
if (isChecked) {
|
!field.value
|
||||||
if (!lockedIds.includes(project.id)) lockedIds.push(project.id)
|
}
|
||||||
const nextSelectable = selectableIds.filter((id) => id !== project.id)
|
onCheckedChange={(checked) => {
|
||||||
const minCap = lockedIds.length
|
if (!field.value) return;
|
||||||
field.onChange({
|
const isChecked = checked === true;
|
||||||
...field.value,
|
const selectableIds =
|
||||||
lockedProjectIds: lockedIds,
|
field.value.aiSelectableProjectIds.slice();
|
||||||
aiSelectableProjectIds: nextSelectable,
|
const nextSelectable = isChecked
|
||||||
maxProjects: Math.max(field.value.maxProjects, minCap),
|
? selectableIds.includes(project.id)
|
||||||
})
|
? selectableIds
|
||||||
return
|
: [...selectableIds, project.id]
|
||||||
}
|
: selectableIds.filter(
|
||||||
|
(id) => id !== project.id,
|
||||||
const nextLocked = lockedIds.filter((id) => id !== project.id)
|
);
|
||||||
if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
|
field.onChange({
|
||||||
field.onChange({
|
...field.value,
|
||||||
...field.value,
|
aiSelectableProjectIds:
|
||||||
lockedProjectIds: nextLocked,
|
nextSelectable,
|
||||||
aiSelectableProjectIds: selectableIds,
|
});
|
||||||
maxProjects: clampInt(field.value.maxProjects, nextLocked.length, maxProjectsTotal),
|
}}
|
||||||
})
|
/>
|
||||||
}}
|
</TableCell>
|
||||||
/>
|
</TableRow>
|
||||||
</TableCell>
|
);
|
||||||
<TableCell>
|
})}
|
||||||
<Checkbox
|
</TableBody>
|
||||||
checked={locked ? true : aiSelectable}
|
</Table>
|
||||||
disabled={locked || isLoading || isSaving || isProjectsLoading || !field.value}
|
)}
|
||||||
onCheckedChange={(checked) => {
|
/>
|
||||||
if (!field.value) return
|
</>
|
||||||
const isChecked = checked === true
|
)}
|
||||||
const selectableIds = field.value.aiSelectableProjectIds.slice()
|
</div>
|
||||||
const nextSelectable = isChecked
|
</>
|
||||||
? selectableIds.includes(project.id)
|
)}
|
||||||
? selectableIds
|
</div>
|
||||||
: [...selectableIds, project.id]
|
</AccordionContent>
|
||||||
: selectableIds.filter((id) => id !== project.id)
|
</AccordionItem>
|
||||||
field.onChange({ ...field.value, aiSelectableProjectIds: nextSelectable })
|
);
|
||||||
}}
|
};
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,24 +1,31 @@
|
|||||||
import React from "react"
|
import type { SearchTermsValues } from "@client/pages/settings/types";
|
||||||
import { useFormContext, Controller } from "react-hook-form"
|
import type { UpdateSettingsInput } from "@shared/settings-schema";
|
||||||
|
import type React from "react";
|
||||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
import { Separator } from "@/components/ui/separator"
|
import {
|
||||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
AccordionContent,
|
||||||
import type { SearchTermsValues } from "@client/pages/settings/types"
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
type SearchTermsSectionProps = {
|
type SearchTermsSectionProps = {
|
||||||
values: SearchTermsValues
|
values: SearchTermsValues;
|
||||||
isLoading: boolean
|
isLoading: boolean;
|
||||||
isSaving: boolean
|
isSaving: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
|
export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
|
||||||
values,
|
values,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSaving,
|
isSaving,
|
||||||
}) => {
|
}) => {
|
||||||
const { default: defaultSearchTerms, effective: effectiveSearchTerms } = values
|
const { default: defaultSearchTerms, effective: effectiveSearchTerms } =
|
||||||
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
|
values;
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
formState: { errors },
|
||||||
|
} = useFormContext<UpdateSettingsInput>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordionItem value="search-terms" className="border rounded-lg px-4">
|
<AccordionItem value="search-terms" className="border rounded-lg px-4">
|
||||||
@@ -35,15 +42,21 @@ export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<textarea
|
<textarea
|
||||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
value={field.value ? field.value.join('\n') : (defaultSearchTerms ?? []).join('\n')}
|
value={
|
||||||
|
field.value
|
||||||
|
? field.value.join("\n")
|
||||||
|
: (defaultSearchTerms ?? []).join("\n")
|
||||||
|
}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const text = event.target.value
|
const text = event.target.value;
|
||||||
const terms = text.split('\n')
|
const terms = text.split("\n");
|
||||||
field.onChange(terms)
|
field.onChange(terms);
|
||||||
}}
|
}}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
if (field.value) {
|
if (field.value) {
|
||||||
field.onChange(field.value.map(t => t.trim()).filter(Boolean))
|
field.onChange(
|
||||||
|
field.value.map((t) => t.trim()).filter(Boolean),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="e.g. web developer"
|
placeholder="e.g. web developer"
|
||||||
@@ -52,9 +65,14 @@ export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{errors.searchTerms && <p className="text-xs text-destructive">{errors.searchTerms.message}</p>}
|
{errors.searchTerms && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{errors.searchTerms.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
One term per line. Applies to UKVisaJobs and other supported extractors.
|
One term per line. Applies to UKVisaJobs and other supported
|
||||||
|
extractors.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -63,15 +81,19 @@ export const SearchTermsSection: React.FC<SearchTermsSectionProps> = ({
|
|||||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-muted-foreground">Effective</div>
|
<div className="text-xs text-muted-foreground">Effective</div>
|
||||||
<div className="break-words font-mono text-xs">{(effectiveSearchTerms || []).join(', ') || "—"}</div>
|
<div className="break-words font-mono text-xs">
|
||||||
|
{(effectiveSearchTerms || []).join(", ") || "—"}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-muted-foreground">Default (env)</div>
|
<div className="text-xs text-muted-foreground">Default (env)</div>
|
||||||
<div className="break-words font-mono text-xs">{(defaultSearchTerms || []).join(', ') || "—"}</div>
|
<div className="break-words font-mono text-xs">
|
||||||
|
{(defaultSearchTerms || []).join(", ") || "—"}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import React from "react"
|
import type React from "react";
|
||||||
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
type SettingsInputProps = {
|
type SettingsInputProps = {
|
||||||
label: string
|
label: string;
|
||||||
inputProps: React.InputHTMLAttributes<HTMLInputElement>
|
inputProps: React.InputHTMLAttributes<HTMLInputElement>;
|
||||||
placeholder?: string
|
placeholder?: string;
|
||||||
type?: React.HTMLInputTypeAttribute
|
type?: React.HTMLInputTypeAttribute;
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
error?: string
|
error?: string;
|
||||||
helper?: string
|
helper?: string;
|
||||||
current?: string | null
|
current?: string | null;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const SettingsInput: React.FC<SettingsInputProps> = ({
|
export const SettingsInput: React.FC<SettingsInputProps> = ({
|
||||||
label,
|
label,
|
||||||
@@ -23,7 +23,7 @@ export const SettingsInput: React.FC<SettingsInputProps> = ({
|
|||||||
helper,
|
helper,
|
||||||
current,
|
current,
|
||||||
}) => {
|
}) => {
|
||||||
const id = inputProps.id || inputProps.name
|
const id = inputProps.id || inputProps.name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -32,7 +32,13 @@ export const SettingsInput: React.FC<SettingsInputProps> = ({
|
|||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<Input {...inputProps} id={id} type={type} placeholder={placeholder} disabled={disabled} />
|
<Input
|
||||||
|
{...inputProps}
|
||||||
|
id={id}
|
||||||
|
type={type}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
{helper && <div className="text-xs text-muted-foreground">{helper}</div>}
|
{helper && <div className="text-xs text-muted-foreground">{helper}</div>}
|
||||||
{current !== undefined && (
|
{current !== undefined && (
|
||||||
@@ -41,5 +47,5 @@ export const SettingsInput: React.FC<SettingsInputProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,24 +1,33 @@
|
|||||||
import React from "react"
|
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
|
||||||
import { useFormContext, Controller } from "react-hook-form"
|
import type { NumericSettingValues } from "@client/pages/settings/types";
|
||||||
|
import type { UpdateSettingsInput } from "@shared/settings-schema";
|
||||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
import type React from "react";
|
||||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
import type { NumericSettingValues } from "@client/pages/settings/types"
|
import {
|
||||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
|
||||||
type UkvisajobsSectionProps = {
|
type UkvisajobsSectionProps = {
|
||||||
values: NumericSettingValues
|
values: NumericSettingValues;
|
||||||
isLoading: boolean
|
isLoading: boolean;
|
||||||
isSaving: boolean
|
isSaving: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
|
export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
|
||||||
values,
|
values,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSaving,
|
isSaving,
|
||||||
}) => {
|
}) => {
|
||||||
const { effective: effectiveUkvisajobsMaxJobs, default: defaultUkvisajobsMaxJobs } = values
|
const {
|
||||||
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
|
effective: effectiveUkvisajobsMaxJobs,
|
||||||
|
default: defaultUkvisajobsMaxJobs,
|
||||||
|
} = values;
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
formState: { errors },
|
||||||
|
} = useFormContext<UpdateSettingsInput>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordionItem value="ukvisajobs" className="border rounded-lg px-4">
|
<AccordionItem value="ukvisajobs" className="border rounded-lg px-4">
|
||||||
@@ -41,11 +50,11 @@ export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
|
|||||||
max: 1000,
|
max: 1000,
|
||||||
value: field.value ?? defaultUkvisajobsMaxJobs,
|
value: field.value ?? defaultUkvisajobsMaxJobs,
|
||||||
onChange: (event) => {
|
onChange: (event) => {
|
||||||
const value = parseInt(event.target.value, 10)
|
const value = parseInt(event.target.value, 10);
|
||||||
if (Number.isNaN(value)) {
|
if (Number.isNaN(value)) {
|
||||||
field.onChange(null)
|
field.onChange(null);
|
||||||
} else {
|
} else {
|
||||||
field.onChange(Math.min(1000, Math.max(1, value)))
|
field.onChange(Math.min(1000, Math.max(1, value)));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -59,5 +68,5 @@ export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { render, screen } from "@testing-library/react"
|
import type { UpdateSettingsInput } from "@shared/settings-schema";
|
||||||
import { useForm, FormProvider } from "react-hook-form"
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { FormProvider, useForm } from "react-hook-form";
|
||||||
import { Accordion } from "@/components/ui/accordion"
|
import { Accordion } from "@/components/ui/accordion";
|
||||||
import { WebhooksSection } from "./WebhooksSection"
|
import { WebhooksSection } from "./WebhooksSection";
|
||||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
|
||||||
|
|
||||||
const WebhooksHarness = () => {
|
const WebhooksHarness = () => {
|
||||||
const methods = useForm<UpdateSettingsInput>({
|
const methods = useForm<UpdateSettingsInput>({
|
||||||
@@ -11,8 +10,8 @@ const WebhooksHarness = () => {
|
|||||||
pipelineWebhookUrl: "https://pipeline.com",
|
pipelineWebhookUrl: "https://pipeline.com",
|
||||||
jobCompleteWebhookUrl: "https://job.com",
|
jobCompleteWebhookUrl: "https://job.com",
|
||||||
webhookSecret: "",
|
webhookSecret: "",
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
@@ -32,19 +31,21 @@ const WebhooksHarness = () => {
|
|||||||
/>
|
/>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
describe("WebhooksSection", () => {
|
describe("WebhooksSection", () => {
|
||||||
it("renders both webhook sections and the secret", () => {
|
it("renders both webhook sections and the secret", () => {
|
||||||
render(<WebhooksHarness />)
|
render(<WebhooksHarness />);
|
||||||
|
|
||||||
expect(screen.getByText("Pipeline Status")).toBeInTheDocument()
|
expect(screen.getByText("Pipeline Status")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Job Completion")).toBeInTheDocument()
|
expect(screen.getByText("Job Completion")).toBeInTheDocument();
|
||||||
|
|
||||||
expect(screen.getByDisplayValue("https://pipeline.com")).toBeInTheDocument()
|
expect(
|
||||||
expect(screen.getByDisplayValue("https://job.com")).toBeInTheDocument()
|
screen.getByDisplayValue("https://pipeline.com"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue("https://job.com")).toBeInTheDocument();
|
||||||
|
|
||||||
expect(screen.getByText("sec-********")).toBeInTheDocument()
|
expect(screen.getByText("sec-********")).toBeInTheDocument();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
import React from "react"
|
import { SettingsInput } from "@client/pages/settings/components/SettingsInput";
|
||||||
import { useFormContext } from "react-hook-form"
|
import type { WebhookValues } from "@client/pages/settings/types";
|
||||||
|
import { formatSecretHint } from "@client/pages/settings/utils";
|
||||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
import type { UpdateSettingsInput } from "@shared/settings-schema";
|
||||||
import { Separator } from "@/components/ui/separator"
|
import type React from "react";
|
||||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
import { useFormContext } from "react-hook-form";
|
||||||
import type { WebhookValues } from "@client/pages/settings/types"
|
import {
|
||||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
AccordionContent,
|
||||||
import { formatSecretHint } from "@client/pages/settings/utils"
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
type WebhooksSectionProps = {
|
type WebhooksSectionProps = {
|
||||||
pipelineWebhook: WebhookValues
|
pipelineWebhook: WebhookValues;
|
||||||
jobCompleteWebhook: WebhookValues
|
jobCompleteWebhook: WebhookValues;
|
||||||
webhookSecretHint: string | null
|
webhookSecretHint: string | null;
|
||||||
isLoading: boolean
|
isLoading: boolean;
|
||||||
isSaving: boolean
|
isSaving: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const WebhooksSection: React.FC<WebhooksSectionProps> = ({
|
export const WebhooksSection: React.FC<WebhooksSectionProps> = ({
|
||||||
pipelineWebhook,
|
pipelineWebhook,
|
||||||
@@ -23,7 +26,10 @@ export const WebhooksSection: React.FC<WebhooksSectionProps> = ({
|
|||||||
isLoading,
|
isLoading,
|
||||||
isSaving,
|
isSaving,
|
||||||
}) => {
|
}) => {
|
||||||
const { register, formState: { errors } } = useFormContext<UpdateSettingsInput>()
|
const {
|
||||||
|
register,
|
||||||
|
formState: { errors },
|
||||||
|
} = useFormContext<UpdateSettingsInput>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordionItem value="webhooks" className="border rounded-lg px-4">
|
<AccordionItem value="webhooks" className="border rounded-lg px-4">
|
||||||
@@ -55,7 +61,9 @@ export const WebhooksSection: React.FC<WebhooksSectionProps> = ({
|
|||||||
inputProps={register("jobCompleteWebhookUrl")}
|
inputProps={register("jobCompleteWebhookUrl")}
|
||||||
placeholder={jobCompleteWebhook.default || "https://..."}
|
placeholder={jobCompleteWebhook.default || "https://..."}
|
||||||
disabled={isLoading || isSaving}
|
disabled={isLoading || isSaving}
|
||||||
error={errors.jobCompleteWebhookUrl?.message as string | undefined}
|
error={
|
||||||
|
errors.jobCompleteWebhookUrl?.message as string | undefined
|
||||||
|
}
|
||||||
helper={`When set, the server sends a POST when you mark a job as applied (includes the job description). Default: ${jobCompleteWebhook.default || "—"}.`}
|
helper={`When set, the server sends a POST when you mark a job as applied (includes the job description). Default: ${jobCompleteWebhook.default || "—"}.`}
|
||||||
current={jobCompleteWebhook.effective || "—"}
|
current={jobCompleteWebhook.effective || "—"}
|
||||||
/>
|
/>
|
||||||
@@ -75,5 +83,5 @@ export const WebhooksSection: React.FC<WebhooksSectionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -2,17 +2,24 @@
|
|||||||
* Settings page constants.
|
* Settings page constants.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { JobStatus } from "@shared/types"
|
import type { JobStatus } from "@shared/types";
|
||||||
|
|
||||||
/** All available job statuses for clearing */
|
/** All available job statuses for clearing */
|
||||||
export const ALL_JOB_STATUSES: JobStatus[] = ['discovered', 'processing', 'ready', 'applied', 'skipped', 'expired']
|
export const ALL_JOB_STATUSES: JobStatus[] = [
|
||||||
|
"discovered",
|
||||||
|
"processing",
|
||||||
|
"ready",
|
||||||
|
"applied",
|
||||||
|
"skipped",
|
||||||
|
"expired",
|
||||||
|
];
|
||||||
|
|
||||||
/** Status descriptions for UI */
|
/** Status descriptions for UI */
|
||||||
export const STATUS_DESCRIPTIONS: Record<JobStatus, string> = {
|
export const STATUS_DESCRIPTIONS: Record<JobStatus, string> = {
|
||||||
discovered: 'Crawled but not processed',
|
discovered: "Crawled but not processed",
|
||||||
processing: 'Currently generating resume',
|
processing: "Currently generating resume",
|
||||||
ready: 'PDF generated, waiting for user to apply',
|
ready: "PDF generated, waiting for user to apply",
|
||||||
applied: 'User marked as applied',
|
applied: "User marked as applied",
|
||||||
skipped: 'User skipped this job',
|
skipped: "User skipped this job",
|
||||||
expired: 'Deadline passed',
|
expired: "Deadline passed",
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,40 +1,40 @@
|
|||||||
export type EffectiveDefault<T> = {
|
export type EffectiveDefault<T> = {
|
||||||
effective: T
|
effective: T;
|
||||||
default: T
|
default: T;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type ModelValues = EffectiveDefault<string> & {
|
export type ModelValues = EffectiveDefault<string> & {
|
||||||
scorer: string
|
scorer: string;
|
||||||
tailoring: string
|
tailoring: string;
|
||||||
projectSelection: string
|
projectSelection: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type WebhookValues = EffectiveDefault<string>
|
export type WebhookValues = EffectiveDefault<string>;
|
||||||
export type NumericSettingValues = EffectiveDefault<number>
|
export type NumericSettingValues = EffectiveDefault<number>;
|
||||||
export type SearchTermsValues = EffectiveDefault<string[]>
|
export type SearchTermsValues = EffectiveDefault<string[]>;
|
||||||
export type DisplayValues = EffectiveDefault<boolean>
|
export type DisplayValues = EffectiveDefault<boolean>;
|
||||||
|
|
||||||
export type JobspyValues = {
|
export type JobspyValues = {
|
||||||
sites: EffectiveDefault<string[]>
|
sites: EffectiveDefault<string[]>;
|
||||||
location: EffectiveDefault<string>
|
location: EffectiveDefault<string>;
|
||||||
resultsWanted: EffectiveDefault<number>
|
resultsWanted: EffectiveDefault<number>;
|
||||||
hoursOld: EffectiveDefault<number>
|
hoursOld: EffectiveDefault<number>;
|
||||||
countryIndeed: EffectiveDefault<string>
|
countryIndeed: EffectiveDefault<string>;
|
||||||
linkedinFetchDescription: EffectiveDefault<boolean>
|
linkedinFetchDescription: EffectiveDefault<boolean>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type EnvSettingsValues = {
|
export type EnvSettingsValues = {
|
||||||
readable: {
|
readable: {
|
||||||
rxresumeEmail: string
|
rxresumeEmail: string;
|
||||||
ukvisajobsEmail: string
|
ukvisajobsEmail: string;
|
||||||
basicAuthUser: string
|
basicAuthUser: string;
|
||||||
}
|
};
|
||||||
private: {
|
private: {
|
||||||
openrouterApiKeyHint: string | null
|
openrouterApiKeyHint: string | null;
|
||||||
rxresumePasswordHint: string | null
|
rxresumePasswordHint: string | null;
|
||||||
ukvisajobsPasswordHint: string | null
|
ukvisajobsPasswordHint: string | null;
|
||||||
basicAuthPasswordHint: string | null
|
basicAuthPasswordHint: string | null;
|
||||||
webhookSecretHint: string | null
|
webhookSecretHint: string | null;
|
||||||
}
|
};
|
||||||
basicAuthActive: boolean
|
basicAuthActive: boolean;
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -2,15 +2,19 @@
|
|||||||
* Settings page helpers.
|
* Settings page helpers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { arraysEqual } from "@/lib/utils"
|
import type { ResumeProjectsSettings } from "@shared/types";
|
||||||
import type { ResumeProjectsSettings } from "@shared/types"
|
import { arraysEqual } from "@/lib/utils";
|
||||||
|
|
||||||
export function resumeProjectsEqual(a: ResumeProjectsSettings, b: ResumeProjectsSettings) {
|
export function resumeProjectsEqual(
|
||||||
|
a: ResumeProjectsSettings,
|
||||||
|
b: ResumeProjectsSettings,
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
a.maxProjects === b.maxProjects &&
|
a.maxProjects === b.maxProjects &&
|
||||||
arraysEqual(a.lockedProjectIds, b.lockedProjectIds) &&
|
arraysEqual(a.lockedProjectIds, b.lockedProjectIds) &&
|
||||||
arraysEqual(a.aiSelectableProjectIds, b.aiSelectableProjectIds)
|
arraysEqual(a.aiSelectableProjectIds, b.aiSelectableProjectIds)
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formatSecretHint = (hint: string | null) => (hint ? `${hint}********` : "Not set")
|
export const formatSecretHint = (hint: string | null) =>
|
||||||
|
hint ? `${hint}********` : "Not set";
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
import { ChevronDown } from "lucide-react";
|
||||||
import { ChevronDown } from "lucide-react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Accordion = AccordionPrimitive.Root
|
const Accordion = AccordionPrimitive.Root;
|
||||||
|
|
||||||
const AccordionItem = React.forwardRef<
|
const AccordionItem = React.forwardRef<
|
||||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||||
@@ -17,8 +17,8 @@ const AccordionItem = React.forwardRef<
|
|||||||
className={cn("border-b", className)}
|
className={cn("border-b", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
AccordionItem.displayName = "AccordionItem"
|
AccordionItem.displayName = "AccordionItem";
|
||||||
|
|
||||||
const AccordionTrigger = React.forwardRef<
|
const AccordionTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||||
@@ -29,7 +29,7 @@ const AccordionTrigger = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -37,8 +37,8 @@ const AccordionTrigger = React.forwardRef<
|
|||||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||||
</AccordionPrimitive.Trigger>
|
</AccordionPrimitive.Trigger>
|
||||||
</AccordionPrimitive.Header>
|
</AccordionPrimitive.Header>
|
||||||
))
|
));
|
||||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
const AccordionContent = React.forwardRef<
|
const AccordionContent = React.forwardRef<
|
||||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||||
@@ -51,8 +51,8 @@ const AccordionContent = React.forwardRef<
|
|||||||
>
|
>
|
||||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||||
</AccordionPrimitive.Content>
|
</AccordionPrimitive.Content>
|
||||||
))
|
));
|
||||||
|
|
||||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||||
|
|
||||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import * as React from "react"
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
import * as React from "react";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
const AlertDialog = AlertDialogPrimitive.Root;
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
|
||||||
|
|
||||||
const AlertDialog = AlertDialogPrimitive.Root
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||||
|
|
||||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||||
|
|
||||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
|
||||||
|
|
||||||
const AlertDialogOverlay = React.forwardRef<
|
const AlertDialogOverlay = React.forwardRef<
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
@@ -17,13 +16,13 @@ const AlertDialogOverlay = React.forwardRef<
|
|||||||
<AlertDialogPrimitive.Overlay
|
<AlertDialogPrimitive.Overlay
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
const AlertDialogContent = React.forwardRef<
|
const AlertDialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
@@ -35,13 +34,13 @@ const AlertDialogContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</AlertDialogPortal>
|
</AlertDialogPortal>
|
||||||
))
|
));
|
||||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
const AlertDialogHeader = ({
|
const AlertDialogHeader = ({
|
||||||
className,
|
className,
|
||||||
@@ -50,12 +49,12 @@ const AlertDialogHeader = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col space-y-2 text-center sm:text-left",
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
AlertDialogHeader.displayName = "AlertDialogHeader";
|
||||||
|
|
||||||
const AlertDialogFooter = ({
|
const AlertDialogFooter = ({
|
||||||
className,
|
className,
|
||||||
@@ -64,12 +63,12 @@ const AlertDialogFooter = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
AlertDialogFooter.displayName = "AlertDialogFooter";
|
||||||
|
|
||||||
const AlertDialogTitle = React.forwardRef<
|
const AlertDialogTitle = React.forwardRef<
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
@@ -80,8 +79,8 @@ const AlertDialogTitle = React.forwardRef<
|
|||||||
className={cn("text-lg font-semibold", className)}
|
className={cn("text-lg font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
const AlertDialogDescription = React.forwardRef<
|
const AlertDialogDescription = React.forwardRef<
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
@@ -92,9 +91,9 @@ const AlertDialogDescription = React.forwardRef<
|
|||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
AlertDialogDescription.displayName =
|
AlertDialogDescription.displayName =
|
||||||
AlertDialogPrimitive.Description.displayName
|
AlertDialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
const AlertDialogAction = React.forwardRef<
|
const AlertDialogAction = React.forwardRef<
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||||
@@ -105,8 +104,8 @@ const AlertDialogAction = React.forwardRef<
|
|||||||
className={cn(buttonVariants(), className)}
|
className={cn(buttonVariants(), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||||
|
|
||||||
const AlertDialogCancel = React.forwardRef<
|
const AlertDialogCancel = React.forwardRef<
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
@@ -117,12 +116,12 @@ const AlertDialogCancel = React.forwardRef<
|
|||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({ variant: "outline" }),
|
buttonVariants({ variant: "outline" }),
|
||||||
"mt-2 sm:mt-0",
|
"mt-2 sm:mt-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -136,4 +135,4 @@ export {
|
|||||||
AlertDialogDescription,
|
AlertDialogDescription,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,59 +1,59 @@
|
|||||||
import * as React from "react"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const alertVariants = cva(
|
const alertVariants = cva(
|
||||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-background text-foreground",
|
default: "bg-background text-foreground",
|
||||||
destructive:
|
destructive:
|
||||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
const Alert = React.forwardRef<
|
const Alert = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
>(({ className, variant, ...props }, ref) => (
|
>(({ className, variant, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
role="alert"
|
role="alert"
|
||||||
className={cn(alertVariants({ variant }), className)}
|
className={cn(alertVariants({ variant }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
Alert.displayName = "Alert"
|
Alert.displayName = "Alert";
|
||||||
|
|
||||||
const AlertTitle = React.forwardRef<
|
const AlertTitle = React.forwardRef<
|
||||||
HTMLParagraphElement,
|
HTMLParagraphElement,
|
||||||
React.HTMLAttributes<HTMLHeadingElement>
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<h5
|
<h5
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
AlertTitle.displayName = "AlertTitle"
|
AlertTitle.displayName = "AlertTitle";
|
||||||
|
|
||||||
const AlertDescription = React.forwardRef<
|
const AlertDescription = React.forwardRef<
|
||||||
HTMLParagraphElement,
|
HTMLParagraphElement,
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
AlertDescription.displayName = "AlertDescription"
|
AlertDescription.displayName = "AlertDescription";
|
||||||
|
|
||||||
export { Alert, AlertTitle, AlertDescription }
|
export { Alert, AlertTitle, AlertDescription };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import type * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
@@ -20,8 +20,8 @@ const badgeVariants = cva(
|
|||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
export interface BadgeProps
|
export interface BadgeProps
|
||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
@@ -30,7 +30,7 @@ export interface BadgeProps
|
|||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Badge, badgeVariants }
|
export { Badge, badgeVariants };
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as React from "react"
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
@@ -11,8 +11,7 @@ const buttonVariants = cva(
|
|||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
destructive:
|
destructive: "bg-red-900 text-red-200 shadow-sm hover:bg-red-600/90",
|
||||||
"bg-red-900 text-red-200 shadow-sm hover:bg-red-600/90",
|
|
||||||
outline:
|
outline:
|
||||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
secondary:
|
secondary:
|
||||||
@@ -31,27 +30,27 @@ const buttonVariants = cva(
|
|||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
VariantProps<typeof buttonVariants> {
|
VariantProps<typeof buttonVariants> {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : "button";
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
Button.displayName = "Button"
|
Button.displayName = "Button";
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Card = React.forwardRef<
|
const Card = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
@@ -10,12 +10,12 @@ const Card = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-xl border bg-card text-card-foreground shadow",
|
"rounded-xl border bg-card text-card-foreground shadow",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
Card.displayName = "Card"
|
Card.displayName = "Card";
|
||||||
|
|
||||||
const CardHeader = React.forwardRef<
|
const CardHeader = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
@@ -26,8 +26,8 @@ const CardHeader = React.forwardRef<
|
|||||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
CardHeader.displayName = "CardHeader"
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
const CardTitle = React.forwardRef<
|
const CardTitle = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
@@ -38,8 +38,8 @@ const CardTitle = React.forwardRef<
|
|||||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
CardTitle.displayName = "CardTitle"
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
const CardDescription = React.forwardRef<
|
const CardDescription = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
@@ -50,16 +50,16 @@ const CardDescription = React.forwardRef<
|
|||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
CardDescription.displayName = "CardDescription"
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
const CardContent = React.forwardRef<
|
const CardContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
))
|
));
|
||||||
CardContent.displayName = "CardContent"
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
const CardFooter = React.forwardRef<
|
const CardFooter = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
@@ -70,7 +70,14 @@ const CardFooter = React.forwardRef<
|
|||||||
className={cn("flex items-center p-6 pt-0", className)}
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
CardFooter.displayName = "CardFooter"
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as React from "react"
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
import { Check } from "lucide-react";
|
||||||
import { Check } from "lucide-react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Checkbox = React.forwardRef<
|
const Checkbox = React.forwardRef<
|
||||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
@@ -12,16 +12,17 @@ const Checkbox = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
<CheckboxPrimitive.Indicator
|
||||||
|
className={cn("flex items-center justify-center text-current")}
|
||||||
|
>
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
</CheckboxPrimitive.Root>
|
</CheckboxPrimitive.Root>
|
||||||
))
|
));
|
||||||
Checkbox.displayName = "Checkbox"
|
Checkbox.displayName = "Checkbox";
|
||||||
|
|
||||||
export { Checkbox }
|
|
||||||
|
|
||||||
|
export { Checkbox };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { Drawer as DrawerPrimitive } from "vaul"
|
import { Drawer as DrawerPrimitive } from "vaul";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Drawer = ({
|
const Drawer = ({
|
||||||
shouldScaleBackground = true,
|
shouldScaleBackground = true,
|
||||||
@@ -11,14 +11,14 @@ const Drawer = ({
|
|||||||
shouldScaleBackground={shouldScaleBackground}
|
shouldScaleBackground={shouldScaleBackground}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
Drawer.displayName = "Drawer"
|
Drawer.displayName = "Drawer";
|
||||||
|
|
||||||
const DrawerTrigger = DrawerPrimitive.Trigger
|
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||||
|
|
||||||
const DrawerPortal = DrawerPrimitive.Portal
|
const DrawerPortal = DrawerPrimitive.Portal;
|
||||||
|
|
||||||
const DrawerClose = DrawerPrimitive.Close
|
const DrawerClose = DrawerPrimitive.Close;
|
||||||
|
|
||||||
const DrawerOverlay = React.forwardRef<
|
const DrawerOverlay = React.forwardRef<
|
||||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||||
@@ -29,8 +29,8 @@ const DrawerOverlay = React.forwardRef<
|
|||||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
|
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
const DrawerContent = React.forwardRef<
|
const DrawerContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||||
@@ -42,7 +42,7 @@ const DrawerContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -50,8 +50,8 @@ const DrawerContent = React.forwardRef<
|
|||||||
{children}
|
{children}
|
||||||
</DrawerPrimitive.Content>
|
</DrawerPrimitive.Content>
|
||||||
</DrawerPortal>
|
</DrawerPortal>
|
||||||
))
|
));
|
||||||
DrawerContent.displayName = "DrawerContent"
|
DrawerContent.displayName = "DrawerContent";
|
||||||
|
|
||||||
const DrawerHeader = ({
|
const DrawerHeader = ({
|
||||||
className,
|
className,
|
||||||
@@ -61,8 +61,8 @@ const DrawerHeader = ({
|
|||||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
DrawerHeader.displayName = "DrawerHeader"
|
DrawerHeader.displayName = "DrawerHeader";
|
||||||
|
|
||||||
const DrawerFooter = ({
|
const DrawerFooter = ({
|
||||||
className,
|
className,
|
||||||
@@ -72,8 +72,8 @@ const DrawerFooter = ({
|
|||||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
DrawerFooter.displayName = "DrawerFooter"
|
DrawerFooter.displayName = "DrawerFooter";
|
||||||
|
|
||||||
const DrawerTitle = React.forwardRef<
|
const DrawerTitle = React.forwardRef<
|
||||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||||
@@ -83,12 +83,12 @@ const DrawerTitle = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-lg font-semibold leading-none tracking-tight",
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
|
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||||
|
|
||||||
const DrawerDescription = React.forwardRef<
|
const DrawerDescription = React.forwardRef<
|
||||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||||
@@ -99,8 +99,8 @@ const DrawerDescription = React.forwardRef<
|
|||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
|
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Drawer,
|
Drawer,
|
||||||
@@ -113,4 +113,4 @@ export {
|
|||||||
DrawerFooter,
|
DrawerFooter,
|
||||||
DrawerTitle,
|
DrawerTitle,
|
||||||
DrawerDescription,
|
DrawerDescription,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import * as React from "react"
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
|
|
||||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
|
||||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||||
|
|
||||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||||
|
|
||||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
|
|
||||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
const DropdownMenuSubTrigger = React.forwardRef<
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}
|
}
|
||||||
>(({ className, inset, children, ...props }, ref) => (
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
@@ -27,15 +27,16 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRight className="ml-auto h-4 w-4" />
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
))
|
));
|
||||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
const DropdownMenuSubContent = React.forwardRef<
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
@@ -45,12 +46,13 @@ const DropdownMenuSubContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
const DropdownMenuContent = React.forwardRef<
|
const DropdownMenuContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
@@ -62,18 +64,18 @@ const DropdownMenuContent = React.forwardRef<
|
|||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuPrimitive.Portal>
|
</DropdownMenuPrimitive.Portal>
|
||||||
))
|
));
|
||||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
const DropdownMenuItem = React.forwardRef<
|
const DropdownMenuItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}
|
}
|
||||||
>(({ className, inset, ...props }, ref) => (
|
>(({ className, inset, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
@@ -81,12 +83,12 @@ const DropdownMenuItem = React.forwardRef<
|
|||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
@@ -96,7 +98,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -108,9 +110,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
|||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
))
|
));
|
||||||
DropdownMenuCheckboxItem.displayName =
|
DropdownMenuCheckboxItem.displayName =
|
||||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
const DropdownMenuRadioItem = React.forwardRef<
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
@@ -120,7 +122,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -131,22 +133,26 @@ const DropdownMenuRadioItem = React.forwardRef<
|
|||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
))
|
));
|
||||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
const DropdownMenuLabel = React.forwardRef<
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
inset?: boolean
|
inset?: boolean;
|
||||||
}
|
}
|
||||||
>(({ className, inset, ...props }, ref) => (
|
>(({ className, inset, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Label
|
<DropdownMenuPrimitive.Label
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
|
inset && "pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
const DropdownMenuSeparator = React.forwardRef<
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
@@ -157,8 +163,8 @@ const DropdownMenuSeparator = React.forwardRef<
|
|||||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
const DropdownMenuShortcut = ({
|
const DropdownMenuShortcut = ({
|
||||||
className,
|
className,
|
||||||
@@ -169,9 +175,9 @@ const DropdownMenuShortcut = ({
|
|||||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -189,5 +195,4 @@ export {
|
|||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuRadioGroup,
|
DropdownMenuRadioGroup,
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { useMemo } from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import { cn } from "@/lib/utils"
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Label } from "@/components/ui/label"
|
import { cn } from "@/lib/utils";
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
|
|
||||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||||
return (
|
return (
|
||||||
@@ -14,11 +13,11 @@ function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col gap-6",
|
"flex flex-col gap-6",
|
||||||
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldLegend({
|
function FieldLegend({
|
||||||
@@ -34,11 +33,11 @@ function FieldLegend({
|
|||||||
"mb-3 font-medium",
|
"mb-3 font-medium",
|
||||||
"data-[variant=legend]:text-base",
|
"data-[variant=legend]:text-base",
|
||||||
"data-[variant=label]:text-sm",
|
"data-[variant=label]:text-sm",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -47,11 +46,11 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="field-group"
|
data-slot="field-group"
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
|
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fieldVariants = cva(
|
const fieldVariants = cva(
|
||||||
@@ -75,8 +74,8 @@ const fieldVariants = cva(
|
|||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
orientation: "vertical",
|
orientation: "vertical",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function Field({
|
function Field({
|
||||||
className,
|
className,
|
||||||
@@ -91,7 +90,7 @@ function Field({
|
|||||||
className={cn(fieldVariants({ orientation }), className)}
|
className={cn(fieldVariants({ orientation }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -100,11 +99,11 @@ function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="field-content"
|
data-slot="field-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
|
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldLabel({
|
function FieldLabel({
|
||||||
@@ -118,11 +117,11 @@ function FieldLabel({
|
|||||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
||||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>[data-slot=field]]:p-4",
|
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>[data-slot=field]]:p-4",
|
||||||
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
|
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
@@ -131,11 +130,11 @@ function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
data-slot="field-label"
|
data-slot="field-label"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-fit items-center gap-2 text-sm font-medium leading-snug group-data-[disabled=true]/field:opacity-50",
|
"flex w-fit items-center gap-2 text-sm font-medium leading-snug group-data-[disabled=true]/field:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
@@ -146,11 +145,11 @@ function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
|||||||
"text-muted-foreground text-sm font-normal leading-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
|
"text-muted-foreground text-sm font-normal leading-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
|
||||||
"nth-last-2:-mt-1 last:mt-0 [[data-variant=legend]+&]:-mt-1.5",
|
"nth-last-2:-mt-1 last:mt-0 [[data-variant=legend]+&]:-mt-1.5",
|
||||||
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldSeparator({
|
function FieldSeparator({
|
||||||
@@ -158,7 +157,7 @@ function FieldSeparator({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -166,7 +165,7 @@ function FieldSeparator({
|
|||||||
data-content={!!children}
|
data-content={!!children}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -180,7 +179,7 @@ function FieldSeparator({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldError({
|
function FieldError({
|
||||||
@@ -189,33 +188,33 @@ function FieldError({
|
|||||||
errors,
|
errors,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div"> & {
|
}: React.ComponentProps<"div"> & {
|
||||||
errors?: Array<{ message?: string } | undefined>
|
errors?: Array<{ message?: string } | undefined>;
|
||||||
}) {
|
}) {
|
||||||
const content = useMemo(() => {
|
const content = useMemo(() => {
|
||||||
if (children) {
|
if (children) {
|
||||||
return children
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!errors) {
|
if (!errors) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errors?.length === 1 && errors[0]?.message) {
|
if (errors?.length === 1 && errors[0]?.message) {
|
||||||
return errors[0].message
|
return errors[0].message;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||||
{errors.map(
|
{errors.map(
|
||||||
(error, index) =>
|
(error, index) =>
|
||||||
error?.message && <li key={index}>{error.message}</li>
|
error?.message && <li key={index}>{error.message}</li>,
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
)
|
);
|
||||||
}, [children, errors])
|
}, [children, errors]);
|
||||||
|
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -227,7 +226,7 @@ function FieldError({
|
|||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -241,4 +240,4 @@ export {
|
|||||||
FieldSet,
|
FieldSet,
|
||||||
FieldContent,
|
FieldContent,
|
||||||
FieldTitle,
|
FieldTitle,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
({ className, type, ...props }, ref) => {
|
({ className, type, ...props }, ref) => {
|
||||||
@@ -9,15 +9,14 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
|||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
Input.displayName = "Input"
|
Input.displayName = "Input";
|
||||||
|
|
||||||
export { Input }
|
|
||||||
|
|
||||||
|
export { Input };
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const labelVariants = cva(
|
const labelVariants = cva(
|
||||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||||
)
|
);
|
||||||
|
|
||||||
const Label = React.forwardRef<
|
const Label = React.forwardRef<
|
||||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
@@ -20,7 +20,7 @@ const Label = React.forwardRef<
|
|||||||
className={cn(labelVariants(), className)}
|
className={cn(labelVariants(), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
Label.displayName = LabelPrimitive.Root.displayName
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
export { Label }
|
export { Label };
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Progress = React.forwardRef<
|
const Progress = React.forwardRef<
|
||||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||||
@@ -13,7 +13,7 @@ const Progress = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -22,7 +22,7 @@ const Progress = React.forwardRef<
|
|||||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
/>
|
/>
|
||||||
</ProgressPrimitive.Root>
|
</ProgressPrimitive.Root>
|
||||||
))
|
));
|
||||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||||
|
|
||||||
export { Progress }
|
export { Progress };
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as React from "react"
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
import { Circle } from "lucide-react";
|
||||||
import { Circle } from "lucide-react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const RadioGroup = React.forwardRef<
|
const RadioGroup = React.forwardRef<
|
||||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||||
@@ -14,9 +14,9 @@ const RadioGroup = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||||
|
|
||||||
const RadioGroupItem = React.forwardRef<
|
const RadioGroupItem = React.forwardRef<
|
||||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||||
@@ -27,7 +27,7 @@ const RadioGroupItem = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -35,8 +35,8 @@ const RadioGroupItem = React.forwardRef<
|
|||||||
<Circle className="h-3.5 w-3.5 fill-primary" />
|
<Circle className="h-3.5 w-3.5 fill-primary" />
|
||||||
</RadioGroupPrimitive.Indicator>
|
</RadioGroupPrimitive.Indicator>
|
||||||
</RadioGroupPrimitive.Item>
|
</RadioGroupPrimitive.Item>
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||||
|
|
||||||
export { RadioGroup, RadioGroupItem }
|
export { RadioGroup, RadioGroupItem };
|
||||||
|
|||||||
@@ -1,159 +1,159 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Select = SelectPrimitive.Root
|
const Select = SelectPrimitive.Root;
|
||||||
|
|
||||||
const SelectGroup = SelectPrimitive.Group
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
|
||||||
const SelectValue = SelectPrimitive.Value
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
const SelectTrigger = React.forwardRef<
|
const SelectTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, ...props }, ref) => (
|
||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<SelectPrimitive.Icon asChild>
|
<SelectPrimitive.Icon asChild>
|
||||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
</SelectPrimitive.Icon>
|
</SelectPrimitive.Icon>
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>
|
||||||
))
|
));
|
||||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
const SelectScrollUpButton = React.forwardRef<
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SelectPrimitive.ScrollUpButton
|
<SelectPrimitive.ScrollUpButton
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default items-center justify-center py-1",
|
"flex cursor-default items-center justify-center py-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronUp className="h-4 w-4" />
|
<ChevronUp className="h-4 w-4" />
|
||||||
</SelectPrimitive.ScrollUpButton >
|
</SelectPrimitive.ScrollUpButton>
|
||||||
))
|
));
|
||||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
|
|
||||||
const SelectScrollDownButton = React.forwardRef<
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SelectPrimitive.ScrollDownButton
|
<SelectPrimitive.ScrollDownButton
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default items-center justify-center py-1",
|
"flex cursor-default items-center justify-center py-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="h-4 w-4" />
|
||||||
</SelectPrimitive.ScrollDownButton>
|
</SelectPrimitive.ScrollDownButton>
|
||||||
))
|
));
|
||||||
SelectScrollDownButton.displayName =
|
SelectScrollDownButton.displayName =
|
||||||
SelectPrimitive.ScrollDownButton.displayName
|
SelectPrimitive.ScrollDownButton.displayName;
|
||||||
|
|
||||||
const SelectContent = React.forwardRef<
|
const SelectContent = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
<SelectPrimitive.Portal>
|
<SelectPrimitive.Portal>
|
||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
position={position}
|
position={position}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SelectScrollUpButton />
|
<SelectScrollUpButton />
|
||||||
<SelectPrimitive.Viewport
|
<SelectPrimitive.Viewport
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-1",
|
"p-1",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</SelectPrimitive.Viewport>
|
</SelectPrimitive.Viewport>
|
||||||
<SelectScrollDownButton />
|
<SelectScrollDownButton />
|
||||||
</SelectPrimitive.Content>
|
</SelectPrimitive.Content>
|
||||||
</SelectPrimitive.Portal>
|
</SelectPrimitive.Portal>
|
||||||
))
|
));
|
||||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
const SelectLabel = React.forwardRef<
|
const SelectLabel = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SelectPrimitive.Label
|
<SelectPrimitive.Label
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
const SelectItem = React.forwardRef<
|
const SelectItem = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, ...props }, ref) => (
|
||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
<SelectPrimitive.ItemIndicator>
|
<SelectPrimitive.ItemIndicator>
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
</SelectPrimitive.ItemIndicator>
|
</SelectPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
))
|
));
|
||||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
const SelectSeparator = React.forwardRef<
|
const SelectSeparator = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SelectPrimitive.Separator
|
<SelectPrimitive.Separator
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Select,
|
Select,
|
||||||
SelectGroup,
|
SelectGroup,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectLabel,
|
SelectLabel,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectSeparator,
|
SelectSeparator,
|
||||||
SelectScrollUpButton,
|
SelectScrollUpButton,
|
||||||
SelectScrollDownButton,
|
SelectScrollDownButton,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react"
|
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Separator = React.forwardRef<
|
const Separator = React.forwardRef<
|
||||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
@@ -9,7 +9,7 @@ const Separator = React.forwardRef<
|
|||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||||
ref
|
ref,
|
||||||
) => (
|
) => (
|
||||||
<SeparatorPrimitive.Root
|
<SeparatorPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -18,12 +18,12 @@ const Separator = React.forwardRef<
|
|||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 bg-border",
|
"shrink-0 bg-border",
|
||||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
),
|
||||||
)
|
);
|
||||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||||
|
|
||||||
export { Separator }
|
export { Separator };
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import * as React from "react"
|
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { X } from "lucide-react";
|
||||||
import { X } from "lucide-react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Sheet = SheetPrimitive.Root
|
const Sheet = SheetPrimitive.Root;
|
||||||
|
|
||||||
const SheetTrigger = SheetPrimitive.Trigger
|
const SheetTrigger = SheetPrimitive.Trigger;
|
||||||
|
|
||||||
const SheetClose = SheetPrimitive.Close
|
const SheetClose = SheetPrimitive.Close;
|
||||||
|
|
||||||
const SheetPortal = SheetPrimitive.Portal
|
const SheetPortal = SheetPrimitive.Portal;
|
||||||
|
|
||||||
const SheetOverlay = React.forwardRef<
|
const SheetOverlay = React.forwardRef<
|
||||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||||
@@ -20,13 +20,13 @@ const SheetOverlay = React.forwardRef<
|
|||||||
<SheetPrimitive.Overlay
|
<SheetPrimitive.Overlay
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
const sheetVariants = cva(
|
const sheetVariants = cva(
|
||||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||||
@@ -44,8 +44,8 @@ const sheetVariants = cva(
|
|||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
side: "right",
|
side: "right",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
interface SheetContentProps
|
interface SheetContentProps
|
||||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||||
@@ -69,8 +69,8 @@ const SheetContent = React.forwardRef<
|
|||||||
{children}
|
{children}
|
||||||
</SheetPrimitive.Content>
|
</SheetPrimitive.Content>
|
||||||
</SheetPortal>
|
</SheetPortal>
|
||||||
))
|
));
|
||||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||||
|
|
||||||
const SheetHeader = ({
|
const SheetHeader = ({
|
||||||
className,
|
className,
|
||||||
@@ -79,12 +79,12 @@ const SheetHeader = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col space-y-2 text-center sm:text-left",
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
SheetHeader.displayName = "SheetHeader"
|
SheetHeader.displayName = "SheetHeader";
|
||||||
|
|
||||||
const SheetFooter = ({
|
const SheetFooter = ({
|
||||||
className,
|
className,
|
||||||
@@ -93,12 +93,12 @@ const SheetFooter = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
SheetFooter.displayName = "SheetFooter"
|
SheetFooter.displayName = "SheetFooter";
|
||||||
|
|
||||||
const SheetTitle = React.forwardRef<
|
const SheetTitle = React.forwardRef<
|
||||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||||
@@ -109,8 +109,8 @@ const SheetTitle = React.forwardRef<
|
|||||||
className={cn("text-lg font-semibold text-foreground", className)}
|
className={cn("text-lg font-semibold text-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||||
|
|
||||||
const SheetDescription = React.forwardRef<
|
const SheetDescription = React.forwardRef<
|
||||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||||
@@ -121,8 +121,8 @@ const SheetDescription = React.forwardRef<
|
|||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Sheet,
|
Sheet,
|
||||||
@@ -135,4 +135,4 @@ export {
|
|||||||
SheetFooter,
|
SheetFooter,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetDescription,
|
SheetDescription,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Toaster as Sonner } from "sonner"
|
import { Toaster as Sonner } from "sonner";
|
||||||
|
|
||||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
return (
|
return (
|
||||||
@@ -20,7 +20,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export { Toaster }
|
export { Toaster };
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
const Table = React.forwardRef<
|
||||||
({ className, ...props }, ref) => (
|
HTMLTableElement,
|
||||||
<div className="w-full overflow-auto">
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
<table
|
>(({ className, ...props }, ref) => (
|
||||||
ref={ref}
|
<div className="w-full overflow-auto">
|
||||||
className={cn("w-full caption-bottom text-sm", className)}
|
<table
|
||||||
{...props}
|
ref={ref}
|
||||||
/>
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
</div>
|
{...props}
|
||||||
)
|
/>
|
||||||
)
|
</div>
|
||||||
Table.displayName = "Table"
|
));
|
||||||
|
Table.displayName = "Table";
|
||||||
|
|
||||||
const TableHeader = React.forwardRef<
|
const TableHeader = React.forwardRef<
|
||||||
HTMLTableSectionElement,
|
HTMLTableSectionElement,
|
||||||
React.HTMLAttributes<HTMLTableSectionElement>
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
))
|
));
|
||||||
TableHeader.displayName = "TableHeader"
|
TableHeader.displayName = "TableHeader";
|
||||||
|
|
||||||
const TableBody = React.forwardRef<
|
const TableBody = React.forwardRef<
|
||||||
HTMLTableSectionElement,
|
HTMLTableSectionElement,
|
||||||
@@ -32,8 +33,8 @@ const TableBody = React.forwardRef<
|
|||||||
className={cn("[&_tr:last-child]:border-0", className)}
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TableBody.displayName = "TableBody"
|
TableBody.displayName = "TableBody";
|
||||||
|
|
||||||
const TableFooter = React.forwardRef<
|
const TableFooter = React.forwardRef<
|
||||||
HTMLTableSectionElement,
|
HTMLTableSectionElement,
|
||||||
@@ -41,25 +42,29 @@ const TableFooter = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<tfoot
|
<tfoot
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TableFooter.displayName = "TableFooter"
|
TableFooter.displayName = "TableFooter";
|
||||||
|
|
||||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
const TableRow = React.forwardRef<
|
||||||
({ className, ...props }, ref) => (
|
HTMLTableRowElement,
|
||||||
<tr
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
ref={ref}
|
>(({ className, ...props }, ref) => (
|
||||||
className={cn(
|
<tr
|
||||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
ref={ref}
|
||||||
className
|
className={cn(
|
||||||
)}
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
{...props}
|
className,
|
||||||
/>
|
)}
|
||||||
)
|
{...props}
|
||||||
)
|
/>
|
||||||
TableRow.displayName = "TableRow"
|
));
|
||||||
|
TableRow.displayName = "TableRow";
|
||||||
|
|
||||||
const TableHead = React.forwardRef<
|
const TableHead = React.forwardRef<
|
||||||
HTMLTableCellElement,
|
HTMLTableCellElement,
|
||||||
@@ -69,12 +74,12 @@ const TableHead = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TableHead.displayName = "TableHead"
|
TableHead.displayName = "TableHead";
|
||||||
|
|
||||||
const TableCell = React.forwardRef<
|
const TableCell = React.forwardRef<
|
||||||
HTMLTableCellElement,
|
HTMLTableCellElement,
|
||||||
@@ -82,11 +87,14 @@ const TableCell = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<td
|
<td
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", className)}
|
className={cn(
|
||||||
|
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TableCell.displayName = "TableCell"
|
TableCell.displayName = "TableCell";
|
||||||
|
|
||||||
const TableCaption = React.forwardRef<
|
const TableCaption = React.forwardRef<
|
||||||
HTMLTableCaptionElement,
|
HTMLTableCaptionElement,
|
||||||
@@ -97,8 +105,8 @@ const TableCaption = React.forwardRef<
|
|||||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TableCaption.displayName = "TableCaption"
|
TableCaption.displayName = "TableCaption";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Table,
|
Table,
|
||||||
@@ -109,5 +117,4 @@ export {
|
|||||||
TableRow,
|
TableRow,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableCaption,
|
TableCaption,
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import * as React from "react"
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Tabs = TabsPrimitive.Root
|
const Tabs = TabsPrimitive.Root;
|
||||||
|
|
||||||
const TabsList = React.forwardRef<
|
const TabsList = React.forwardRef<
|
||||||
React.ElementRef<typeof TabsPrimitive.List>,
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
@@ -13,12 +13,12 @@ const TabsList = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TabsList.displayName = TabsPrimitive.List.displayName
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||||
|
|
||||||
const TabsTrigger = React.forwardRef<
|
const TabsTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
@@ -28,12 +28,12 @@ const TabsTrigger = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
const TabsContent = React.forwardRef<
|
const TabsContent = React.forwardRef<
|
||||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
@@ -43,11 +43,11 @@ const TabsContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Textarea = React.forwardRef<
|
const Textarea = React.forwardRef<
|
||||||
HTMLTextAreaElement,
|
HTMLTextAreaElement,
|
||||||
@@ -10,13 +10,13 @@ const Textarea = React.forwardRef<
|
|||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
Textarea.displayName = "Textarea"
|
Textarea.displayName = "Textarea";
|
||||||
|
|
||||||
export { Textarea }
|
export { Textarea };
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const TooltipProvider = TooltipPrimitive.Provider
|
const TooltipProvider = TooltipPrimitive.Provider;
|
||||||
|
|
||||||
const Tooltip = TooltipPrimitive.Root
|
const Tooltip = TooltipPrimitive.Root;
|
||||||
|
|
||||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||||
|
|
||||||
const TooltipContent = React.forwardRef<
|
const TooltipContent = React.forwardRef<
|
||||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
<TooltipPrimitive.Portal>
|
<TooltipPrimitive.Portal>
|
||||||
<TooltipPrimitive.Content
|
<TooltipPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
))
|
));
|
||||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||||
|
|
||||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
import type { Job } from "@shared/types";
|
||||||
import { twMerge } from "tailwind-merge"
|
import { type ClassValue, clsx } from "clsx";
|
||||||
import type { Job } from "@shared/types"
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
// --- CSS ---
|
// --- CSS ---
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Dates ---
|
// --- Dates ---
|
||||||
export const formatDate = (dateStr?: string | null) => {
|
export const formatDate = (dateStr?: string | null) => {
|
||||||
if (!dateStr) return null;
|
if (!dateStr) return null;
|
||||||
try {
|
try {
|
||||||
const normalized = dateStr.includes("T") ? dateStr : dateStr.replace(" ", "T");
|
const normalized = dateStr.includes("T")
|
||||||
|
? dateStr
|
||||||
|
: dateStr.replace(" ", "T");
|
||||||
const parsed = new Date(normalized);
|
const parsed = new Date(normalized);
|
||||||
if (Number.isNaN(parsed.getTime())) return dateStr;
|
if (Number.isNaN(parsed.getTime())) return dateStr;
|
||||||
return parsed.toLocaleDateString("en-GB", {
|
return parsed.toLocaleDateString("en-GB", {
|
||||||
@@ -27,7 +29,9 @@ export const formatDate = (dateStr?: string | null) => {
|
|||||||
export const formatDateTime = (dateStr?: string | null) => {
|
export const formatDateTime = (dateStr?: string | null) => {
|
||||||
if (!dateStr) return null;
|
if (!dateStr) return null;
|
||||||
try {
|
try {
|
||||||
const normalized = dateStr.includes("T") ? dateStr : dateStr.replace(" ", "T");
|
const normalized = dateStr.includes("T")
|
||||||
|
? dateStr
|
||||||
|
: dateStr.replace(" ", "T");
|
||||||
const parsed = new Date(normalized);
|
const parsed = new Date(normalized);
|
||||||
if (Number.isNaN(parsed.getTime())) return dateStr;
|
if (Number.isNaN(parsed.getTime())) return dateStr;
|
||||||
const date = parsed.toLocaleDateString("en-GB", {
|
const date = parsed.toLocaleDateString("en-GB", {
|
||||||
@@ -79,7 +83,8 @@ export const stripHtml = (value: string) =>
|
|||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
export const safeFilenamePart = (value: string) => value.replace(/[^a-z0-9]/gi, "_");
|
export const safeFilenamePart = (value: string) =>
|
||||||
|
value.replace(/[^a-z0-9]/gi, "_");
|
||||||
|
|
||||||
// --- Comparisons & Math ---
|
// --- Comparisons & Math ---
|
||||||
export function arraysEqual(a: string[], b: string[]) {
|
export function arraysEqual(a: string[], b: string[]) {
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export { apiRouter } from './routes.js';
|
export { apiRouter } from "./routes.js";
|
||||||
|
|||||||
@@ -2,27 +2,27 @@
|
|||||||
* API routes for the orchestrator.
|
* API routes for the orchestrator.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Router } from 'express';
|
import { Router } from "express";
|
||||||
import { jobsRouter } from './routes/jobs.js';
|
import { databaseRouter } from "./routes/database.js";
|
||||||
import { settingsRouter } from './routes/settings.js';
|
import { jobsRouter } from "./routes/jobs.js";
|
||||||
import { pipelineRouter } from './routes/pipeline.js';
|
import { manualJobsRouter } from "./routes/manual-jobs.js";
|
||||||
import { manualJobsRouter } from './routes/manual-jobs.js';
|
import { onboardingRouter } from "./routes/onboarding.js";
|
||||||
import { ukVisaJobsRouter } from './routes/ukvisajobs.js';
|
import { pipelineRouter } from "./routes/pipeline.js";
|
||||||
import { webhookRouter } from './routes/webhook.js';
|
import { profileRouter } from "./routes/profile.js";
|
||||||
import { profileRouter } from './routes/profile.js';
|
import { settingsRouter } from "./routes/settings.js";
|
||||||
import { databaseRouter } from './routes/database.js';
|
import { ukVisaJobsRouter } from "./routes/ukvisajobs.js";
|
||||||
import { visaSponsorsRouter } from './routes/visa-sponsors.js';
|
import { visaSponsorsRouter } from "./routes/visa-sponsors.js";
|
||||||
import { onboardingRouter } from './routes/onboarding.js';
|
import { webhookRouter } from "./routes/webhook.js";
|
||||||
|
|
||||||
export const apiRouter = Router();
|
export const apiRouter = Router();
|
||||||
|
|
||||||
apiRouter.use('/jobs', jobsRouter);
|
apiRouter.use("/jobs", jobsRouter);
|
||||||
apiRouter.use('/settings', settingsRouter);
|
apiRouter.use("/settings", settingsRouter);
|
||||||
apiRouter.use('/pipeline', pipelineRouter);
|
apiRouter.use("/pipeline", pipelineRouter);
|
||||||
apiRouter.use('/manual-jobs', manualJobsRouter);
|
apiRouter.use("/manual-jobs", manualJobsRouter);
|
||||||
apiRouter.use('/ukvisajobs', ukVisaJobsRouter);
|
apiRouter.use("/ukvisajobs", ukVisaJobsRouter);
|
||||||
apiRouter.use('/webhook', webhookRouter);
|
apiRouter.use("/webhook", webhookRouter);
|
||||||
apiRouter.use('/profile', profileRouter);
|
apiRouter.use("/profile", profileRouter);
|
||||||
apiRouter.use('/database', databaseRouter);
|
apiRouter.use("/database", databaseRouter);
|
||||||
apiRouter.use('/visa-sponsors', visaSponsorsRouter);
|
apiRouter.use("/visa-sponsors", visaSponsorsRouter);
|
||||||
apiRouter.use('/onboarding', onboardingRouter);
|
apiRouter.use("/onboarding", onboardingRouter);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user