Init repo
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace MissionControl.Data;
|
||||
|
||||
public static class Composition
|
||||
{
|
||||
public static IServiceCollection AddMissionControlData(
|
||||
this IServiceCollection services,
|
||||
string connectionString
|
||||
) =>
|
||||
services.AddDbContext<MissionControlContext>(options =>
|
||||
options.UseSqlServer(connectionString, sql => sql.EnableRetryOnFailure())
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MissionControl.Domain.Entities;
|
||||
|
||||
namespace MissionControl.Data;
|
||||
|
||||
public static class DevelopmentDatabase
|
||||
{
|
||||
public static async Task MigrateAndSeedAsync(
|
||||
IServiceProvider services,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<MissionControlContext>();
|
||||
|
||||
await context.Database.MigrateAsync(ct);
|
||||
|
||||
if (await context.Missions.AnyAsync(ct))
|
||||
return;
|
||||
|
||||
context.Missions.AddRange(
|
||||
new Mission
|
||||
{
|
||||
Name = "Artemis Echo",
|
||||
Description = "Resupply run to the lunar gateway station.",
|
||||
Status = MissionStatus.Active,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-40),
|
||||
},
|
||||
new Mission
|
||||
{
|
||||
Name = "Kuiper Courier",
|
||||
Description = "Sample return from the outer edge of the Kuiper belt.",
|
||||
Status = MissionStatus.Active,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-31),
|
||||
},
|
||||
new Mission
|
||||
{
|
||||
Name = "Red Horizon",
|
||||
Description = "First crewed survey of the Valles Marineris canyon system.",
|
||||
Status = MissionStatus.Planned,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-12),
|
||||
},
|
||||
new Mission
|
||||
{
|
||||
Name = "Solar Sailor",
|
||||
Description = "Prototype solar sail shakedown cruise around Venus.",
|
||||
Status = MissionStatus.Planned,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-3),
|
||||
},
|
||||
new Mission
|
||||
{
|
||||
Name = "Juno Reborn",
|
||||
Description = "Recover and reboot a decommissioned Jupiter probe.",
|
||||
Status = MissionStatus.Completed,
|
||||
CreatedAt = DateTimeOffset.UtcNow.AddDays(-90),
|
||||
}
|
||||
);
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using MissionControl.Domain.Entities;
|
||||
|
||||
namespace MissionControl.Data.EntityConfigurations;
|
||||
|
||||
public class MissionConfiguration : IEntityTypeConfiguration<Mission>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Mission> builder)
|
||||
{
|
||||
builder.Property(m => m.Name).HasMaxLength(100);
|
||||
builder.Property(m => m.Description).HasMaxLength(500);
|
||||
builder.Property(m => m.Status).HasConversion<string>().HasMaxLength(20);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using MissionControl.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MissionControl.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(MissionControlContext))]
|
||||
[Migration("20260721084606_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.10")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("MissionControl.Domain.Entities.Mission", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Missions");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MissionControl.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Missions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
Name = table.Column<string>(
|
||||
type: "nvarchar(100)",
|
||||
maxLength: 100,
|
||||
nullable: false
|
||||
),
|
||||
Description = table.Column<string>(
|
||||
type: "nvarchar(500)",
|
||||
maxLength: 500,
|
||||
nullable: true
|
||||
),
|
||||
Status = table.Column<string>(
|
||||
type: "nvarchar(20)",
|
||||
maxLength: 20,
|
||||
nullable: false
|
||||
),
|
||||
CreatedAt = table.Column<DateTimeOffset>(
|
||||
type: "datetimeoffset",
|
||||
nullable: false
|
||||
),
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Missions", x => x.Id);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(name: "Missions");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using MissionControl.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MissionControl.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(MissionControlContext))]
|
||||
partial class MissionControlContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.10")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("MissionControl.Domain.Entities.Mission", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Missions");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.10" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MissionControl.Domain\MissionControl.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,13 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MissionControl.Domain.Entities;
|
||||
|
||||
namespace MissionControl.Data;
|
||||
|
||||
public class MissionControlContext(DbContextOptions<MissionControlContext> options)
|
||||
: DbContext(options)
|
||||
{
|
||||
public DbSet<Mission> Missions => Set<Mission>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(MissionControlContext).Assembly);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace MissionControl.Domain.Entities;
|
||||
|
||||
public class Mission
|
||||
{
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
|
||||
public required string Name { get; set; }
|
||||
|
||||
public string? Description { get; set; }
|
||||
|
||||
public MissionStatus Status { get; set; } = MissionStatus.Planned;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace MissionControl.Domain.Entities;
|
||||
|
||||
public enum MissionStatus
|
||||
{
|
||||
Planned,
|
||||
Active,
|
||||
Completed,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk"></Project>
|
||||
@@ -0,0 +1,26 @@
|
||||
using MissionControl.Domain.Entities;
|
||||
|
||||
namespace MissionControl.Server.Endpoints.Missions;
|
||||
|
||||
public record MissionResponse
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
public required string Name { get; init; }
|
||||
|
||||
public string? Description { get; init; }
|
||||
|
||||
public required MissionStatus Status { get; init; }
|
||||
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
public static MissionResponse FromDomain(Mission mission) =>
|
||||
new()
|
||||
{
|
||||
Id = mission.Id,
|
||||
Name = mission.Name,
|
||||
Description = mission.Description,
|
||||
Status = mission.Status,
|
||||
CreatedAt = mission.CreatedAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using FastEndpoints;
|
||||
using FluentValidation;
|
||||
using MissionControl.Data;
|
||||
using MissionControl.Domain.Entities;
|
||||
|
||||
namespace MissionControl.Server.Endpoints.Missions;
|
||||
|
||||
public class CreateMission(MissionControlContext dbContext)
|
||||
: Endpoint<CreateMissionRequest, MissionResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/missions");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CreateMissionRequest req, CancellationToken ct)
|
||||
{
|
||||
var mission = new Mission { Name = req.Name, Description = req.Description };
|
||||
|
||||
dbContext.Missions.Add(mission);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await Send.OkAsync(MissionResponse.FromDomain(mission), ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateMissionRequest
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
public class CreateMissionRequestValidator : Validator<CreateMissionRequest>
|
||||
{
|
||||
public CreateMissionRequestValidator()
|
||||
{
|
||||
RuleFor(r => r.Name).NotEmpty().MaximumLength(100);
|
||||
RuleFor(r => r.Description).MaximumLength(500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MissionControl.Data;
|
||||
|
||||
namespace MissionControl.Server.Endpoints.Missions;
|
||||
|
||||
public class ListMissions(MissionControlContext dbContext)
|
||||
: EndpointWithoutRequest<ListMissionsResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/missions");
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var missions = await dbContext
|
||||
.Missions.AsNoTracking()
|
||||
.OrderByDescending(m => m.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var response = new ListMissionsResponse
|
||||
{
|
||||
Missions = missions.Select(MissionResponse.FromDomain).ToList(),
|
||||
};
|
||||
|
||||
await Send.OkAsync(response, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record ListMissionsResponse
|
||||
{
|
||||
public required IReadOnlyList<MissionResponse> Missions { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<SpaRoot>..\mission-control-client</SpaRoot>
|
||||
<SpaProxyLaunchCommand>npm run dev</SpaProxyLaunchCommand>
|
||||
<SpaProxyServerUrl>http://localhost:5173</SpaProxyServerUrl>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FastEndpoints" Version="8.2.0" />
|
||||
<PackageReference Include="FastEndpoints.Swagger" Version="8.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SpaProxy" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MissionControl.Domain\MissionControl.Domain.csproj" />
|
||||
<ProjectReference Include="..\MissionControl.Data\MissionControl.Data.csproj" />
|
||||
<ProjectReference Include="..\mission-control-client\mission-control-client.esproj">
|
||||
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using FastEndpoints;
|
||||
using FastEndpoints.Swagger;
|
||||
using MissionControl.Data;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddMissionControlData(
|
||||
builder.Configuration.GetConnectionString("MissionControl")
|
||||
?? throw new InvalidOperationException("Missing connection string 'MissionControl'")
|
||||
);
|
||||
|
||||
builder.Services.AddFastEndpoints();
|
||||
builder.Services.SwaggerDocument(options => options.ShortSchemaNames = true);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseDefaultExceptionHandler(useGenericReason: !app.Environment.IsDevelopment());
|
||||
app.UseFastEndpoints(config =>
|
||||
{
|
||||
config.Endpoints.RoutePrefix = "api";
|
||||
config.Endpoints.ShortNames = true;
|
||||
// The exercise ships without authentication, so every endpoint is anonymous.
|
||||
config.Endpoints.Configurator = endpoint => endpoint.AllowAnonymous();
|
||||
config.Serializer.Options.Converters.Add(new JsonStringEnumConverter());
|
||||
});
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwaggerGen();
|
||||
await DevelopmentDatabase.MigrateAndSeedAsync(app.Services);
|
||||
}
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"MissionControl.Server": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5010",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "Microsoft.AspNetCore.SpaProxy"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"MissionControl": "Server=localhost,1433;Database=MissionControl;User Id=sa;Password=MissionControl!23;TrustServerCertificate=True"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
src/routeTree.gen.ts
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"plugins": ["@ianvs/prettier-plugin-sort-imports"],
|
||||
"importOrderTypeScriptVersion": "5.9.3",
|
||||
"singleQuote": true
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist', 'src/routeTree.gen.ts']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/rocket.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Mission Control</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.VisualStudio.JavaScript.Sdk/1.0.784122">
|
||||
<PropertyGroup>
|
||||
<StartupCommand>npm run dev</StartupCommand>
|
||||
<!-- Don't run npm install / npm run build as part of the .NET build -->
|
||||
<ShouldRunBuildScript>false</ShouldRunBuildScript>
|
||||
<ShouldRunNpmInstall>false</ShouldRunNpmInstall>
|
||||
<BuildOutputFolder>$(MSBuildProjectDirectory)\dist</BuildOutputFolder>
|
||||
</PropertyGroup>
|
||||
<!-- Targets needed to avoid MSB4057 -->
|
||||
<!-- https://github.com/dotnet/sdk/issues/44587 -->
|
||||
<!-- https://github.com/dotnet/sdk/issues/45760 -->
|
||||
<Target Name="_GetRequiredWorkloads" />
|
||||
<Target Name="pack" />
|
||||
</Project>
|
||||
+6112
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "mission-control-client",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"format": "prettier . --write",
|
||||
"format:check": "prettier . --check",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"tsc": "tsc -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^8.3.18",
|
||||
"@mantine/form": "^8.3.18",
|
||||
"@mantine/hooks": "^8.3.18",
|
||||
"@mantine/notifications": "^8.3.18",
|
||||
"@tabler/icons-react": "^3.45.0",
|
||||
"@tanstack/react-query": "^5.101.3",
|
||||
"@tanstack/react-router": "^1.170.18",
|
||||
"axios": "^1.18.1",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.5",
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
|
||||
"@tanstack/react-query-devtools": "^5.101.3",
|
||||
"@tanstack/react-router-devtools": "^1.167.0",
|
||||
"@tanstack/router-plugin": "^1.168.23",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-plugin-react-hooks": "^6.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"postcss": "^8.5.20",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.9.6",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vite": "^7.3.6",
|
||||
"vitest": "^3.2.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'postcss-preset-mantine': {},
|
||||
'postcss-simple-vars': {
|
||||
variables: {
|
||||
'mantine-breakpoint-xs': '36em',
|
||||
'mantine-breakpoint-sm': '48em',
|
||||
'mantine-breakpoint-md': '62em',
|
||||
'mantine-breakpoint-lg': '75em',
|
||||
'mantine-breakpoint-xl': '88em',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🚀</text></svg>
|
||||
|
After Width: | Height: | Size: 110 B |
@@ -0,0 +1,3 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export const apiClient = axios.create({ baseURL: '/api' });
|
||||
@@ -0,0 +1,32 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
export type MissionStatus = 'Planned' | 'Active' | 'Completed';
|
||||
|
||||
export type Mission = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: MissionStatus;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type CreateMissionRequest = {
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type ListMissionsResponse = {
|
||||
missions: Mission[];
|
||||
};
|
||||
|
||||
export async function listMissions(): Promise<Mission[]> {
|
||||
const response = await apiClient.get<ListMissionsResponse>('/missions');
|
||||
return response.data.missions;
|
||||
}
|
||||
|
||||
export async function createMission(
|
||||
request: CreateMissionRequest,
|
||||
): Promise<Mission> {
|
||||
const response = await apiClient.post<Mission>('/missions', request);
|
||||
return response.data;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MissionStatusBadge } from './MissionStatusBadge';
|
||||
|
||||
describe('MissionStatusBadge', () => {
|
||||
it('shows the status label', () => {
|
||||
render(
|
||||
<MantineProvider>
|
||||
<MissionStatusBadge status="Active" />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Active')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Badge } from '@mantine/core';
|
||||
import type { MissionStatus } from '../api/missions';
|
||||
|
||||
const statusColors: Record<MissionStatus, string> = {
|
||||
Planned: 'blue',
|
||||
Active: 'green',
|
||||
Completed: 'gray',
|
||||
};
|
||||
|
||||
export function MissionStatusBadge({ status }: { status: MissionStatus }) {
|
||||
return <Badge color={statusColors[status]}>{status}</Badge>;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import '@mantine/core/styles.css';
|
||||
import '@mantine/notifications/styles.css';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { Notifications } from '@mantine/notifications';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { createRouter, RouterProvider } from '@tanstack/react-router';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { queryClient } from './queryClient';
|
||||
import { routeTree } from './routeTree.gen';
|
||||
|
||||
const router = createRouter({ routeTree });
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<MantineProvider>
|
||||
<Notifications />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</MantineProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { isAxiosError } from 'axios';
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: (failureCount, error) =>
|
||||
isAxiosError(error) &&
|
||||
error.response?.status.toString().startsWith('4') !== true &&
|
||||
failureCount <= 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as MissionsRouteImport } from './routes/missions'
|
||||
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const MissionsRoute = MissionsRouteImport.update({
|
||||
id: '/missions',
|
||||
path: '/missions',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/missions': typeof MissionsRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/missions': typeof MissionsRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/missions': typeof MissionsRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/missions'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/missions'
|
||||
id: '__root__' | '/' | '/missions'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
MissionsRoute: typeof MissionsRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/missions': {
|
||||
id: '/missions'
|
||||
path: '/missions'
|
||||
fullPath: '/missions'
|
||||
preLoaderRoute: typeof MissionsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
MissionsRoute: MissionsRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Anchor, AppShell, Group, Title } from '@mantine/core';
|
||||
import { IconRocket } from '@tabler/icons-react';
|
||||
import { createRootRoute, Link, Outlet } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: () => (
|
||||
<AppShell header={{ height: 60 }} padding="md">
|
||||
<AppShell.Header>
|
||||
<Group h="100%" px="md">
|
||||
<Group gap="xs">
|
||||
<IconRocket />
|
||||
<Title order={3}>Mission Control</Title>
|
||||
</Group>
|
||||
<Group ml="xl" gap="md">
|
||||
<Anchor component={Link} to="/missions">
|
||||
Missions
|
||||
</Anchor>
|
||||
</Group>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
<AppShell.Main>
|
||||
<Outlet />
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
),
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: '/missions' });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { createMission, listMissions } from '../api/missions';
|
||||
import { MissionStatusBadge } from '../components/MissionStatusBadge';
|
||||
|
||||
export const Route = createFileRoute('/missions')({
|
||||
component: MissionsPage,
|
||||
});
|
||||
|
||||
function MissionsPage() {
|
||||
return (
|
||||
<Stack maw={900}>
|
||||
<Title order={2}>Missions</Title>
|
||||
<CreateMissionForm />
|
||||
<MissionsTable />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateMissionForm() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const form = useForm({
|
||||
mode: 'uncontrolled',
|
||||
initialValues: { name: '', description: '' },
|
||||
validate: {
|
||||
name: (value) => (value.trim().length === 0 ? 'Name is required' : null),
|
||||
},
|
||||
});
|
||||
|
||||
const createMissionMutation = useMutation({
|
||||
mutationFn: createMission,
|
||||
onSuccess: (mission) => {
|
||||
notifications.show({
|
||||
title: 'Mission created',
|
||||
message: `${mission.name} is on the board.`,
|
||||
color: 'green',
|
||||
});
|
||||
form.reset();
|
||||
return queryClient.invalidateQueries({ queryKey: ['missions'] });
|
||||
},
|
||||
onError: () =>
|
||||
notifications.show({
|
||||
title: 'Something went wrong',
|
||||
message: 'The mission could not be created.',
|
||||
color: 'red',
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<Paper withBorder p="md">
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) =>
|
||||
createMissionMutation.mutate({
|
||||
name: values.name,
|
||||
description: values.description || undefined,
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="Orbital Bloom"
|
||||
withAsterisk
|
||||
key={form.key('name')}
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<Textarea
|
||||
label="Description"
|
||||
placeholder="What is this mission setting out to do?"
|
||||
key={form.key('description')}
|
||||
{...form.getInputProps('description')}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button type="submit" loading={createMissionMutation.isPending}>
|
||||
Create mission
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function MissionsTable() {
|
||||
const missionsQuery = useQuery({
|
||||
queryKey: ['missions'],
|
||||
queryFn: listMissions,
|
||||
});
|
||||
|
||||
if (missionsQuery.isPending) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
if (missionsQuery.isError) {
|
||||
return (
|
||||
<Alert color="red" title="Could not load missions">
|
||||
Is the API running?
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Description</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{missionsQuery.data.map((mission) => (
|
||||
<Table.Tr key={mission.id}>
|
||||
<Table.Td>{mission.name}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{mission.description}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<MissionStatusBadge status={mission.status} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
// Mantine relies on browser APIs that jsdom does not implement.
|
||||
// https://mantine.dev/guides/vitest/
|
||||
const { getComputedStyle } = window;
|
||||
window.getComputedStyle = (elt) => getComputedStyle(elt);
|
||||
window.HTMLElement.prototype.scrollIntoView = () => {};
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
|
||||
class ResizeObserverMock {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
window.ResizeObserver = ResizeObserverMock;
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { env } from 'process';
|
||||
import { tanstackRouter } from '@tanstack/router-plugin/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
const apiBaseAddress = env.ASPNETCORE_URLS
|
||||
? env.ASPNETCORE_URLS.split(';')[0]
|
||||
: 'http://localhost:5010';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tanstackRouter({
|
||||
target: 'react',
|
||||
autoCodeSplitting: true,
|
||||
}),
|
||||
react(),
|
||||
],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': apiBaseAddress,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user