Init repo

This commit is contained in:
Stedoss
2026-07-27 19:21:52 +01:00
commit bb6a1159ec
60 changed files with 8277 additions and 0 deletions
+15
View File
@@ -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>
+34
View File
@@ -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
+5
View File
@@ -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,
},
},
]);
+13
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+55
View File
@@ -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>;
}
+29
View File
@@ -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;
+1
View File
@@ -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"]
}
+7
View File
@@ -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"]
}
+30
View File
@@ -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'],
},
});