Add target sample data retrieval and viewer components

- Implemented `GetTargetSample` in `TargetsController` for fetching target sample data.
- Updated `ITargetRepository` and `ITargetService` with `GetSampleData` method.
- Created `TargetSample` class to structure sample data.
- Added `GetSampleData` method in `TargetRepository` with hardcoded sample data.
- Enhanced `MailingEdit.tsx` to manage target sample visibility and validation.
- Introduced `TargetSampleViewer` and `TemplateViewer` components for displaying data in dialogs.
- Made minor adjustments in `Servers.tsx` for better row handling.
This commit is contained in:
David Headrick 2025-03-21 19:30:41 -05:00
parent a5fd034a31
commit 12bfdf57ce
10 changed files with 507 additions and 169 deletions

View File

@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
using Surge365.MassEmailReact.Application.DTOs; using Surge365.MassEmailReact.Application.DTOs;
using Surge365.MassEmailReact.Application.Interfaces; using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities; using Surge365.MassEmailReact.Domain.Entities;
using Surge365.MassEmailReact.Infrastructure.Repositories;
namespace Surge365.MassEmailReact.API.Controllers namespace Surge365.MassEmailReact.API.Controllers
{ {
@ -62,5 +63,20 @@ namespace Surge365.MassEmailReact.API.Controllers
return Ok(updatedTarget); return Ok(updatedTarget);
} }
[HttpGet("{targetId}/sample")]
public async Task<IActionResult> GetTargetSample(int targetId)
{
try
{
var sample = await _targetService.GetSampleData(targetId);
return Ok(sample);
}
catch (Exception ex)
{
// Log the exception (e.g., using ILogger if injected)
return StatusCode(500, new { message = "Error fetching sample data", error = ex.Message });
}
}
} }
} }

View File

@ -13,5 +13,6 @@ namespace Surge365.MassEmailReact.Application.Interfaces
Task<List<Target>> GetAllAsync(bool activeOnly = true); Task<List<Target>> GetAllAsync(bool activeOnly = true);
Task<int?> CreateAsync(Target target); Task<int?> CreateAsync(Target target);
Task<bool> UpdateAsync(Target target); Task<bool> UpdateAsync(Target target);
Task<TargetSample> GetSampleData(int targetId);
} }
} }

View File

@ -8,5 +8,6 @@ namespace Surge365.MassEmailReact.Application.Interfaces
Task<List<Target>> GetAllAsync(bool activeOnly = true); Task<List<Target>> GetAllAsync(bool activeOnly = true);
Task<int?> CreateAsync(TargetUpdateDto targetDto); Task<int?> CreateAsync(TargetUpdateDto targetDto);
Task<bool> UpdateAsync(TargetUpdateDto targetDto); Task<bool> UpdateAsync(TargetUpdateDto targetDto);
Task<TargetSample> GetSampleData(int targetId);
} }
} }

View File

@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Domain.Entities
{
public class TargetSample
{
public List<string> ColumnNames { get; set; } = new List<string>();
public List<Dictionary<string, string>> Rows { get; set; } = new List<Dictionary<string, string>>();
}
}

View File

@ -118,5 +118,42 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
// Targets.Add(target); // Targets.Add(target);
//} //}
public async Task<TargetSample> GetSampleData(int targetId)
{
// Placeholder hardcoded sample data for testing
var sample = new TargetSample
{
ColumnNames = new List<string> { "id", "name", "email", "age" },
Rows = new List<Dictionary<string, string>>
{
new Dictionary<string, string>
{
{ "id", "1" },
{ "name", "John Doe" },
{ "email", "john.doe@example.com" },
{ "age", "30" }
},
new Dictionary<string, string>
{
{ "id", "2" },
{ "name", "Jane Smith" },
{ "email", "jane.smith@example.com" },
{ "age", "25" }
},
new Dictionary<string, string>
{
{ "id", "3" },
{ "name", "Bob Johnson" },
{ "email", "bob.johnson@example.com" },
{ "age", "45" }
}
}
};
// Simulate async operation (e.g., DB call) for testing
await Task.Delay(100); // Optional: Mimics network latency
return sample;
}
} }
} }

View File

@ -69,5 +69,9 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
return await _targetRepository.UpdateAsync(target); return await _targetRepository.UpdateAsync(target);
} }
public async Task<TargetSample> GetSampleData(int targetId)
{
return await _targetRepository.GetSampleData(targetId);
}
} }
} }

View File

@ -11,14 +11,22 @@ import {
FormControlLabel, FormControlLabel,
Box Box
} from "@mui/material"; } from "@mui/material";
import Template from "@/types/template";
import Mailing from "@/types/mailing"; import Mailing from "@/types/mailing";
import Target from "@/types/target";
import EmailList from "@/components/forms/EmailList";
import TestEmailList from "@/types/testEmailList";
import TemplateViewer from "@/components/modals/TemplateViewer"
import TargetSampleViewer from "@/components/modals/TargetSampleViewer"
import { useSetupData, SetupData } from "@/context/SetupDataContext"; import { useSetupData, SetupData } from "@/context/SetupDataContext";
import { useForm, Controller, Resolver } from "react-hook-form"; import { useForm, Controller, Resolver } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup"; import * as yup from "yup";
import EmailList from "@/components/forms/EmailList";
import TestEmailList from "@/types/testEmailList"; import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker';
import { DatePicker } from '@mui/x-date-pickers/DatePicker'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import dayjs, { Dayjs } from 'dayjs'; // Import Dayjs for date handling
type MailingEditProps = { type MailingEditProps = {
open: boolean; open: boolean;
@ -47,6 +55,8 @@ const schema = yup.object().shape({
id: yup.number().nullable(), id: yup.number().nullable(),
name: yup.string().required("Name is required") name: yup.string().required("Name is required")
.test("unique-name", "Name must be unique", async function (value) { .test("unique-name", "Name must be unique", async function (value) {
if (value.length === 0)
return true;
return await nameIsAvailable(this.parent.id, value); return await nameIsAvailable(this.parent.id, value);
}), }),
description: yup.string().default(""), description: yup.string().default(""),
@ -59,7 +69,16 @@ const schema = yup.object().shape({
return setupData.targets.some(t => t.id === value); return setupData.targets.some(t => t.id === value);
}), }),
statusCode: yup.string().default("ED"), statusCode: yup.string().default("ED"),
scheduleDate: yup.date().nullable(), scheduleDate: yup.date()
.nullable()
.when("$scheduleForLater", (scheduleForLater, schema) => { // Use context variable
return scheduleForLater
? schema
.required("Schedule date is required when scheduled for later")
.min(new Date(), "Schedule date must be in the future")
: schema.nullable();
}),
//scheduleDate: yup.date().nullable(),
// .when("statusCode", { // .when("statusCode", {
// is: (value: string) => value === "SC" || value === "SD", // String comparison // is: (value: string) => value === "SC" || value === "SD", // String comparison
@ -104,8 +123,14 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
const isNew = !mailing || mailing.id === 0; const isNew = !mailing || mailing.id === 0;
const setupData: SetupData = useSetupData(); const setupData: SetupData = useSetupData();
const [approved, setApproved] = useState<boolean>(false); const [approved, setApproved] = useState<boolean>(false);
const [recurring, setRecurring] = useState<boolean>(false);
const [scheduleForLater, setScheduleForLater] = useState<boolean>(false);
const [testEmailListId, setTestEmailListId] = useState<number | null>(null); const [testEmailListId, setTestEmailListId] = useState<number | null>(null);
const [emails, setEmails] = useState<string[]>([]); // State for email array const [emails, setEmails] = useState<string[]>([]); // State for email array
const [templateViewerOpen, setTemplateViewerOpen] = useState<boolean>(false);
const [currentTemplate, setCurrentTemplate] = useState<Template | null>(null);
const [targetSampleViewerOpen, setTargetSampleViewerOpen] = useState<boolean>(false);
const [currentTarget, setCurrentTarget] = useState<Target | null>(null);
const { register, trigger, control, handleSubmit, reset, formState: { errors } } = useForm<Mailing>({ const { register, trigger, control, handleSubmit, reset, formState: { errors } } = useForm<Mailing>({
mode: "onBlur", mode: "onBlur",
@ -128,12 +153,41 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
setTestEmailListId(null); setTestEmailListId(null);
setEmails([]); setEmails([]);
} }
if (mailing?.templateId) {
const template = setupData.templates.find(t => t.id === mailing.templateId) || null;
setCurrentTemplate(template);
} else {
setCurrentTemplate(null);
} }
}, [open, mailing, reset, setupData.testEmailLists]); if (mailing?.targetId) {
const target = setupData.targets.find(t => t.id === mailing.targetId) || null;
setCurrentTarget(target);
} else {
setCurrentTarget(null);
}
}
}, [open, mailing, reset, setupData.testEmailLists, setupData.targets]);
const handleSave = async (formData: Mailing) => { const handleSave = async (formData: Mailing) => {
const apiUrl = isNew ? "/api/mailings" : `/api/mailings/${formData.id}`; const apiUrl = isNew ? "/api/mailings" : `/api/mailings/${formData.id}`;
const method = isNew ? "POST" : "PUT"; const method = isNew ? "POST" : "PUT";
setLoading(true); setLoading(true);
if (approved) {
formData.statusCode = "SC";
if (!scheduleForLater) {
formData.scheduleDate = null;
}
if (!recurring) {
formData.recurringTypeCode = null;
formData.recurringStartDate = null;
}
}
else {
formData.statusCode = "ED";
formData.scheduleDate = null;
formData.recurringTypeCode = null;
formData.recurringStartDate = null;
}
try { try {
const response = await fetch(apiUrl, { const response = await fetch(apiUrl, {
method: method, method: method,
@ -162,13 +216,27 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
const handleTestEmailListChange = (list: TestEmailList | null) => { const handleTestEmailListChange = (list: TestEmailList | null) => {
if (list) { if (list) {
setTestEmailListId(list.id);
setEmails(list.emails); setEmails(list.emails);
} }
} }
const handleApprovedChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleApprovedChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setApproved(event.target.checked); setApproved(event.target.checked);
}; };
const handleRecurringChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setRecurring(event.target.checked);
};
const handleScheduleForLaterChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setScheduleForLater(event.target.checked);
};
const handleTemplateViewerOpen = () => {
setTemplateViewerOpen(!templateViewerOpen);
};
const handleTargetSampleViewerOpen = () => {
setTargetSampleViewerOpen(!targetSampleViewerOpen);
};
return ( return (
<LocalizationProvider dateAdapter={AdapterDayjs}> {/* Wrap with LocalizationProvider */}
<Dialog open={open} onClose={(_, reason) => { onClose(reason); }} maxWidth="sm" fullWidth disableEscapeKeyDown > <Dialog open={open} onClose={(_, reason) => { onClose(reason); }} maxWidth="sm" fullWidth disableEscapeKeyDown >
<DialogTitle>{isNew ? "Add Mailing" : "Edit Mailing id=" + mailing?.id}</DialogTitle> <DialogTitle>{isNew ? "Add Mailing" : "Edit Mailing id=" + mailing?.id}</DialogTitle>
<DialogContent> <DialogContent>
@ -200,6 +268,7 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
onChange={(_, newValue) => { onChange={(_, newValue) => {
field.onChange(newValue ? newValue.id : null); field.onChange(newValue ? newValue.id : null);
trigger("templateId"); trigger("templateId");
setCurrentTemplate(newValue);
}} }}
renderInput={(params) => ( renderInput={(params) => (
<TextField <TextField
@ -214,6 +283,10 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
/> />
)} )}
/> />
{currentTemplate && (
<Button onClick={handleTemplateViewerOpen}>View Template</Button>
)}
<Controller <Controller
name="targetId" name="targetId"
control={control} control={control}
@ -226,6 +299,7 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
onChange={(_, newValue) => { onChange={(_, newValue) => {
field.onChange(newValue ? newValue.id : null); field.onChange(newValue ? newValue.id : null);
trigger("targetId"); trigger("targetId");
setCurrentTarget(newValue);
}} }}
renderInput={(params) => ( renderInput={(params) => (
<TextField <TextField
@ -240,6 +314,9 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
/> />
)} )}
/> />
{currentTarget && (
<Button onClick={handleTargetSampleViewerOpen}>View Target Sample</Button>
)}
<Autocomplete <Autocomplete
options={setupData.testEmailLists} options={setupData.testEmailLists}
getOptionLabel={(option) => option.name} getOptionLabel={(option) => option.name}
@ -261,9 +338,36 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
</Box> </Box>
{approved && (<> {approved && (<>
<Box sx={{ display: 'flex', flexDirection: 'column', ml: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', ml: 3 }}>
<FormControlLabel control={<Checkbox />} label="Recurring Mailing" /> <FormControlLabel control={<Checkbox checked={recurring} onChange={handleRecurringChange} />} label="Recurring Mailing" />
<FormControlLabel control={<Checkbox />} label="Schedule for Later" /> {recurring && (<>
<DatePicker /> TODO: RECURRING OPTIONS HERE
</>)}
<FormControlLabel control={<Checkbox checked={scheduleForLater} onChange={handleScheduleForLaterChange} />} label="Schedule for Later" />
{scheduleForLater && (
<Controller
name="scheduleDate"
control={control}
render={({ field }) => (
<DateTimePicker
label="Schedule Date"
value={field.value ? dayjs(field.value) : null} // Convert to Dayjs
onChange={(newValue: Dayjs | null) => {
field.onChange(newValue ? newValue.toDate() : null); // Convert back to Date
trigger("scheduleDate"); // Revalidate
}}
slotProps={{
textField: {
fullWidth: true,
margin: "dense",
error: !!errors.scheduleDate,
helperText: errors.scheduleDate?.message,
},
}}
minDateTime={dayjs()} // Enforce future date in UI
/>
)}
/>
)}
</Box> </Box>
</>)} </>)}
{/*<Controller {/*<Controller
@ -329,6 +433,21 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
)} )}
/> />
*/} */}
{templateViewerOpen && (
<TemplateViewer
open={templateViewerOpen}
template={currentTemplate!}
onClose={() => { setTemplateViewerOpen(false) }}
/>
)}
{targetSampleViewerOpen && (
<TargetSampleViewer
open={targetSampleViewerOpen}
target={currentTarget!}
onClose={() => { setTargetSampleViewerOpen(false) }}
/>
)}
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => { onClose( 'cancelled'); }} disabled={loading}>Cancel</Button> <Button onClick={() => { onClose( 'cancelled'); }} disabled={loading}>Cancel</Button>
@ -337,6 +456,7 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
</LocalizationProvider>
); );
}; };

View File

@ -0,0 +1,101 @@
import { useState, useEffect } from "react";
import {
IconButton,
Box,
Dialog,
DialogTitle,
DialogContent,
} from "@mui/material";
import Target from "@/types/target";
import CloseIcon from '@mui/icons-material/Close';
import { DataGrid, GridColDef } from '@mui/x-data-grid';
type TargetSampleViewerProps = {
open: boolean;
target: Target;
onClose: () => void;
};
type TargetSample = {
columnNames: string[];
rows: { [key: string]: string }[];
};
const TargetSampleViewer = ({ open, target, onClose }: TargetSampleViewerProps) => {
const [targetSample, setTargetSample] = useState<TargetSample | null>(null); // Store the full sample response
const [loading, setLoading] = useState(false); // Optional: Add loading state
// Fetch sample data when dialog opens
useEffect(() => {
if (open) {
const fetchSampleData = async () => {
setLoading(true);
try {
const response = await fetch(`/api/targets/${target.id}/sample`);
if (!response.ok) throw new Error("Failed to fetch sample data");
const data: TargetSample = await response.json();
setTargetSample(data);
} catch (error) {
console.error("Error fetching target sample:", error);
setTargetSample(null); // Reset on error
} finally {
setLoading(false);
}
};
fetchSampleData();
}
}, [open, target.id]);
// Transform columnNames into GridColDef array
const columns: GridColDef[] = targetSample?.columnNames?.map((colName) => ({
field: colName,
headerName: colName.charAt(0).toUpperCase() + colName.slice(1), // Capitalize header
flex: 1,
sortable: true,
})) || [];
// Transform TargetRow into DataGrid-compatible rows
const rows = targetSample?.rows?.map((row, index) => {
const rowData: { [key: string]: string } = { id: index.toString() }; // Use index as unique id
targetSample.columnNames.forEach((key) => {
rowData[key] = row[key] ?? ""; // Map each key-value pair to the row object
});
return rowData;
}) || [];
return (
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
<DialogTitle>
<Box display="flex" alignItems="center" justifyContent="space-between">
<span>View Target Sample "{target.name}"</span>
<IconButton
aria-label="close"
onClick={onClose}
sx={{ marginLeft: "auto" }}
>
<CloseIcon />
</IconButton>
</Box>
</DialogTitle>
<DialogContent>
<DataGrid
rows={rows} // Use transformed rows
columns={columns} // Use transformed columns
autoPageSize
loading={loading} // Show loading state
sx={{ width: "100%", height: "calc(90vh - 120px)" }}
initialState={{
pagination: {
paginationModel: {
pageSize: 20,
},
},
}}
pageSizeOptions={[10, 20, 50, 100]}
/>
</DialogContent>
</Dialog>
);
};
export default TargetSampleViewer;

View File

@ -0,0 +1,43 @@
import {
IconButton,
Box,
Dialog,
DialogTitle,
DialogContent
} from "@mui/material";
import Template from "@/types/template";
import CloseIcon from '@mui/icons-material/Close';
type TemplateViewerProps = {
open: boolean;
template: Template;
onClose: () => void;
};
const TemplateViewer = ({ open, template, onClose }: TemplateViewerProps) => {
return (
<Dialog open={open} onClose={() => { onClose(); }} maxWidth="md" fullWidth >
<DialogTitle>
<Box display="flex" alignItems="center" justifyContent="space-between">
<span>View Template "{template.name}"</span>
<IconButton
aria-label="close"
onClick={onClose}
sx={{ marginLeft: "auto" }} // Pushes button to the right
>
<CloseIcon />
</IconButton>
</Box>
</DialogTitle>
<DialogContent>
<iframe
srcDoc={template.htmlBody ?? ""}
sandbox="allow-same-origin"
style={{ width: "100%", height: "calc(90vh - 120px)", border: "none" }}
/>
</DialogContent>
</Dialog>
);
};
export default TemplateViewer;