Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion service/src/Quarter.Core/Commands/CommandHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ await repositoryFactory.UserRepository()

private async Task ExecuteAsync(AddProjectCommand command, OperationContext oc, CancellationToken ct)
{
var project = new Project(command.Name, command.Description);
var project = new Project(command.Name, command.Description, command.Color);
await repositoryFactory.ProjectRepository(oc.UserId).CreateAsync(project, ct);
}

Expand All @@ -79,6 +79,8 @@ await repositoryFactory.ProjectRepository(oc.UserId).UpdateByIdAsync(command.Pro
current.Name = command.Name;
if (command.Description is not null)
current.Description = command.Description;
if (command.Color is not null)
current.Color = command.Color;

return current;
}, ct);
Expand Down
4 changes: 2 additions & 2 deletions service/src/Quarter.Core/Commands/ProjectCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@

namespace Quarter.Core.Commands;

public record AddProjectCommand(string Name, string Description) : ICommand;
public record EditProjectCommand(IdOf<Project> ProjectId, string? Name, string? Description) : ICommand;
public record AddProjectCommand(string Name, string Description, Color Color) : ICommand;
public record EditProjectCommand(IdOf<Project> ProjectId, string? Name, string? Description, Color? Color) : ICommand;
public record RemoveProjectCommand(IdOf<Project> ProjectId) : ICommand;
public record ArchiveProjectCommand(IdOf<Project> ProjectId) : ICommand;
public record RestoreProjectCommand(IdOf<Project> ProjectId) : ICommand;
Expand Down
19 changes: 17 additions & 2 deletions service/src/Quarter.Core/Models/Project.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,34 @@ namespace Quarter.Core.Models;

public class Project : IAggregate<Project>
{
/// <summary>
/// Default color used for projects created before the color feature was added.
/// </summary>
public static readonly Color DefaultColor = Color.FromHexString("#457b9d");

[JsonConverter(typeof(IdOfJsonConverter<Project>))]
public IdOf<Project> Id { get; set; }
public UtcDateTime Created { get; set; }
public UtcDateTime? Updated { get; set; }
public string Name { get; set; }
public string Description { get; set; }

private Color? _color;
public Color Color
{
get => _color ?? DefaultColor;
set => _color = value;
}

public bool IsArchived { get; set; }

public Project(string name, string description)
public Project(string name, string description, Color color)
{
Id = IdOf<Project>.Random();
Created = UtcDateTime.Now();
Name = name;
Description = description;
Color = color;
}

#pragma warning disable CS8618
Expand All @@ -46,9 +60,10 @@ public override bool Equals(object? obj)
Description == other.Description &&
Created.DateTime == other.Created.DateTime &&
Updated?.DateTime == other.Updated?.DateTime &&
Color.Equals(other.Color) &&
IsArchived == other.IsArchived;
}

public override int GetHashCode()
=> HashCode.Combine(Id, Name, Description, Created, Updated, IsArchived);
=> HashCode.Combine(Id, Name, Description, Created, Updated, Color, IsArchived);
}
3 changes: 2 additions & 1 deletion service/src/Quarter.Core/Repositories/ProjectRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Threading.Tasks;
using Npgsql;
using Quarter.Core.Models;
using Quarter.Core.Utils;

namespace Quarter.Core.Repositories;

Expand All @@ -16,7 +17,7 @@ public static class ProjectRepository
{
public static Task<Project> CreateSandboxProjectAsync(IProjectRepository self, CancellationToken ct)
{
var project = new Project("Your first project", "A project is used to group activities.");
var project = new Project("Your first project", "A project is used to group activities.", Color.FromHexString("#457b9d"));
return self.CreateAsync(project, ct);
}
}
Expand Down
12 changes: 10 additions & 2 deletions service/src/Quarter.HttpApi/Resources/ProjectResource.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using Quarter.Core.Models;
using Quarter.Core.Utils;

namespace Quarter.HttpApi.Resources;

Expand All @@ -11,17 +12,18 @@ namespace Quarter.HttpApi.Resources;
/// <param name="id">The ID of the project</param>
/// <param name="name">The name of the project</param>
/// <param name="description">The project description</param>
/// <param name="color">The project color in CSS HEX</param>
/// <param name="isArchived">Whether or not the project is archived</param>
/// <param name="created">Timestamp for when the project was created (ISO-8601)</param>
/// <param name="updated">Timestamp for when the project was last updated, or null if it has never been updated (ISO-8601)</param>
public record ProjectResourceOutput(string id, string name, string description, bool isArchived, string created, string? updated)
public record ProjectResourceOutput(string id, string name, string description, string color, bool isArchived, string created, string? updated)
{
public Uri Location()
=> new($"/api/projects/{id}", UriKind.Relative);

public static ProjectResourceOutput From(Project project)
{
return new ProjectResourceOutput(project.Id.AsString(), project.Name, project.Description, project.IsArchived, project.Created.IsoString(), project.Updated?.IsoString());
return new ProjectResourceOutput(project.Id.AsString(), project.Name, project.Description, project.Color.ToHex(), project.IsArchived, project.Created.IsoString(), project.Updated?.IsoString());
}
}

Expand All @@ -31,6 +33,9 @@ public class CreateProjectResourceInput
public string? name { get; set; }

public string? description { get; set; }

[RegularExpression("^#([0-9a-fA-F]{3}){1,2}$", ErrorMessage = "The color field is invalid, must be a HEX value (e.g. #04a85b).")]
public string? color { get; set; }
}

public class UpdateProjectResourceInput
Expand All @@ -39,5 +44,8 @@ public class UpdateProjectResourceInput

public string? description { get; set; }

[RegularExpression("^#([0-9a-fA-F]{3}){1,2}$", ErrorMessage = "The color field is invalid, must be a HEX value (e.g. #04a85b).")]
public string? color { get; set; }

public bool? isArchived { get; set; }
}
4 changes: 3 additions & 1 deletion service/src/Quarter.HttpApi/Services/ApiService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ public IAsyncEnumerable<ProjectResourceOutput> ProjectsForUserAsync(OperationCon

public async Task<ProjectResourceOutput> CreateProjectAsync(CreateProjectResourceInput input, OperationContext oc, CancellationToken ct)
{
var project = new Project(input.name!, input.description!);
var color = input.color is not null ? Color.FromHexString(input.color) : Project.DefaultColor;
var project = new Project(input.name!, input.description!, color);
project = await repositoryFactory.ProjectRepository(oc.UserId).CreateAsync(project, ct);
return ProjectResourceOutput.From(project);
}
Expand All @@ -54,6 +55,7 @@ public async Task<ProjectResourceOutput> UpdateProjectAsync(IdOf<Project> projec
{
if (input.name is not null) existing.Name = input.name;
if (input.description is not null) existing.Description = input.description;
if (input.color is not null) existing.Color = Color.FromHexString(input.color);
if (input.isArchived is {} isArchived) existing.IsArchived = isArchived;
return existing;
}, ct);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Threading.Tasks;
using NUnit.Framework;
using Quarter.Core.Commands;
using Quarter.Core.Utils;

namespace Quarter.Core.UnitTest.Commands;

Expand All @@ -14,7 +15,7 @@ public class WhenAddingProject : AddProjectCommandTest
[OneTimeSetUp]
public async Task AddingProject()
{
var command = new AddProjectCommand("Sample project", "Something");
var command = new AddProjectCommand("Sample project", "Something", Color.FromHexString("#457b9d"));
await Handler.ExecuteAsync(command, OperationContext(), CancellationToken.None);
}

Expand All @@ -28,5 +29,15 @@ public async Task ItShouldHaveAddedTheProject()

Assert.That(projects, Is.EquivalentTo(new[] { "Sample project" }));
}

[Test]
public async Task ItShouldHavePersistedTheColor()
{
var projects = await RepositoryFactory.ProjectRepository(ActingUser)
.GetAllAsync(CancellationToken.None)
.ToListAsync();

Assert.That(projects.Single().Color, Is.EqualTo(Color.FromHexString("#457b9d")));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public class WhenProjectExist : RemoveProjectCommandTest
public async Task AddingInitialProject()
{
_projectRepository = RepositoryFactory.ProjectRepository(ActingUser);
_initialProject = await _projectRepository.CreateAsync(new Project("a", "a"), CancellationToken.None);
_initialProject = await _projectRepository.CreateAsync(new Project("a", "a", Color.FromHexString("#457b9d")), CancellationToken.None);
var command = new ArchiveProjectCommand(_initialProject.Id);

await Handler.ExecuteAsync(command, OperationContext(), CancellationToken.None);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ protected OperationContext OperationContext()
protected Task<Project> CreateProjectAsync(string name)
{
var repo = RepositoryFactory.ProjectRepository(ActingUser);
return repo.CreateAsync(new Project(name, $"Description for {name}"), CancellationToken.None);
return repo.CreateAsync(new Project(name, $"Description for {name}", Color.FromHexString("#457b9d")), CancellationToken.None);
}

protected Task<Activity> CreateActivityAsync(IdOf<Project> projectId, string name)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Quarter.Core.Commands;
using Quarter.Core.Exceptions;
using Quarter.Core.Models;
using Quarter.Core.Utils;

namespace Quarter.Core.UnitTest.Commands;

Expand All @@ -16,7 +17,7 @@ public class WhenProjectDoesNotExist : EditProjectCommandTest
[Test]
public void ItShouldFail()
{
var command = new EditProjectCommand(IdOf<Project>.Random(), null, null);
var command = new EditProjectCommand(IdOf<Project>.Random(), null, null, null);
Assert.ThrowsAsync<NotFoundException>(() => Handler.ExecuteAsync(command, OperationContext(), CancellationToken.None));
}
}
Expand All @@ -42,9 +43,9 @@ public static IEnumerable<object[]> EditTests()
public async Task ItShouldHaveFieldValue(string name, string description, string expectedName, string expectedDescription)
{
var projectRepository = RepositoryFactory.ProjectRepository(ActingUser);
var initialProject = await projectRepository.CreateAsync(new Project("Initial name", "Initial description"),
var initialProject = await projectRepository.CreateAsync(new Project("Initial name", "Initial description", Color.FromHexString("#457b9d")),
CancellationToken.None);
var command = new EditProjectCommand(initialProject.Id, name, description);
var command = new EditProjectCommand(initialProject.Id, name, description, null);
await Handler.ExecuteAsync(command, OperationContext(), CancellationToken.None);

var readProject = await projectRepository.GetByIdAsync(initialProject.Id, CancellationToken.None);
Expand All @@ -56,4 +57,37 @@ public async Task ItShouldHaveFieldValue(string name, string description, string
});
}
}

public class WhenEditingProjectColor : EditProjectCommandTest
{
[Test]
public async Task ItShouldUpdateColorWhenProvided()
{
var projectRepository = RepositoryFactory.ProjectRepository(ActingUser);
var initialProject = await projectRepository.CreateAsync(
new Project("Name", "Desc", Color.FromHexString("#457b9d")), CancellationToken.None);

var newColor = Color.FromHexString("#e63946");
var command = new EditProjectCommand(initialProject.Id, null, null, newColor);
await Handler.ExecuteAsync(command, OperationContext(), CancellationToken.None);

var readProject = await projectRepository.GetByIdAsync(initialProject.Id, CancellationToken.None);
Assert.That(readProject.Color, Is.EqualTo(newColor));
}

[Test]
public async Task ItShouldNotChangeColorWhenNull()
{
var projectRepository = RepositoryFactory.ProjectRepository(ActingUser);
var originalColor = Color.FromHexString("#457b9d");
var initialProject = await projectRepository.CreateAsync(
new Project("Name", "Desc", originalColor), CancellationToken.None);

var command = new EditProjectCommand(initialProject.Id, "New name", null, null);
await Handler.ExecuteAsync(command, OperationContext(), CancellationToken.None);

var readProject = await projectRepository.GetByIdAsync(initialProject.Id, CancellationToken.None);
Assert.That(readProject.Color, Is.EqualTo(originalColor));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public class WhenProjectExist : RemoveProjectCommandTest
public async Task AddingInitialProject()
{
_projectRepository = RepositoryFactory.ProjectRepository(ActingUser);
_initialProject = await _projectRepository.CreateAsync(new Project("a", "a"), CancellationToken.None);
_initialProject = await _projectRepository.CreateAsync(new Project("a", "a", Color.FromHexString("#457b9d")), CancellationToken.None);
var command = new RemoveProjectCommand(_initialProject.Id);

await Handler.ExecuteAsync(command, OperationContext(), CancellationToken.None);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Quarter.Core.Exceptions;
using Quarter.Core.Models;
using Quarter.Core.Repositories;
using Quarter.Core.Utils;

namespace Quarter.Core.UnitTest.Commands;

Expand All @@ -30,7 +31,7 @@ public class WhenProjectExist : RemoveProjectCommandTest
public async Task AddingInitialProject()
{
_projectRepository = RepositoryFactory.ProjectRepository(ActingUser);
var project = new Project("a", "a");
var project = new Project("a", "a", Color.FromHexString("#457b9d"));
project.Archive();
_initialProject = await _projectRepository.CreateAsync(project, CancellationToken.None);
var command = new RestoreProjectCommand(_initialProject.Id);
Expand Down
11 changes: 8 additions & 3 deletions service/test/unit/Quarter.Core.UnitTest/Models/ProjectTest.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using NUnit.Framework;
using Quarter.Core.Models;
using Quarter.Core.Utils;

namespace Quarter.Core.UnitTest.Models
{
Expand All @@ -8,7 +9,7 @@ public class ProjectTest
{
public class WhenConstructed
{
private readonly Project _project = new Project("Alpha", "Alpha:Description");
private readonly Project _project = new Project("Alpha", "Alpha:Description", Color.FromHexString("#457b9d"));

[Test]
public void ItShouldHaveName()
Expand All @@ -18,6 +19,10 @@ public void ItShouldHaveName()
public void ItShouldHaveDescription()
=> Assert.That(_project.Description, Is.EqualTo("Alpha:Description"));

[Test]
public void ItShouldHaveColor()
=> Assert.That(_project.Color, Is.EqualTo(Color.FromHexString("#457b9d")));

[Test]
public void ItShouldGetAnIdAssigned()
=> Assert.That(_project.Id, Is.Not.Null);
Expand All @@ -37,7 +42,7 @@ public void ItShouldNotBeArchived()

public class WhenArchived
{
private readonly Project _project = new Project("Alpha", "Alpha:Description");
private readonly Project _project = new Project("Alpha", "Alpha:Description", Color.FromHexString("#457b9d"));

[OneTimeSetUp]
public void WhenArchivedSetUp()
Expand All @@ -52,7 +57,7 @@ public void ItShouldBeArchived()

public class WhenRestored
{
private readonly Project _project = new Project("Alpha", "Alpha:Description");
private readonly Project _project = new Project("Alpha", "Alpha:Description", Color.FromHexString("#457b9d"));

[OneTimeSetUp]
public void WhenRestoredSetUp()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Quarter.Core.Models;
using Quarter.Core.Repositories;
using Quarter.Core.UnitTest.TestUtils;
using Quarter.Core.Utils;

namespace Quarter.Core.UnitTest.Repositories
{
Expand All @@ -17,7 +18,7 @@ protected override IdOf<Project> ArbitraryId()
protected override Project ArbitraryAggregate()
{
var id = Guid.NewGuid();
return new Project($"Name {id}", $"Description {id}");
return new Project($"Name {id}", $"Description {id}", Color.FromHexString("#457b9d"));
}

protected override Project WithoutTimestamps(Project aggregate)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ public async Task GetByDateShouldReturnTimesheetForUserOnly()
}

private static Project ArbitraryProject()
=> new Project("Arbitrary", "Arbitrary");
=> new Project("Arbitrary", "Arbitrary", Color.FromHexString("#457b9d"));

private static Activity ArbitraryActivity()
=> new Activity(IdOf<Project>.Random(), "Arbitrary", "Arbitrary", Color.FromHexString("#000"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public class WithValidInput
{
public static IEnumerable<object[]> ValidResources()
{
yield return [new CreateProjectResourceInput { name = "OK", description = "OK", color = "#457b9d" }];
yield return [new CreateProjectResourceInput { name = "OK", description = "OK" }];
}

Expand All @@ -36,8 +37,9 @@ public class WithInvalidInput
{
public static IEnumerable<object[]> InvalidResources()
{
yield return [new CreateProjectResourceInput { name = null! }, "The name field is required."];
yield return [new CreateProjectResourceInput { name = "" }, "The name field is required."];
yield return [new CreateProjectResourceInput { name = null!, color = "#457b9d" }, "The name field is required."];
yield return [new CreateProjectResourceInput { name = "", color = "#457b9d" }, "The name field is required."];
yield return [new CreateProjectResourceInput { name = "OK", color = "invalid" }, "The color field is invalid, must be a HEX value (e.g. #04a85b)."];
}

[TestCaseSource(nameof(InvalidResources))]
Expand Down
Loading