Add target and server management features
- Implemented CreateTarget and updated UpdateTarget methods in TargetsController. - Introduced IServerService and IServerRepository for server operations. - Created ServersController for handling server-related API requests. - Added ServerUpdateDto for server updates and enhanced Target/Server classes with default values. - Updated TargetRepository and TargetService for target creation and updates. - Modified DapperConfiguration for server mappings. - Updated package dependencies for form validation and state management. - Enhanced Layout.tsx for improved responsiveness and user experience. - Added TitleContext for managing page titles. - Updated Servers.tsx and ServerEdit.tsx for server data handling. - Improved error handling and loading states across components.
This commit is contained in:
parent
4180e50c9c
commit
6f00235702
57
Surge365.MassEmailReact.API/Controllers/ServersController.cs
Normal file
57
Surge365.MassEmailReact.API/Controllers/ServersController.cs
Normal file
@ -0,0 +1,57 @@
|
||||
using Microsoft.AspNetCore.DataProtection.KeyManagement;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Surge365.MassEmailReact.Application.DTOs;
|
||||
using Surge365.MassEmailReact.Application.Interfaces;
|
||||
using Surge365.MassEmailReact.Domain.Entities;
|
||||
|
||||
namespace Surge365.MassEmailReact.Server.Controllers
|
||||
{
|
||||
[Route("api/[controller]")]
|
||||
[ApiController]
|
||||
public class ServersController : ControllerBase
|
||||
{
|
||||
private readonly IServerService _serverService;
|
||||
|
||||
public ServersController(IServerService serverService)
|
||||
{
|
||||
_serverService = serverService;
|
||||
}
|
||||
|
||||
|
||||
[HttpGet("GetAll")]
|
||||
public async Task<IActionResult> GetAll([FromQuery] bool? activeOnly, bool? returnPassword = null)
|
||||
{
|
||||
bool activeOnlyValue = activeOnly == null || activeOnly.Value ? true : false;
|
||||
bool returnPasswordValue = returnPassword != null && returnPassword.Value ? true : false;
|
||||
var servers = await _serverService.GetAllAsync(activeOnlyValue, returnPasswordValue);
|
||||
return Ok(servers);
|
||||
}
|
||||
|
||||
[HttpGet("{key}")]
|
||||
public async Task<IActionResult> GetByKey(int id, bool? returnPassword = null)
|
||||
{
|
||||
bool returnPasswordValue = returnPassword == null || returnPassword.Value ? true : false;
|
||||
var server = await _serverService.GetByIdAsync(id, returnPasswordValue);
|
||||
return server is not null ? Ok(server) : NotFound($"Server with key '{id}' not found.");
|
||||
}
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> UpdateServer(int id, [FromBody] ServerUpdateDto serverUpdateDto)
|
||||
{
|
||||
if (id != serverUpdateDto.Id)
|
||||
return BadRequest("ID in URL does not match ID in request body");
|
||||
|
||||
var existingServer = await _serverService.GetByIdAsync(id);
|
||||
if (existingServer == null)
|
||||
return NotFound($"Server with ID {id} not found");
|
||||
|
||||
var success = await _serverService.UpdateAsync(serverUpdateDto);
|
||||
if (!success)
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to update server.");
|
||||
|
||||
var updatedServer = await _serverService.GetByIdAsync(id);
|
||||
|
||||
return Ok(updatedServer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -32,15 +32,29 @@ namespace Surge365.MassEmailReact.Server.Controllers
|
||||
var target = await _targetService.GetByIdAsync(id);
|
||||
return target is not null ? Ok(target) : NotFound($"Target with key '{id}' not found.");
|
||||
}
|
||||
[HttpPost()]
|
||||
public async Task<IActionResult> CreateTarget(int id, [FromBody] TargetUpdateDto targetUpdateDto)
|
||||
{
|
||||
if (targetUpdateDto.Id != null && targetUpdateDto.Id > 0)
|
||||
return BadRequest("Id must be null or 0");
|
||||
|
||||
var targetId = await _targetService.CreateAsync(targetUpdateDto);
|
||||
if (targetId == null)
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to craete target.");
|
||||
|
||||
var createdTarget = await _targetService.GetByIdAsync(targetId.Value);
|
||||
|
||||
return Ok(createdTarget);
|
||||
}
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> UpdateTarget(int id, [FromBody] TargetUpdateDto targetUpdateDto)
|
||||
{
|
||||
if (id != targetUpdateDto.Id)
|
||||
return BadRequest("ID in URL does not match ID in request body");
|
||||
return BadRequest("Id in URL does not match Id in request body");
|
||||
|
||||
var existingTarget = await _targetService.GetByIdAsync(id);
|
||||
if (existingTarget == null)
|
||||
return NotFound($"Target with ID {id} not found");
|
||||
return NotFound($"Target with Id {id} not found");
|
||||
|
||||
var success = await _targetService.UpdateAsync(targetUpdateDto);
|
||||
if (!success)
|
||||
|
||||
@ -16,6 +16,8 @@ builder.Services.AddScoped<IUserRepository, UserRepository>();
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
builder.Services.AddScoped<ITargetService, TargetService>();
|
||||
builder.Services.AddScoped<ITargetRepository, TargetRepository>();
|
||||
builder.Services.AddScoped<IServerService, ServerService>();
|
||||
builder.Services.AddScoped<IServerRepository, ServerRepository>();
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseDefaultFiles();
|
||||
|
||||
18
Surge365.MassEmailReact.Application/DTOs/ServerUpdateDto.cs
Normal file
18
Surge365.MassEmailReact.Application/DTOs/ServerUpdateDto.cs
Normal file
@ -0,0 +1,18 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Surge365.MassEmailReact.Domain.Entities
|
||||
{
|
||||
public class ServerUpdateDto
|
||||
{
|
||||
public int? Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string ServerName { get; set; } = "";
|
||||
public short Port { get; set; }
|
||||
public string Username { get; set; } = "";
|
||||
public string Password { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
using Surge365.MassEmailReact.Domain.Entities;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Surge365.MassEmailReact.Application.Interfaces
|
||||
{
|
||||
public interface IServerRepository
|
||||
{
|
||||
Task<Server?> GetByIdAsync(int id, bool returnPassword = false);
|
||||
Task<List<Server>> GetAllAsync(bool activeOnly = true, bool returnPassword = false);
|
||||
Task<bool> UpdateAsync(Server server);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
using Surge365.MassEmailReact.Domain.Entities;
|
||||
|
||||
namespace Surge365.MassEmailReact.Application.Interfaces
|
||||
{
|
||||
public interface IServerService
|
||||
{
|
||||
Task<Server?> GetByIdAsync(int id, bool returnPassword = false);
|
||||
Task<List<Server>> GetAllAsync(bool activeOnly = true, bool returnPassword = false);
|
||||
Task<bool> UpdateAsync(ServerUpdateDto serverDto);
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,7 @@ namespace Surge365.MassEmailReact.Application.Interfaces
|
||||
{
|
||||
Task<Target?> GetByIdAsync(int id);
|
||||
Task<List<Target>> GetAllAsync(bool activeOnly = true);
|
||||
Task<int?> CreateAsync(Target target);
|
||||
Task<bool> UpdateAsync(Target target);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ namespace Surge365.MassEmailReact.Application.Interfaces
|
||||
{
|
||||
Task<Target?> GetByIdAsync(int id);
|
||||
Task<List<Target>> GetAllAsync(bool activeOnly = true);
|
||||
Task<int?> CreateAsync(TargetUpdateDto targetDto);
|
||||
Task<bool> UpdateAsync(TargetUpdateDto targetDto);
|
||||
}
|
||||
}
|
||||
|
||||
20
Surge365.MassEmailReact.Domain/Entities/Server.cs
Normal file
20
Surge365.MassEmailReact.Domain/Entities/Server.cs
Normal file
@ -0,0 +1,20 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Surge365.MassEmailReact.Domain.Entities
|
||||
{
|
||||
public class Server
|
||||
{
|
||||
public int? Id { get; private set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string ServerName { get; set; } = "";
|
||||
public short Port { get; set; }
|
||||
public string Username { get; set; } = "";
|
||||
public string Password { get; set; } = "";
|
||||
|
||||
public Server() { }
|
||||
}
|
||||
}
|
||||
@ -10,10 +10,10 @@ namespace Surge365.MassEmailReact.Domain.Entities
|
||||
{
|
||||
public int? Id { get; private set; }
|
||||
public int ServerId { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string DatabaseName { get; set; }
|
||||
public string ViewName { get; set; }
|
||||
public string FilterQuery { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string DatabaseName { get; set; } = "";
|
||||
public string ViewName { get; set; } = "";
|
||||
public string FilterQuery { get; set; } = "";
|
||||
public bool AllowWriteBack { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ namespace Surge365.MassEmailReact.Infrastructure.DapperMaps
|
||||
FluentMapper.Initialize(config =>
|
||||
{
|
||||
config.AddMap(new TargetMap());
|
||||
config.AddMap(new ServerMap());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper.FluentMap.Mapping;
|
||||
using Surge365.MassEmailReact.Domain.Entities;
|
||||
|
||||
namespace Surge365.MassEmailReact.Infrastructure.DapperMaps
|
||||
{
|
||||
public class ServerMap : EntityMap<Server>
|
||||
{
|
||||
public ServerMap()
|
||||
{
|
||||
Map(t => t.Id).ToColumn("server_key");
|
||||
Map(t => t.Name).ToColumn("name");
|
||||
Map(t => t.ServerName).ToColumn("server_name");
|
||||
Map(t => t.Port).ToColumn("port");
|
||||
Map(t => t.Username).ToColumn("username");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,100 @@
|
||||
using Dapper;
|
||||
using Dapper.FluentMap;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Surge365.MassEmailReact.Application.Interfaces;
|
||||
using Surge365.MassEmailReact.Domain.Entities;
|
||||
using Surge365.MassEmailReact.Domain.Enums;
|
||||
using Surge365.MassEmailReact.Domain.Enums.Extensions;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Surge365.MassEmailReact.Infrastructure.Repositories
|
||||
{
|
||||
public class ServerRepository : IServerRepository
|
||||
{
|
||||
private IConfiguration _config;
|
||||
private const string _connectionStringName = "MassEmail.ConnectionString";
|
||||
private string? ConnectionString
|
||||
{
|
||||
get
|
||||
{
|
||||
return _config.GetConnectionString(_connectionStringName);
|
||||
}
|
||||
}
|
||||
public ServerRepository(IConfiguration config)
|
||||
{
|
||||
_config = config;
|
||||
#if DEBUG
|
||||
if (!FluentMapper.EntityMaps.ContainsKey(typeof(Server)))
|
||||
{
|
||||
throw new InvalidOperationException("Server dapper mapping is missing. Make sure ConfigureMappings() is called inside program.cs (program startup).");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
public async Task<Server?> GetByIdAsync(int serverKey, bool returnPassword = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(_config);
|
||||
ArgumentNullException.ThrowIfNull(_connectionStringName);
|
||||
|
||||
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
|
||||
|
||||
Server? server = (await conn.QueryAsync<Server>("mem_get_server_by_id", new { server_key = serverKey }, commandType: CommandType.StoredProcedure)).FirstOrDefault();
|
||||
if (server != null && !returnPassword)
|
||||
{
|
||||
server.Password = "";
|
||||
}
|
||||
return server;
|
||||
}
|
||||
public async Task<List<Server>> GetAllAsync(bool activeOnly = true, bool returnPassword = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(_config);
|
||||
ArgumentNullException.ThrowIfNull(_connectionStringName);
|
||||
|
||||
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
|
||||
|
||||
List<Server> servers = (await conn.QueryAsync<Server>("mem_get_server_all", new { active_only = activeOnly }, commandType: CommandType.StoredProcedure)).ToList();
|
||||
if (!returnPassword)
|
||||
{
|
||||
foreach (Server server in servers)
|
||||
server.Password = "";
|
||||
}
|
||||
return servers;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateAsync(Server server)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(server);
|
||||
ArgumentNullException.ThrowIfNull(server.Id);
|
||||
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
|
||||
|
||||
var parameters = new DynamicParameters();
|
||||
parameters.Add("@server_key", server.Id, DbType.Int32);
|
||||
parameters.Add("@name", server.Name, DbType.String);
|
||||
parameters.Add("@server_name", server.ServerName, DbType.String);
|
||||
parameters.Add("@port", server.Port, DbType.Int32);
|
||||
parameters.Add("@username", server.Username, DbType.String);
|
||||
parameters.Add("@password", server.Password, DbType.String);
|
||||
|
||||
// Output parameter
|
||||
parameters.Add("@success", dbType: DbType.Boolean, direction: ParameterDirection.Output);
|
||||
|
||||
await conn.ExecuteAsync("mem_save_server", parameters, commandType: CommandType.StoredProcedure);
|
||||
|
||||
// Retrieve the output parameter value
|
||||
bool success = parameters.Get<bool>("@success");
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
//public void Add(Server server)
|
||||
//{
|
||||
// Servers.Add(server);
|
||||
//}
|
||||
|
||||
}
|
||||
}
|
||||
@ -56,6 +56,37 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
|
||||
//return conn.Query<Target>("SELECT * FROM mem_target WHERE is_active = @active_only", new { active_only = activeOnly }).ToList();
|
||||
}
|
||||
|
||||
public async Task<int?> CreateAsync(Target target)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
if (target.Id != null && target.Id > 0)
|
||||
throw new Exception("ID must be null");
|
||||
|
||||
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
|
||||
|
||||
var parameters = new DynamicParameters();
|
||||
parameters.Add("@target_key", dbType: DbType.Int32, direction: ParameterDirection.Output);
|
||||
parameters.Add("@server_key", target.ServerId, DbType.Int32);
|
||||
parameters.Add("@name", target.Name, DbType.String);
|
||||
parameters.Add("@database_name", target.DatabaseName, DbType.String);
|
||||
parameters.Add("@view_name", target.ViewName, DbType.String);
|
||||
parameters.Add("@filter_query", target.FilterQuery, DbType.String);
|
||||
parameters.Add("@allow_write_back", target.AllowWriteBack, DbType.Boolean);
|
||||
parameters.Add("@is_active", target.IsActive, DbType.Boolean);
|
||||
|
||||
// Output parameter
|
||||
parameters.Add("@success", dbType: DbType.Boolean, direction: ParameterDirection.Output);
|
||||
|
||||
await conn.ExecuteAsync("mem_save_target", parameters, commandType: CommandType.StoredProcedure);
|
||||
|
||||
// Retrieve the output parameter value
|
||||
bool success = parameters.Get<bool>("@success");
|
||||
|
||||
if(success)
|
||||
return parameters.Get<int>("@target_key");
|
||||
|
||||
return null;
|
||||
}
|
||||
public async Task<bool> UpdateAsync(Target target)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
|
||||
@ -101,7 +101,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
|
||||
DataAccess da = new DataAccess(_config, _connectionStringName);
|
||||
DataSet ds = await da.CallRetrievalProcedureAsync(pms, "adm_get_adm_login_all");
|
||||
if (ds.Tables.Count == 0 || ds.Tables[0].Rows.Count == 0)
|
||||
return null;
|
||||
throw new Exception("No users returned");
|
||||
|
||||
return LoadFromDataRow(ds.Tables[0]);
|
||||
}
|
||||
|
||||
@ -0,0 +1,53 @@
|
||||
using Surge365.MassEmailReact.Application.Interfaces;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Surge365.MassEmailReact.Domain.Entities;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
|
||||
namespace Surge365.MassEmailReact.Infrastructure.Services
|
||||
{
|
||||
public class ServerService : IServerService
|
||||
{
|
||||
private readonly IServerRepository _serverRepository;
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
public ServerService(IServerRepository serverRepository, IConfiguration config)
|
||||
{
|
||||
_serverRepository = serverRepository;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public async Task<Server?> GetByIdAsync(int id, bool returnPassword = false)
|
||||
{
|
||||
return await _serverRepository.GetByIdAsync(id, returnPassword);
|
||||
}
|
||||
public async Task<List<Server>> GetAllAsync(bool activeOnly = true, bool returnPassword = false)
|
||||
{
|
||||
return await _serverRepository.GetAllAsync(activeOnly, returnPassword);
|
||||
}
|
||||
public async Task<bool> UpdateAsync(ServerUpdateDto serverDto)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serverDto, nameof(serverDto));
|
||||
ArgumentNullException.ThrowIfNull(serverDto.Id, nameof(serverDto.Id));
|
||||
|
||||
var server = await _serverRepository.GetByIdAsync(serverDto.Id.Value);
|
||||
if (server == null || server.Id == null) return false;
|
||||
|
||||
server.Name = serverDto.Name;
|
||||
server.ServerName = serverDto.ServerName;
|
||||
server.Port = serverDto.Port;
|
||||
server.Username = serverDto.Username;
|
||||
server.Password = serverDto.Password;
|
||||
|
||||
return await _serverRepository.UpdateAsync(server);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -33,6 +33,24 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
|
||||
{
|
||||
return await _targetRepository.GetAllAsync(activeOnly);
|
||||
}
|
||||
public async Task<int?> CreateAsync(TargetUpdateDto targetDto)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(targetDto, nameof(targetDto));
|
||||
if (targetDto.Id != null && targetDto.Id > 0)
|
||||
throw new Exception("ID must be null");
|
||||
|
||||
var target = new Target();
|
||||
|
||||
target.ServerId = targetDto.ServerId;
|
||||
target.Name = targetDto.Name;
|
||||
target.DatabaseName = targetDto.DatabaseName;
|
||||
target.ViewName = targetDto.ViewName;
|
||||
target.FilterQuery = targetDto.FilterQuery;
|
||||
target.AllowWriteBack = targetDto.AllowWriteBack;
|
||||
target.IsActive = targetDto.IsActive;
|
||||
|
||||
return await _targetRepository.CreateAsync(target);
|
||||
}
|
||||
public async Task<bool> UpdateAsync(TargetUpdateDto targetDto)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(targetDto, nameof(targetDto));
|
||||
|
||||
81
Surge365.MassEmailReact.Web/package-lock.json
generated
81
Surge365.MassEmailReact.Web/package-lock.json
generated
@ -11,6 +11,7 @@
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@fontsource/roboto": "^5.1.1",
|
||||
"@hookform/resolvers": "^4.1.2",
|
||||
"@mui/icons-material": "^6.4.5",
|
||||
"@mui/material": "^6.4.5",
|
||||
"@mui/x-charts": "^7.27.1",
|
||||
@ -23,8 +24,10 @@
|
||||
"react": "^19.0.0",
|
||||
"react-bootstrap": "^2.10.9",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-icons": "^5.3.0",
|
||||
"react-router-dom": "^7.0.1"
|
||||
"react-router-dom": "^7.0.1",
|
||||
"yup": "^1.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
@ -1063,6 +1066,18 @@
|
||||
"integrity": "sha512-XwVVXtERDQIM7HPUIbyDe0FP4SRovpjF7zMI8M7pbqFp3ahLJsJTd18h+E6pkar6UbV3btbwkKjYARr5M+SQow==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-4.1.2.tgz",
|
||||
"integrity": "sha512-wl6H9c9wLOZMJAqGLEVKzbCkxJuV+BYuLFZFCQtCwMe0b3qQk4kUBd/ZAj13SwcSqcx86rCgSCyngQfmA6DOWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/utils": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-hook-form": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@ -1990,6 +2005,12 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@stencil/core": {
|
||||
"version": "4.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.26.0.tgz",
|
||||
@ -3959,6 +3980,12 @@
|
||||
"react": ">=0.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/property-expr": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
|
||||
"integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@ -4042,6 +4069,22 @@
|
||||
"react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.54.2",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz",
|
||||
"integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/react-hook-form"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-icons": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
|
||||
@ -4358,6 +4401,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-case": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
|
||||
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@ -4371,6 +4420,12 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/toposort": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
|
||||
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz",
|
||||
@ -4409,6 +4464,18 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "2.19.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
|
||||
"integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=12.20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.7.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
|
||||
@ -4659,6 +4726,18 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yup": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/yup/-/yup-1.6.1.tgz",
|
||||
"integrity": "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"property-expr": "^2.0.5",
|
||||
"tiny-case": "^1.0.3",
|
||||
"toposort": "^2.0.2",
|
||||
"type-fest": "^2.19.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@fontsource/roboto": "^5.1.1",
|
||||
"@hookform/resolvers": "^4.1.2",
|
||||
"@mui/icons-material": "^6.4.5",
|
||||
"@mui/material": "^6.4.5",
|
||||
"@mui/x-charts": "^7.27.1",
|
||||
@ -26,8 +27,10 @@
|
||||
"react": "^19.0.0",
|
||||
"react-bootstrap": "^2.10.9",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-icons": "^5.3.0",
|
||||
"react-router-dom": "^7.0.1"
|
||||
"react-router-dom": "^7.0.1",
|
||||
"yup": "^1.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
// src/components/layouts/Layout.tsx
|
||||
import React, { ReactNode } from 'react';
|
||||
import React, { ReactNode, useEffect } from 'react';
|
||||
import { useTheme, useMediaQuery } from '@mui/material';
|
||||
|
||||
import { styled, useColorScheme } from '@mui/material/styles';
|
||||
import Box from '@mui/material/Box';
|
||||
import Drawer from '@mui/material/Drawer';
|
||||
@ -16,14 +18,26 @@ import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import DirectionsCarIcon from '@mui/icons-material/DirectionsCar';
|
||||
import PeopleIcon from '@mui/icons-material/People';
|
||||
|
||||
import DnsIcon from '@mui/icons-material/Dns';
|
||||
import TargetIcon from '@mui/icons-material/TrackChanges';
|
||||
import MarkEmailReadIcon from '@mui/icons-material/MarkEmailRead';
|
||||
import BlockIcon from '@mui/icons-material/Block';
|
||||
import LinkOffIcon from '@mui/icons-material/LinkOff';
|
||||
import EmailIcon from '@mui/icons-material/Email';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import ScheduleSendIcon from '@mui/icons-material/ScheduleSend';
|
||||
import AutorenewIcon from '@mui/icons-material/Autorenew';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import Select, { SelectChangeEvent } from '@mui/material/Select';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
|
||||
import { useTitle } from "@/context/TitleContext";
|
||||
|
||||
// Constants
|
||||
const drawerWidth = 240;
|
||||
|
||||
@ -56,26 +70,26 @@ interface LayoutProps {
|
||||
}
|
||||
|
||||
const Layout = ({ children }: LayoutProps) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [open, setOpen] = React.useState(true);
|
||||
const { mode, setMode } = useColorScheme(); // MUI v6 hook for theme switching
|
||||
const iconButtonRef = React.useRef<HTMLButtonElement>(null)
|
||||
const iconButtonRef = React.useRef<HTMLButtonElement>(null);
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm")); //TODO: Move this to shared utils?
|
||||
const { title } = useTitle();
|
||||
|
||||
const handleDrawerOpen = () => {
|
||||
setOpen(true);
|
||||
sendResize();
|
||||
};
|
||||
|
||||
const handleDrawerClose = () => {
|
||||
setOpen(false);
|
||||
sendResize();
|
||||
};
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
setOpen(false);
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
const sendResize = () => {
|
||||
//// Force window resize event after drawer state changes
|
||||
//setTimeout(() => {
|
||||
// window.dispatchEvent(new Event("resize"));
|
||||
//}); // Delay slightly to ensure UI updates
|
||||
};
|
||||
const handleThemeChange = (event: SelectChangeEvent) => {
|
||||
setMode(event.target.value as 'light' | 'dark');
|
||||
if (iconButtonRef.current) {
|
||||
@ -102,7 +116,7 @@ const Layout = ({ children }: LayoutProps) => {
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
...(open && {
|
||||
width: `calc(100% - ${drawerWidth}px)`,
|
||||
width: isMobile ? "100%" : `calc(100% - ${drawerWidth}px)`,
|
||||
ml: `${drawerWidth}px`,
|
||||
transition: theme.transitions.create(['width', 'margin', 'padding'], {
|
||||
easing: theme.transitions.easing.easeInOut,
|
||||
@ -112,6 +126,17 @@ const Layout = ({ children }: LayoutProps) => {
|
||||
})}
|
||||
>
|
||||
<Toolbar>
|
||||
{isMobile && open ?
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="close drawer"
|
||||
onClick={handleDrawerClose}
|
||||
edge="start"
|
||||
ref={iconButtonRef}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</IconButton> :
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
@ -122,8 +147,9 @@ const Layout = ({ children }: LayoutProps) => {
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
}
|
||||
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
|
||||
Surge365 Dashboard
|
||||
{title}
|
||||
</Typography>
|
||||
<FormControl sx={{ minWidth: 120 }} size="small">
|
||||
<InputLabel
|
||||
@ -145,14 +171,14 @@ const Layout = ({ children }: LayoutProps) => {
|
||||
color: 'white', // White text
|
||||
'& .MuiSvgIcon-root': { color: 'white' }, // White dropdown arrow
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: 'white', // Gray in light, white in dark
|
||||
borderColor: 'white',
|
||||
},
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: 'white', // Darker gray on hover in light
|
||||
borderColor: 'white',
|
||||
borderWidth: 2
|
||||
},
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: 'white', // Even darker gray when focused in light
|
||||
borderColor: 'white',
|
||||
borderWidth: 2
|
||||
},
|
||||
}}
|
||||
@ -166,18 +192,19 @@ const Layout = ({ children }: LayoutProps) => {
|
||||
|
||||
{/* Sidebar */}
|
||||
<Drawer
|
||||
variant="persistent"
|
||||
variant={isMobile ? "temporary" : "persistent"}
|
||||
anchor="left"
|
||||
open={open}
|
||||
sx={{
|
||||
width: open ? `var(--mui-drawer-width, ${drawerWidth}px)` : 0,
|
||||
width: isMobile ? "100%" : open ? `var(--mui-drawer-width, ${drawerWidth}px)` : 0,
|
||||
flexShrink: 0,
|
||||
'& .MuiDrawer-paper': {
|
||||
width: open ? `var(--mui-drawer-width, ${drawerWidth}px)` : 0,
|
||||
width: isMobile ? "100%" : open ? `var(--mui-drawer-width, ${drawerWidth}px)` : 0,
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
<DrawerHeader>
|
||||
<IconButton onClick={handleDrawerClose}>
|
||||
<ChevronLeftIcon />
|
||||
@ -187,8 +214,16 @@ const Layout = ({ children }: LayoutProps) => {
|
||||
<List>
|
||||
{[
|
||||
{ text: 'Home', icon: <DashboardIcon />, path: '/home' },
|
||||
{ text: 'Targets', icon: <DirectionsCarIcon />, path: '/targets' },
|
||||
{ text: 'Templates', icon: <PeopleIcon />, path: '/templates' },
|
||||
{ text: 'Servers', icon: <DnsIcon />, path: '/servers' },
|
||||
{ text: 'Targets', icon: <TargetIcon />, path: '/targets' },
|
||||
{ text: 'Test Lists', icon: <MarkEmailReadIcon />, path: '/testLists' },
|
||||
{ text: 'Blocked Emails', icon: <BlockIcon />, path: '/blockedEmails' },
|
||||
{ text: 'Unsubscribe Urls', icon: <LinkOffIcon />, path: '/unsubscribeUrls' },
|
||||
{ text: 'Templates', icon: <EmailIcon />, path: '/templates' },
|
||||
{ text: 'New Mailings', icon: <SendIcon />, path: '/newMailings' }, //TODO: Maybe move all mailings to same page? Mailing stats on dashboard?
|
||||
{ text: 'Scheduled Mailings', icon: <ScheduleSendIcon />, path: '/scheduledMailings' }, //
|
||||
{ text: 'Active Mailings', icon: <AutorenewIcon />, path: '/activeMailings' },
|
||||
{ text: 'Completed Mailings', icon: <CheckCircleIcon />, path: '/completedMailings' },
|
||||
].map((item) => (
|
||||
<ListItem key={item.text} disablePadding>
|
||||
<ListItemButton component={RouterLink} to={item.path}>
|
||||
|
||||
107
Surge365.MassEmailReact.Web/src/components/modals/ServerEdit.tsx
Normal file
107
Surge365.MassEmailReact.Web/src/components/modals/ServerEdit.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
TextField,
|
||||
DialogActions,
|
||||
Button,
|
||||
} from "@mui/material";
|
||||
import Server from "@/types/server";
|
||||
|
||||
type ServerEditProps = {
|
||||
open: boolean;
|
||||
server: Server;
|
||||
onClose: () => void;
|
||||
onSave: (updatedServer: Server) => void;
|
||||
};
|
||||
|
||||
const ServerEdit = ({ open, server, onClose, onSave }: ServerEditProps) => {
|
||||
const [formData, setFormData] = useState<Server>({ ...server });
|
||||
//const [serverError, setServerError] = useState(false); // Track validation
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => { //Reset form to unedited state on open or server change
|
||||
if (open) {
|
||||
setFormData(server);
|
||||
}
|
||||
}, [open, server]);
|
||||
|
||||
const handleChange = (field: string, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value || "" }));
|
||||
|
||||
//if (field === "serverId" && value) setServerError(false);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/servers/${formData.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Failed to update");
|
||||
|
||||
const updatedServer = await response.json();
|
||||
onSave(updatedServer); // Update UI optimistically
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Update error:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Edit Server - id={formData.id}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
label="Name"
|
||||
fullWidth
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange("name", e.target.value)}
|
||||
margin="dense"
|
||||
/>
|
||||
<TextField
|
||||
label="Server Name"
|
||||
fullWidth
|
||||
value={formData.serverName}
|
||||
onChange={(e) => handleChange("serverName", e.target.value)}
|
||||
margin="dense"
|
||||
/>
|
||||
<TextField
|
||||
label="Port"
|
||||
fullWidth
|
||||
value={formData.port}
|
||||
onChange={(e) => handleChange("port", e.target.value)}
|
||||
margin="dense"
|
||||
/>
|
||||
<TextField
|
||||
label="Username"
|
||||
fullWidth
|
||||
value={formData.username}
|
||||
onChange={(e) => handleChange("username", e.target.value)}
|
||||
margin="dense"
|
||||
/>
|
||||
<TextField
|
||||
label="Password"
|
||||
fullWidth
|
||||
value={formData.password}
|
||||
onChange={(e) => handleChange("password", e.target.value)}
|
||||
margin="dense"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={loading}>Cancel</Button>
|
||||
<Button onClick={handleSave} color="primary" disabled={loading}>
|
||||
{loading ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerEdit;
|
||||
@ -1,44 +1,105 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
TextField,
|
||||
Autocomplete,
|
||||
DialogActions,
|
||||
Button,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
} from "@mui/material";
|
||||
import { Target } from "@/types/target";
|
||||
import Target from "@/types/target";
|
||||
import { useSetupData, SetupData } from "@/context/SetupDataContext";
|
||||
import { useForm, Controller, Resolver } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
type TargetEditProps = {
|
||||
open: boolean;
|
||||
target: Target;
|
||||
target: Target | null;
|
||||
onClose: () => void;
|
||||
onSave: (updatedTarget: Target) => void;
|
||||
};
|
||||
|
||||
const TargetEdit = ({ open, target, onClose, onSave }: TargetEditProps) => {
|
||||
const [formData, setFormData] = useState<Target>({ ...target });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const schema = yup.object().shape({
|
||||
id: yup.number(),
|
||||
serverId: yup.number().typeError("Server is required").required("Server is required").moreThan(0, "Server is required"),
|
||||
name: yup
|
||||
.string()
|
||||
.required("Name is required")
|
||||
.test("unique-name", "Name must be unique", function (value) {
|
||||
const setupData = this.options.context?.setupData as { targets: Target[] };
|
||||
if (!setupData) return true;
|
||||
|
||||
const handleChange = (field: keyof Target, value: string | boolean) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
return !setupData.targets.some(
|
||||
(t) => t.name.toLowerCase() === value?.toLowerCase() && (t.id === 0 || t.id !== this.parent.id)
|
||||
);
|
||||
}),
|
||||
databaseName: yup.string().required("Database name is required"),
|
||||
viewName: yup.string().required("View name is required"),
|
||||
filterQuery: yup.string().nullable(),
|
||||
allowWriteBack: yup.boolean().default(false),
|
||||
isActive: yup.boolean().default(true),
|
||||
});
|
||||
|
||||
//TODO: Make DatabaseName a select using new array in setupData.servers.databases
|
||||
//TODO: Maybe Make View a select using new array in setupData.servers.databases.views+procs. But would have to allow free form entry just in case/no validation on found in select
|
||||
//TODO: Add verify/test button on form, checks that server, db, view exist, query works and returns data. Show sample data on screen.
|
||||
const defaultTarget: Target = {
|
||||
id: 0,
|
||||
name: "",
|
||||
serverId: 0,
|
||||
databaseName: "",
|
||||
viewName: "",
|
||||
filterQuery: "",
|
||||
allowWriteBack: false,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const TargetEdit = ({ open, target, onClose, onSave }: TargetEditProps) => {
|
||||
const isNew = !target || target.id === 0;
|
||||
const setupData: SetupData = useSetupData();
|
||||
|
||||
const { register, trigger, control, handleSubmit, reset, formState: { errors } } = useForm<Target>({
|
||||
mode: "onBlur",
|
||||
defaultValues: target || defaultTarget,
|
||||
resolver: yupResolver(schema) as Resolver<Target>,
|
||||
context: { setupData }
|
||||
,
|
||||
});
|
||||
//const [formData, setFormData] = useState<Target>(target ? { ...target } : { ...defaultTarget });
|
||||
//const [serverError, setServerError] = useState(false); // Track validation
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => { //Reset form to unedited state on open or target change
|
||||
if (open) {
|
||||
reset(target || defaultTarget, { keepDefaultValues: true });
|
||||
}
|
||||
}, [open, target, reset]);
|
||||
|
||||
//const handleChange = (field: string, value: any) => {
|
||||
// setFormData((prev) => ({ ...prev, [field]: value?.id || "" }));
|
||||
|
||||
// if (field === "serverId" && value) setServerError(false);
|
||||
//};
|
||||
|
||||
const handleSave = async (formData: Target) => {
|
||||
const apiUrl = isNew ? "/api/targets" : `/api/targets/${formData.id}`;
|
||||
const method = isNew ? "POST" : "PUT";
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/targets/${formData.id}`, {
|
||||
method: "PUT",
|
||||
const response = await fetch(apiUrl, {
|
||||
method: method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Failed to update");
|
||||
if (!response.ok) throw new Error(isNew ? "Failed to create" : "Failed to update");
|
||||
|
||||
const updatedTarget = await response.json();
|
||||
onSave(updatedTarget); // Update UI optimistically
|
||||
onSave(updatedTarget);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Update error:", error);
|
||||
@ -49,74 +110,98 @@ const TargetEdit = ({ open, target, onClose, onSave }: TargetEditProps) => {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Edit Target</DialogTitle>
|
||||
<DialogTitle>{isNew ? "Add Target" : "Edit Target id=" + target.id}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Controller
|
||||
name="serverId"
|
||||
control={control}
|
||||
rules={{ required: "Server is required" }}
|
||||
render={({ field }) => (
|
||||
<Autocomplete {...field}
|
||||
options={setupData.servers}
|
||||
getOptionLabel={(option) => option.name}
|
||||
value={setupData.servers.find((s) => s.id === Number(field.value)) || null}
|
||||
onChange={(_, newValue) => {
|
||||
field.onChange(newValue ? newValue.id : null);
|
||||
trigger("serverId");
|
||||
}}
|
||||
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
label="Target Key"
|
||||
{...params}
|
||||
label="Server"
|
||||
fullWidth
|
||||
value={formData.id}
|
||||
onChange={(e) => handleChange("id", e.target.value)}
|
||||
margin="dense"
|
||||
/>
|
||||
<TextField
|
||||
label="Server Key"
|
||||
fullWidth
|
||||
value={formData.serverId}
|
||||
onChange={(e) => handleChange("serverId", e.target.value)}
|
||||
margin="dense"
|
||||
error={!!errors.serverId}
|
||||
helperText={errors.serverId?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<TextField
|
||||
{...register("name")}
|
||||
label="Name"
|
||||
fullWidth
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange("name", e.target.value)}
|
||||
margin="dense"
|
||||
error={!!errors.name}
|
||||
helperText={errors.name?.message}
|
||||
/>
|
||||
<TextField
|
||||
{...register("databaseName")}
|
||||
label="Database Name"
|
||||
fullWidth
|
||||
value={formData.databaseName}
|
||||
onChange={(e) => handleChange("databaseName", e.target.value)}
|
||||
margin="dense"
|
||||
error={!!errors.databaseName}
|
||||
helperText={errors.databaseName?.message}
|
||||
/>
|
||||
<TextField
|
||||
{...register("viewName")}
|
||||
label="View Name"
|
||||
fullWidth
|
||||
value={formData.viewName}
|
||||
onChange={(e) => handleChange("viewName", e.target.value)}
|
||||
margin="dense"
|
||||
error={!!errors.viewName}
|
||||
helperText={errors.viewName?.message}
|
||||
/>
|
||||
<TextField
|
||||
{...register("filterQuery")}
|
||||
label="Filter Query"
|
||||
fullWidth
|
||||
value={formData.filterQuery}
|
||||
onChange={(e) => handleChange("filterQuery", e.target.value)}
|
||||
margin="dense"
|
||||
error={!!errors.filterQuery}
|
||||
helperText={errors.filterQuery?.message}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={formData.allowWriteBack}
|
||||
onChange={(e) => handleChange("allowWriteBack", e.target.checked)}
|
||||
{...register("allowWriteBack")}
|
||||
/>
|
||||
}
|
||||
label="Allow Write Back"
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="isActive"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={formData.isActive}
|
||||
onChange={(e) => handleChange("isActive", e.target.checked)}
|
||||
{...field}
|
||||
checked={field.value}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Active"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={loading}>Cancel</Button>
|
||||
<Button onClick={handleSave} color="primary" disabled={loading}>
|
||||
<Button onClick={handleSubmit(handleSave)} color="primary" disabled={loading}>
|
||||
{loading ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
|
||||
@ -1,18 +1,35 @@
|
||||
// App.tsx or main routing component
|
||||
import React from 'react';
|
||||
import React, { useEffect, ReactNode } from "react";
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
|
||||
import Layout from '@/components/layouts/Layout';
|
||||
import LayoutLogin from '@/components/layouts/LayoutLogin';
|
||||
import Home from '@/components/pages/Home';
|
||||
import Login from '@/components/pages/Login';
|
||||
import Servers from '@/components/pages/Servers';
|
||||
import Targets from '@/components/pages/Targets';
|
||||
import Templates from '@/components/pages/Templates';
|
||||
import { ColorModeContext } from '@/theme/theme';
|
||||
import { SetupDataProvider } from '@/context/SetupDataContext';
|
||||
import { useTitle } from "@/context/TitleContext";
|
||||
|
||||
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
|
||||
interface PageWrapperProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const PageWrapper: React.FC<PageWrapperProps> = ({ title, children }) => {
|
||||
const { setTitle } = useTitle();
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(title);
|
||||
}, [title, setTitle]);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
const [mode, setMode] = React.useState<'light' | 'dark'>('light');
|
||||
@ -38,25 +55,41 @@ const App = () => {
|
||||
<Route
|
||||
path="/home"
|
||||
element={
|
||||
<PageWrapper title="Dashboard">
|
||||
<Layout>
|
||||
<Home />
|
||||
</Layout>
|
||||
</PageWrapper>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/servers"
|
||||
element={
|
||||
<PageWrapper title="Servers">
|
||||
<Layout>
|
||||
<Servers />
|
||||
</Layout>
|
||||
</PageWrapper>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/targets"
|
||||
element={
|
||||
<PageWrapper title="Targets">
|
||||
<Layout>
|
||||
<Targets />
|
||||
</Layout>
|
||||
</PageWrapper>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/templates"
|
||||
element={
|
||||
<PageWrapper title="Templates">
|
||||
<Layout>
|
||||
<Templates />
|
||||
</Layout>
|
||||
</PageWrapper>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
|
||||
@ -4,6 +4,7 @@ import { createTheme, ThemeProvider } from '@mui/material/styles';
|
||||
import '@/css/main.css'
|
||||
import App from '@/components/pages/App'
|
||||
import '@/config/constants';
|
||||
import { TitleProvider } from "@/context/TitleContext";
|
||||
|
||||
|
||||
//DEFAULT THEMES
|
||||
@ -70,7 +71,9 @@ if (rootElement) {
|
||||
createRoot(rootElement).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme} defaultMode="system">
|
||||
<TitleProvider>
|
||||
<App />
|
||||
</TitleProvider>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
170
Surge365.MassEmailReact.Web/src/components/pages/Servers.tsx
Normal file
170
Surge365.MassEmailReact.Web/src/components/pages/Servers.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useSetupData, SetupData } from "@/context/SetupDataContext";
|
||||
//import Typography from '@mui/material/Typography';
|
||||
import { List, Card, CardContent, Typography, Box, useTheme, useMediaQuery, Button, CircularProgress, IconButton } from '@mui/material';
|
||||
import { DataGrid, GridColDef, GridRenderCellParams, GridRowModel, GridToolbarContainer, GridToolbarQuickFilter, GridToolbarExport, GridToolbarDensitySelector, GridToolbarColumnsButton } from '@mui/x-data-grid';
|
||||
import { Lock, LockOpen } from "@mui/icons-material";
|
||||
//import utils from '@/ts/utils';
|
||||
import Server from '@/types/server';
|
||||
import ServerEdit from "@/components/modals/ServerEdit";
|
||||
|
||||
|
||||
function Servers() {
|
||||
const theme = useTheme();
|
||||
const setupData: SetupData = useSetupData();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
|
||||
const [servers, setServers] = useState<Server[] | null>(null);
|
||||
const gridContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [selectedRow, setSelectedRow] = useState<Server | null>(null);
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
|
||||
const togglePasswordVisibility = async () => {
|
||||
if (isPasswordVisible) {
|
||||
setIsPasswordVisible(false);
|
||||
setServers(setupData.servers);
|
||||
}
|
||||
else {
|
||||
try {
|
||||
setIsPasswordVisible(true);
|
||||
const serversResponse = await fetch("/api/servers/GetAll?activeOnly=false&returnPassword=true");
|
||||
const serversData = await serversResponse.json();
|
||||
setServers(serversData);
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error fetching servers:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const columns: GridColDef<Server>[] = [
|
||||
{
|
||||
field: "actions",
|
||||
headerName: "Actions",
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams<Server>) => (
|
||||
<Button variant="contained" color="primary" size="small" onClick={() => handleEdit(params.row)}>
|
||||
Edit
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{ field: "id", headerName: "ID", width: 60 },
|
||||
{ field: "name", headerName: "Name", flex: 1, minWidth: 160 },
|
||||
{ field: "serverName", headerName: "Database", flex: 1, minWidth: 160 },
|
||||
{ field: "port", headerName: "Port", width: 75 },
|
||||
{ field: "username", headerName: "Username", flex: 1, minWidth: 100 },
|
||||
{
|
||||
field: "password",
|
||||
headerName: "Password",
|
||||
width: 175,
|
||||
renderHeader: () => (
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
Password
|
||||
<IconButton size="small" onClick={togglePasswordVisibility} sx={{ marginLeft: 1 }}>
|
||||
{isPasswordVisible ? <LockOpen /> : <Lock />}
|
||||
</IconButton>
|
||||
</div>
|
||||
),
|
||||
renderCell: (params: GridRenderCellParams<Server>) =>
|
||||
isPasswordVisible ? params.value : "••••••••",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
const handleEdit = (row: GridRowModel<Server>) => {
|
||||
setSelectedRow(row);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleUpdateRow = (updatedRow: Server) => {
|
||||
setupData.setServers(updatedRow);
|
||||
updateServers(updatedRow);
|
||||
};
|
||||
const updateServers = (updatedServer: Server) => {
|
||||
setServers((prevServers) => {
|
||||
if (prevServers == null) return null;
|
||||
return prevServers.map((server) => (server.id === updatedServer.id ? updatedServer : server))
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Box ref={gridContainerRef} sx={{
|
||||
position: 'relative', left: 0, right: 0, height: "calc(100vh - 124px)", overflow: "hidden",
|
||||
transition: theme.transitions.create(['width', 'height'], {
|
||||
easing: theme.transitions.easing.easeInOut,
|
||||
duration: theme.transitions.duration.standard,
|
||||
})
|
||||
}}>
|
||||
<Box sx={{ position: 'absolute', inset: 0 }}>
|
||||
{isMobile ? (
|
||||
<List>
|
||||
{ (!isPasswordVisible ? setupData.servers : servers)?.map((row) => (
|
||||
<Card key={row.id} sx={{ marginBottom: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6">{row.name}</Typography>
|
||||
<Typography variant="body2">Name: {row.name}</Typography>
|
||||
<Typography variant="body2">Server Name: {row.serverName}</Typography>
|
||||
<Typography variant="body2">Port: {row.port}</Typography>
|
||||
<Typography variant="body2">Username: {row.username}</Typography>
|
||||
<Typography variant="body2">Password: {row.password}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<DataGrid
|
||||
rows={(!isPasswordVisible ? setupData.servers : servers)!}
|
||||
columns={columns}
|
||||
autoPageSize
|
||||
sx={{ minWidth: "600px" }}
|
||||
//getRowId={(row) => row.id!}//Set this if object doesn't have id as unique id (like serverKey etc)
|
||||
slots={{
|
||||
toolbar: () => (
|
||||
<GridToolbarContainer sx={{ display: "flex", alignItems: "center" }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => setupData.reloadServers()} // Refresh only active servers
|
||||
sx={{ marginRight: 2 }}
|
||||
>
|
||||
{setupData.serversLoading ? <CircularProgress size={24} color="inherit" /> : "Refresh"}
|
||||
</Button>
|
||||
<GridToolbarColumnsButton />
|
||||
<GridToolbarDensitySelector />
|
||||
<GridToolbarExport />
|
||||
<GridToolbarQuickFilter sx={{ ml: "auto" }} /> {/* Keeps the search filter in the toolbar */}
|
||||
</GridToolbarContainer>
|
||||
),
|
||||
}}
|
||||
slotProps={{
|
||||
toolbar: {
|
||||
showQuickFilter: true,
|
||||
quickFilterProps: { /*debounceMs: 500/*Optional: Adds debounce to search*/ },
|
||||
},
|
||||
}}
|
||||
initialState={{
|
||||
pagination: {
|
||||
paginationModel: {
|
||||
pageSize: 20,
|
||||
},
|
||||
},
|
||||
}}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Server Edit Modal */}
|
||||
{selectedRow && (
|
||||
<ServerEdit
|
||||
open={open}
|
||||
server={selectedRow}
|
||||
onClose={() => setOpen(false)}
|
||||
onSave={handleUpdateRow}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Servers;
|
||||
@ -4,7 +4,7 @@ import { useSetupData, SetupData } from "@/context/SetupDataContext";
|
||||
import { List, Card, CardContent, Typography, Box, useTheme, useMediaQuery, Button, CircularProgress } from '@mui/material';
|
||||
import { DataGrid, GridColDef, GridRenderCellParams, GridRowModel, GridToolbarContainer, GridToolbarQuickFilter, GridToolbarExport, GridToolbarDensitySelector, GridToolbarColumnsButton } from '@mui/x-data-grid';
|
||||
//import utils from '@/ts/utils';
|
||||
import { Target } from '@/types/target';
|
||||
import Target from '@/types/target';
|
||||
import TargetEdit from "@/components/modals/TargetEdit";
|
||||
|
||||
|
||||
@ -67,6 +67,10 @@ function Targets() {
|
||||
// if (hadError)
|
||||
// alert(hadErrorMessage); //TODO: Make this look better. MUI Alert popup?
|
||||
//}
|
||||
const handleNew = () => {
|
||||
setSelectedRow(null);
|
||||
setOpen(true);
|
||||
};
|
||||
const handleEdit = (row: GridRowModel<Target>) => {
|
||||
setSelectedRow(row);
|
||||
setOpen(true);
|
||||
@ -114,7 +118,15 @@ function Targets() {
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => setupData.reloadTargets()} // Refresh only active targets
|
||||
onClick={() => handleNew()}
|
||||
sx={{ marginRight: 2 }}
|
||||
>
|
||||
{setupData.targetsLoading ? <CircularProgress size={24} color="inherit" /> : "Add New"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => setupData.reloadTargets()}
|
||||
sx={{ marginRight: 2 }}
|
||||
>
|
||||
{setupData.targetsLoading ? <CircularProgress size={24} color="inherit" /> : "Refresh"}
|
||||
@ -145,7 +157,7 @@ function Targets() {
|
||||
</Box>
|
||||
|
||||
{/* Target Edit Modal */}
|
||||
{selectedRow && (
|
||||
{open && (
|
||||
<TargetEdit
|
||||
open={open}
|
||||
target={selectedRow}
|
||||
|
||||
@ -8,6 +8,7 @@ export type SetupData = {
|
||||
setTargets: (updatedTarget: Target) => void;
|
||||
servers: Server[];
|
||||
reloadServers: () => void;
|
||||
setServers: (updatedServer: Server) => void;
|
||||
reloadSetupData: () => void;
|
||||
targetsLoading: boolean;
|
||||
serversLoading: boolean;
|
||||
@ -46,10 +47,9 @@ export const SetupDataProvider = ({ children }: { children: React.ReactNode }) =
|
||||
const targetsData = await targetsResponse.json();
|
||||
setTargets(targetsData);
|
||||
setTargetsLoading(false);
|
||||
//const serversResponse = await fetch("/api/setup/servers"); //TODO: call once setup
|
||||
//const serversData = await serversResponse.json();
|
||||
const serversResponse = await fetch("/api/servers/GetAll?activeOnly=false&returnPassword=false");
|
||||
const serversData = await serversResponse.json();
|
||||
|
||||
const serversData: Server[] = [];
|
||||
setServers(serversData);
|
||||
setServersLoading(false);
|
||||
setDataLoading(false);
|
||||
@ -63,8 +63,20 @@ export const SetupDataProvider = ({ children }: { children: React.ReactNode }) =
|
||||
};
|
||||
|
||||
const updateTargetCache = (updatedTarget: Target) => {
|
||||
setTargets((prevTargets) =>
|
||||
prevTargets.map((target) => (target.id === updatedTarget.id ? updatedTarget : target))
|
||||
setTargets((prevTargets) => {
|
||||
const targetExists = prevTargets.some((target) => target.id === updatedTarget.id);
|
||||
return targetExists
|
||||
? prevTargets.map((target) => (target.id === updatedTarget.id ? updatedTarget : target))
|
||||
: [...prevTargets, updatedTarget]; // Push new target if not found
|
||||
});
|
||||
|
||||
sessionStorage.setItem("setupData", JSON.stringify({ servers, targets }));
|
||||
};
|
||||
|
||||
const updateServerCache = (updatedServer: Server) => {
|
||||
updatedServer.password = "";
|
||||
setServers((prevServers) =>
|
||||
prevServers.map((server) => (server.id === updatedServer.id ? updatedServer : server))
|
||||
);
|
||||
sessionStorage.setItem("setupData", JSON.stringify({ servers, targets }));
|
||||
};
|
||||
@ -73,7 +85,7 @@ export const SetupDataProvider = ({ children }: { children: React.ReactNode }) =
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SetupDataContext.Provider value={{ targets, reloadTargets: reloadSetupData, setTargets: updateTargetCache, servers, reloadServers: reloadSetupData, reloadSetupData: reloadSetupData, targetsLoading, serversLoading, dataLoading }}>
|
||||
<SetupDataContext.Provider value={{ targets, reloadTargets: reloadSetupData, setTargets: updateTargetCache, servers, reloadServers: reloadSetupData, setServers: updateServerCache, reloadSetupData: reloadSetupData, targetsLoading, serversLoading, dataLoading }}>
|
||||
{children}
|
||||
</SetupDataContext.Provider>
|
||||
);
|
||||
|
||||
26
Surge365.MassEmailReact.Web/src/context/TitleContext.tsx
Normal file
26
Surge365.MassEmailReact.Web/src/context/TitleContext.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { createContext, useContext, useState, ReactNode } from "react";
|
||||
|
||||
interface TitleContextType {
|
||||
title: string;
|
||||
setTitle: (title: string) => void;
|
||||
}
|
||||
|
||||
const TitleContext = createContext<TitleContextType | undefined>(undefined);
|
||||
|
||||
export const TitleProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [title, setTitle] = useState<string>("Default Title");
|
||||
|
||||
return (
|
||||
<TitleContext.Provider value={{ title, setTitle }}>
|
||||
{children}
|
||||
</TitleContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTitle = (): TitleContextType => {
|
||||
const context = useContext(TitleContext);
|
||||
if (!context) {
|
||||
throw new Error("useTitle must be used within a TitleProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@ -1,5 +1,10 @@
|
||||
export interface Server {
|
||||
id?: number;
|
||||
name: string;
|
||||
serverName: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export default Server;
|
||||
@ -9,3 +9,4 @@ export interface Target {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export default Target;
|
||||
Loading…
x
Reference in New Issue
Block a user