This course works on a program that you build yourself. It belongs to the invented firm Brandt Building Services (heating, plumbing, maintenance) and is called the Order Book. That way nobody has to download anything, and you know every place where something will break later, because you wrote it yourself. All people, firms and addresses are invented.
The Order Book is deliberately a typical grown program: a Windows Forms project on .NET Framework 4.8, data in a
DataSet, access with ADO.NET against SQL Server, settings in App.config, sign-in through Windows, invoice printing with
PrintDocument, an export to a file on the network drive and a browser control for the customer card. It is small enough
to write in one lesson, and it contains everything a modernization touches later. It is not well built, and that
is on purpose: It is built the way such programs come about.
You don't learn Windows Forms here. If you don't know it yet, read the code anyway: It is short, and the comments say what happens. The course explains forms only as far as a migration touches them.
House rules for all exercises
- The program lives in your own repository
Brandt.OrderBook. Every step of this lesson is a commit of its own with a title in Conventional Commits form, for examplefeat(data): database script and create script. - No secrets in Git. The connection string of this lesson uses the Windows sign-in and contains no password; that stays that way (lesson M5.3 cleans up the rest as well).
- Before every later step there is a safety net: tests or a copy of the database (lesson M2.1).
- Every Azure exercise ends with the teardown. This lesson runs entirely locally.
- What counts is the exit code, not the message "done".
What you need
- Windows and Visual Studio with the components for .NET desktop development. You build a project for .NET Framework 4.8.
- SQL Server LocalDB, the small developer edition of SQL Server. Microsoft's documentation gives the connection string
Server=(localdb)\MSSQLLocalDB;Integrated Security=truefor the automatic instance. A SQL Server in a container or on another machine works too; then you changeServer=inApp.config. sqlcmdfor the create script.
This is what the folder looks like at the end:
Brandt.OrderBook/
Brandt.OrderBook.sln
database.sql
scripts/
Create-Database.ps1
Brandt.OrderBook/
Brandt.OrderBook.csproj
packages.config
App.config
Program.cs
Settings.cs
Permissions.cs
OrderData.cs
MainForm.cs
CustomersForm.cs
MaintenanceForm.cs
InvoicePrint.cs
Exports.cs
Properties/AssemblyInfo.cs
Create the files in this order. In the code there are comments of the form BREAK B<n>: They mark the places that are worked on in the
next modules. At the end of the lesson you find them in a table.
Step 1: the database
Three tables: Customers, Orders and MaintenancePlan. Plus a stored procedure that returns the maintenance that is due, and a
trigger that moves the maintenance plan forward as soon as a maintenance order is done. Both are rules that live in the database
and appear in no form; remember that for the inventory. The sample data uses relative dates
(SYSDATETIME()) so that it is always "fresh".
IF DB_ID(N'OrderBook') IS NULL CREATE DATABASE OrderBook;
GO
USE OrderBook;
GO
IF OBJECT_ID(N'dbo.Customers') IS NULL
CREATE TABLE dbo.Customers (
CustomerId int IDENTITY(1,1) NOT NULL CONSTRAINT PK_Customers PRIMARY KEY,
Name nvarchar(100) NOT NULL,
Street nvarchar(100) NOT NULL,
PostalCode nvarchar(10) NOT NULL,
City nvarchar(60) NOT NULL,
Phone nvarchar(30) NULL,
Note nvarchar(400) NULL
);
GO
IF OBJECT_ID(N'dbo.MaintenancePlan') IS NULL
CREATE TABLE dbo.MaintenancePlan (
MaintenanceId int IDENTITY(1,1) NOT NULL CONSTRAINT PK_MaintenancePlan PRIMARY KEY,
CustomerId int NOT NULL CONSTRAINT FK_MaintenancePlan_Customers REFERENCES dbo.Customers (CustomerId),
Asset nvarchar(100) NOT NULL,
IntervalMonths int NOT NULL CONSTRAINT CK_MaintenancePlan_Interval CHECK (IntervalMonths BETWEEN 1 AND 60),
LastMaintenance date NOT NULL
);
GO
IF OBJECT_ID(N'dbo.Orders') IS NULL
CREATE TABLE dbo.Orders (
OrderId int IDENTITY(1,1) NOT NULL CONSTRAINT PK_Orders PRIMARY KEY,
CustomerId int NOT NULL CONSTRAINT FK_Orders_Customers REFERENCES dbo.Customers (CustomerId),
MaintenanceId int NULL CONSTRAINT FK_Orders_MaintenancePlan REFERENCES dbo.MaintenancePlan (MaintenanceId),
Title nvarchar(200) NOT NULL,
Kind nvarchar(20) NOT NULL CONSTRAINT CK_Orders_Kind CHECK (Kind IN (N'Heating', N'Plumbing', N'Maintenance')),
Status nvarchar(20) NOT NULL CONSTRAINT CK_Orders_Status CHECK (Status IN (N'New', N'Scheduled', N'Done', N'Invoiced')),
Appointment date NULL,
Hours decimal(6,2) NOT NULL CONSTRAINT DF_Orders_Hours DEFAULT (0),
Material decimal(10,2) NOT NULL CONSTRAINT DF_Orders_Material DEFAULT (0),
InvoiceNumber nvarchar(20) NULL
);
GO
-- When a maintenance order is done, the maintenance plan moves forward. This rule lives only here.
CREATE OR ALTER TRIGGER dbo.trg_Orders_MaintenanceDone ON dbo.Orders AFTER UPDATE AS
BEGIN
SET NOCOUNT ON;
UPDATE w SET LastMaintenance = i.Appointment
FROM dbo.MaintenancePlan AS w
JOIN inserted AS i ON i.MaintenanceId = w.MaintenanceId
JOIN deleted AS d ON d.OrderId = i.OrderId
WHERE i.Status = N'Done' AND d.Status <> N'Done' AND i.Appointment IS NOT NULL;
END;
GO
CREATE OR ALTER PROCEDURE dbo.MaintenanceDue @Until date AS
BEGIN
SET NOCOUNT ON;
SELECT w.MaintenanceId, w.CustomerId, k.Name AS Customer, w.Asset, w.IntervalMonths, w.LastMaintenance,
DATEADD(MONTH, w.IntervalMonths, w.LastMaintenance) AS NextDue
FROM dbo.MaintenancePlan AS w
JOIN dbo.Customers AS k ON k.CustomerId = w.CustomerId
WHERE DATEADD(MONTH, w.IntervalMonths, w.LastMaintenance) <= @Until
ORDER BY NextDue;
END;
GO
IF NOT EXISTS (SELECT 1 FROM dbo.Customers)
BEGIN
INSERT dbo.Customers (Name, Street, PostalCode, City, Phone, Note) VALUES
(N'Sommer Bakery', N'4 Linden Way', N'31999', N'Newfield', N'05100 555-0101', N'Back courtyard, key with the boss'),
(N'Lindemann Property Management', N'2 Market Square', N'31999', N'Newfield', N'05100 555-0102', NULL),
(N'Ms. Petersen', N'17 Birch Avenue', N'31999', N'Newfield', N'05100 555-0103', N'Dog in the garden'),
(N'Reuter Auto Dealership', N'9 Industrial Road', N'31998', N'Ashbrook', N'05101 555-0104', NULL),
(N'Sunshine Daycare', N'3 School Street', N'31998', N'Ashbrook', N'05101 555-0105', N'Call in the mornings');
INSERT dbo.MaintenancePlan (CustomerId, Asset, IntervalMonths, LastMaintenance) VALUES
(1, N'Gas boiler, basement', 12, DATEADD(MONTH, -12, CAST(SYSDATETIME() AS date))),
(2, N'Heating plant, 2 Market Square', 6, DATEADD(MONTH, -5, CAST(SYSDATETIME() AS date))),
(4, N'Oil boiler, workshop', 12, DATEADD(MONTH, -3, CAST(SYSDATETIME() AS date))),
(5, N'Heat pump', 12, DATEADD(MONTH, -11, CAST(SYSDATETIME() AS date)));
INSERT dbo.Orders (CustomerId, MaintenanceId, Title, Kind, Status, Appointment, Hours, Material, InvoiceNumber) VALUES
(1, NULL, N'Bleed the radiators in the sales room', N'Heating', N'Invoiced', DATEADD(DAY, -20, CAST(SYSDATETIME() AS date)), 1.50, 12.00, N'I-SEED-00001'),
(3, NULL, N'Replace the kitchen faucet', N'Plumbing', N'Done', DATEADD(DAY, -3, CAST(SYSDATETIME() AS date)), 2.25, 48.90, NULL),
(4, NULL, N'Replace the heating pump', N'Heating', N'Scheduled', DATEADD(DAY, 6, CAST(SYSDATETIME() AS date)), 0, 0, NULL),
(2, NULL, N'Bathroom renovation, apartment 3, quote', N'Plumbing', N'New', NULL, 0, 0, NULL),
(5, 4, N'Maintenance: heat pump', N'Maintenance', N'Scheduled', DATEADD(DAY, 30, CAST(SYSDATETIME() AS date)), 0, 0, NULL);
END;
GO
The script is repeatable: Each table is created only if it doesn't exist, trigger and procedure with CREATE OR ALTER, the
sample data only into an empty table. A PowerShell script runs it, with -WhatIf like all scripts of this course. The
character encoding 65001 is UTF-8, so that special characters in the sample data arrive intact.
<#
.SYNOPSIS
Creates the database of the Order Book (tables, trigger, procedure, sample data). A second run changes nothing.
.EXAMPLE
./scripts/Create-Database.ps1 -WhatIf
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[string] $Server = '(localdb)\MSSQLLocalDB',
[string] $Script = (Join-Path $PSScriptRoot '..' 'database.sql')
)
$ErrorActionPreference = 'Stop'
$Script = (Resolve-Path -LiteralPath $Script).Path
if ($PSCmdlet.ShouldProcess("$Server (OrderBook)", 'Create database')) {
sqlcmd -S $Server -E -b -f 65001 -i $Script
if ($LASTEXITCODE -ne 0) {
throw "sqlcmd ended with exit code $LASTEXITCODE."
}
Write-Output "Database OrderBook on $Server is ready."
}
./scripts/Create-Database.ps1 -WhatIf
./scripts/Create-Database.ps1
Check the result with a query:
sqlcmd -S "(localdb)\MSSQLLocalDB" -E -d OrderBook -Q "SELECT (SELECT COUNT(*) FROM dbo.Customers) AS Customers, (SELECT COUNT(*) FROM dbo.Orders) AS Orders, (SELECT COUNT(*) FROM dbo.MaintenancePlan) AS MaintenancePlan"
Customers Orders MaintenancePlan
----------- ----------- ---------------
5 5 4
(1 rows affected)
Step 2: the project
The project has the classic format: Every source file is listed individually in the project file, the target version is called
TargetFrameworkVersion v4.8, and the packages are listed in a file of their own, packages.config. This is how projects looked before the
SDK format, and that is exactly what you have to switch later (break point B1). The package Newtonsoft.Json writes the plan for
the field technicians later. LangVersion is set to 7.3 because programs of this kind are mostly written in that style.
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<!-- BREAK B1: classic project format, every file listed individually, packages through packages.config -->
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{6F1D2A4C-3B7E-4C55-9A0E-5D2B8C1F7A10}</ProjectGuid>
<OutputType>WinExe</OutputType>
<RootNamespace>Brandt.OrderBook</RootNamespace>
<AssemblyName>Brandt.OrderBook</AssemblyName>
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<LangVersion>7.3</LangVersion>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
<Reference Include="Newtonsoft.Json, Version=13.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
<HintPath>..\packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll</HintPath>
</Reference>
<Reference Include="System" />
<Reference Include="System.Configuration" />
<Reference Include="System.Core" />
<Reference Include="System.Data" />
<Reference Include="System.Drawing" />
<Reference Include="System.Windows.Forms" />
</ItemGroup>
<ItemGroup>
<Compile Include="CustomersForm.cs" />
<Compile Include="Exports.cs" />
<Compile Include="InvoicePrint.cs" />
<Compile Include="MainForm.cs" />
<Compile Include="MaintenanceForm.cs" />
<Compile Include="OrderData.cs" />
<Compile Include="Permissions.cs" />
<Compile Include="Program.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="Settings.cs" />
</ItemGroup>
<ItemGroup>
<None Include="App.config" />
<None Include="packages.config" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" />
</packages>
The settings live in App.config: the connection, the hourly rate, the call-out fee, the value-added tax rate, the folder
on the network drive and the Windows group of the dispatch office. These are invented values. \\brandt-srv01\Accounting\OrderBook
doesn't exist on your machine; for the exercise you change the value right away.
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
</startup>
<connectionStrings>
<add name="OrderBook"
connectionString="Server=(localdb)\MSSQLLocalDB;Database=OrderBook;Integrated Security=SSPI"
providerName="System.Data.SqlClient" />
</connectionStrings>
<appSettings>
<add key="HourlyRate" value="68.00" />
<add key="CallOutFee" value="29.50" />
<add key="Vat" value="0.19" />
<add key="ExportFolder" value="\\brandt-srv01\Accounting\OrderBook" />
<add key="DispatchGroup" value="BRANDT\Dispatch" />
</appSettings>
</configuration>
using System.Reflection;
using System.Runtime.InteropServices;
[assembly: AssemblyTitle("Brandt Order Book")]
[assembly: AssemblyCompany("Brandt Building Services")]
[assembly: AssemblyProduct("Order Book")]
[assembly: ComVisible(false)]
[assembly: AssemblyVersion("1.8.3.0")]
[assembly: AssemblyFileVersion("1.8.3.0")]
Then the solution. Since .NET 10, dotnet new sln creates a file in the new .slnx format; you get the classic .sln file
with --format sln. You add a project in the classic format with dotnet sln add:
dotnet new sln --format sln -n Brandt.OrderBook
dotnet sln add Brandt.OrderBook\Brandt.OrderBook.csproj
The template "Solution File" was created successfully.
Project `Brandt.OrderBook\Brandt.OrderBook.csproj` added to the solution.
Step 3: startup, settings, permissions
Program starts the program: It loads the data once into a static object (break point B11: one global state for all
forms) and reports unexpected errors to the Windows event log (B12). Without a reachable database a message appears,
and the program ends.
using System;
using System.Data.SqlClient;
using System.Diagnostics;
using System.Windows.Forms;
namespace Brandt.OrderBook
{
internal static class Program
{
// BREAK B11: A single, global state for the whole program.
public static OrderData Data { get; private set; }
[STAThread]
private static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.ThreadException += (s, e) => Report(e.Exception);
try
{
Data = new OrderData(Settings.ConnectionString);
Data.Load();
}
catch (SqlException ex)
{
MessageBox.Show("The database is not reachable: " + ex.Message, "Order Book", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
Application.Run(new MainForm());
}
// BREAK B12: Errors go to the Windows event log.
private static void Report(Exception error)
{
try
{
EventLog.WriteEntry("Application", "Order Book: " + error, EventLogEntryType.Error);
}
catch (Exception)
{
// Without permission for the log, only the message remains.
}
MessageBox.Show("Unexpected error: " + error.Message, "Order Book", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
}
Settings encapsulates App.config. Almost every class of the program accesses it (B2).
using System.Configuration;
using System.Globalization;
namespace Brandt.OrderBook
{
// BREAK B2: Everything comes from App.config, read through System.Configuration.
internal static class Settings
{
public static string ConnectionString
{
get { return ConfigurationManager.ConnectionStrings["OrderBook"].ConnectionString; }
}
public static string ExportFolder { get { return Text("ExportFolder"); } }
public static string DispatchGroup { get { return Text("DispatchGroup"); } }
public static decimal HourlyRate { get { return Number("HourlyRate"); } }
public static decimal CallOutFee { get { return Number("CallOutFee"); } }
public static decimal Vat { get { return Number("Vat"); } }
private static string Text(string name)
{
return ConfigurationManager.AppSettings[name];
}
private static decimal Number(string name)
{
return decimal.Parse(Text(name), CultureInfo.InvariantCulture);
}
}
}
Permissions asks Windows who is signed in and whether the person is in the dispatch group (B3). The user interface then makes
buttons and columns read-only, and that is the entire permission check. On your machine the group BRANDT\Dispatch
doesn't exist; that is why the forms are read-only at first. For the exercise you make the value in App.config empty, and then every
person may do everything (meant for local development only).
using System.Security.Principal;
namespace Brandt.OrderBook
{
// BREAK B3: Who is this, and in which group? Windows answers that, silently and without a sign-in page.
internal static class Permissions
{
public static string User
{
get { return WindowsIdentity.GetCurrent().Name; }
}
// Dispatch may edit; everyone else only reads. Without a group in the configuration everyone may (local development).
public static bool CanEdit
{
get
{
string group = Settings.DispatchGroup;
if (string.IsNullOrEmpty(group))
{
return true;
}
return new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(group);
}
}
}
}
Step 4: data access
OrderData holds a DataSet with three tables and a relation from the customer to its orders and maintenance entries. Three
SqlDataAdapter objects fill it, a SqlCommandBuilder generates INSERT, UPDATE and DELETE (and compares all old values when changing,
that is the "optimistic locking"). The forms bind directly to these tables (B4).
The code contains three places that you find again and again with DataSets and that need explaining:
- Provisional numbers. New rows get negative numbers (
AutoIncrementSeed = -1) so that they never collide with real ones. The database assigns the real number;ReadNewIdfetches it after each INSERT and writes it into the row. Here@@IDENTITYis right andSCOPE_IDENTITY()is wrong, because the command runs in a new scope on the same connection. - The DataSet doesn't know the database's defaults.
HoursandMaterialhave the default value 0 in the database, but the DataSet demands a value anyway. That is whyCreateMaintenanceOrdersets both. - After saving, it reloads. The database trigger can change data that the DataSet doesn't know.
using System;
using System.Data;
using System.Data.SqlClient;
namespace Brandt.OrderBook
{
// BREAK B4: A DataSet with adapters, bound directly to the forms. There is no layer in between.
// BREAK B11: All calls run on the UI thread; Program.Data is the same state for all forms.
public sealed class OrderData
{
private readonly string _connectionString;
private readonly SqlDataAdapter _customers;
private readonly SqlDataAdapter _maintenance;
private readonly SqlDataAdapter _orders;
public OrderData(string connectionString)
{
_connectionString = connectionString;
_customers = Adapter("SELECT CustomerId, Name, Street, PostalCode, City, Phone, Note FROM dbo.Customers ORDER BY Name", true);
_maintenance = Adapter("SELECT MaintenanceId, CustomerId, Asset, IntervalMonths, LastMaintenance FROM dbo.MaintenancePlan", false);
_orders = Adapter(
"SELECT OrderId, CustomerId, MaintenanceId, Title, Kind, Status, Appointment, Hours, Material, InvoiceNumber " +
"FROM dbo.Orders ORDER BY OrderId",
true);
}
public DataSet Data { get; } = new DataSet("OrderBook");
public void Load()
{
if (Data.Tables.Count == 0)
{
ApplySchema(_customers, "Customers");
ApplySchema(_maintenance, "MaintenancePlan");
ApplySchema(_orders, "Orders");
Data.Relations.Add("Customer_Orders", Data.Tables["Customers"].Columns["CustomerId"], Data.Tables["Orders"].Columns["CustomerId"]);
Data.Relations.Add("Customer_MaintenancePlan", Data.Tables["Customers"].Columns["CustomerId"], Data.Tables["MaintenancePlan"].Columns["CustomerId"]);
}
Data.Clear();
_customers.Fill(Data, "Customers");
_maintenance.Fill(Data, "MaintenancePlan");
_orders.Fill(Data, "Orders");
}
// The database trigger can change LastMaintenance; that is why everything is read again afterwards.
public void Save()
{
_customers.Update(Data.Tables["Customers"]);
_orders.Update(Data.Tables["Orders"]);
Load();
}
// BREAK B6: The "due" rule lives in a stored procedure.
public DataTable MaintenanceDue(DateTime until)
{
var table = new DataTable("MaintenanceDue");
using (var command = new SqlCommand("dbo.MaintenanceDue", new SqlConnection(_connectionString)))
{
command.CommandType = CommandType.StoredProcedure;
command.Parameters.AddWithValue("@Until", until);
using (var adapter = new SqlDataAdapter(command))
{
adapter.Fill(table);
}
}
return table;
}
public void CreateMaintenanceOrder(int maintenanceId, int customerId, string asset, DateTime appointment)
{
DataRow row = Data.Tables["Orders"].NewRow();
row["CustomerId"] = customerId;
row["MaintenanceId"] = maintenanceId;
row["Title"] = "Maintenance: " + asset;
row["Kind"] = "Maintenance";
row["Status"] = "Scheduled";
row["Appointment"] = appointment;
row["Hours"] = 0m;
row["Material"] = 0m;
Data.Tables["Orders"].Rows.Add(row);
Save();
}
// New rows get provisional, negative numbers; only the database assigns the real one.
private void ApplySchema(SqlDataAdapter adapter, string table)
{
adapter.FillSchema(Data, SchemaType.Source, table);
DataColumn id = Data.Tables[table].Columns[0];
id.ReadOnly = false;
id.AutoIncrementSeed = -1;
id.AutoIncrementStep = -1;
}
private SqlDataAdapter Adapter(string query, bool writing)
{
var adapter = new SqlDataAdapter(query, _connectionString);
if (writing)
{
// The CommandBuilder generates INSERT, UPDATE and DELETE and compares all old values when changing.
new SqlCommandBuilder(adapter);
adapter.RowUpdated += ReadNewId;
}
return adapter;
}
// After an INSERT the row fetches the database's new number. It is a new command on the same
// connection, so @@IDENTITY: SCOPE_IDENTITY() would be in another scope and return NULL.
private static void ReadNewId(object sender, SqlRowUpdatedEventArgs e)
{
if (e.StatementType != StatementType.Insert)
{
return;
}
using (var command = new SqlCommand("SELECT CAST(@@IDENTITY AS int)", e.Command.Connection, e.Command.Transaction))
{
e.Row[0] = command.ExecuteScalar();
}
}
}
}
Step 5: the forms
The controls are created in code instead of the designer so that all files are in this lesson. A designer would do the same
work in a *.Designer.cs. What matters are the places that touch something later.
The main form shows the orders in a grid bound to a DataView of the DataSet, and offers save,
invoice printing and export. It also contains the pricing rules of the business (B5): at least one hour of labor, no call-out for
maintenance, a 15 percent markup on material, value-added tax from the configuration. Ms. Aydin knows these rules, but they exist only
here, in four small methods of a form, and the invoice print calls them there. The font is set to a fixed value (B10), and the
window size ends up in the Windows registry (B12).
using System;
using System.Data;
using System.Data.SqlClient;
using System.Drawing;
using System.Windows.Forms;
using Microsoft.Win32;
namespace Brandt.OrderBook
{
public sealed class MainForm : Form
{
private const string RegistryKeyPath = @"Software\Brandt\OrderBook";
private readonly DataView _view;
private readonly DataGridView _grid = new DataGridView { Dock = DockStyle.Fill, AutoGenerateColumns = false };
private readonly ComboBox _filter = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = 140 };
private readonly Button _save = new Button { Text = "Save", AutoSize = true };
private readonly Button _invoice = new Button { Text = "Print invoice", AutoSize = true };
private readonly ToolStripMenuItem _export = new ToolStripMenuItem("Export to accounting");
public MainForm()
{
// BREAK B10: Fixed font and font size; the layout relies on the dimensions of this font.
Font = new Font("Microsoft Sans Serif", 8.25F);
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(960, 480);
Text = "Order Book - Brandt Building Services (" + Permissions.User + ")";
_view = new DataView(Program.Data.Data.Tables["Orders"]);
_grid.DataSource = _view;
CreateColumns();
_filter.Items.AddRange(new object[] { "All", "New", "Scheduled", "Done", "Invoiced" });
_filter.SelectedIndex = 0;
_filter.SelectedIndexChanged += (s, e) =>
_view.RowFilter = _filter.SelectedIndex <= 0 ? string.Empty : "Status = '" + _filter.Text + "'";
_save.Click += (s, e) => Save();
_invoice.Click += (s, e) => PrintInvoice();
_grid.DefaultValuesNeeded += DefaultValues;
_grid.DataError += (s, e) =>
{
MessageBox.Show(this, "Please enter customer and title.", Text, MessageBoxButtons.OK, MessageBoxIcon.Warning);
e.ThrowException = false;
};
var bar = new FlowLayoutPanel { Dock = DockStyle.Top, AutoSize = true };
bar.Controls.AddRange(new Control[] { new Label { Text = "Status:", AutoSize = true }, _filter, _save, _invoice });
Controls.Add(_grid);
Controls.Add(bar);
Controls.Add(CreateMenu());
bool canEdit = Permissions.CanEdit;
_grid.ReadOnly = !canEdit;
_grid.AllowUserToAddRows = canEdit;
_save.Enabled = canEdit;
_invoice.Enabled = canEdit;
_export.Enabled = canEdit;
Load += (s, e) => ReadWindow();
FormClosing += (s, e) => WriteWindow();
}
// BREAK B5: The pricing rules of the business live in the form; the invoice print calls them here.
public static decimal Labor(DataRow order)
{
decimal hours = Math.Max(Convert.ToDecimal(order["Hours"]), 1m);
return hours * Settings.HourlyRate;
}
public static decimal CallOut(DataRow order)
{
return (string)order["Kind"] == "Maintenance" ? 0m : Settings.CallOutFee;
}
public static decimal MaterialWithMarkup(DataRow order)
{
return Math.Round(Convert.ToDecimal(order["Material"]) * 1.15m, 2);
}
public static decimal Net(DataRow order)
{
return Labor(order) + CallOut(order) + MaterialWithMarkup(order);
}
public static decimal Gross(DataRow order)
{
return Math.Round(Net(order) * (1 + Settings.Vat), 2);
}
private MenuStrip CreateMenu()
{
var file = new ToolStripMenuItem("File");
file.DropDownItems.Add("Print invoice", null, (s, e) => PrintInvoice());
_export.Click += (s, e) => Export();
file.DropDownItems.Add(_export);
file.DropDownItems.Add("Exit", null, (s, e) => Close());
var view = new ToolStripMenuItem("View");
view.DropDownItems.Add("Customers", null, (s, e) => new CustomersForm().ShowDialog(this));
view.DropDownItems.Add("Maintenance plan", null, (s, e) => new MaintenanceForm().ShowDialog(this));
var menu = new MenuStrip { Dock = DockStyle.Top };
menu.Items.AddRange(new ToolStripItem[] { file, view });
MainMenuStrip = menu;
return menu;
}
private void CreateColumns()
{
DataTable customers = Program.Data.Data.Tables["Customers"];
_grid.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = "OrderId", HeaderText = "No.", ReadOnly = true, Width = 50 });
_grid.Columns.Add(new DataGridViewComboBoxColumn
{
DataPropertyName = "CustomerId",
HeaderText = "Customer",
DataSource = customers,
DisplayMember = "Name",
ValueMember = "CustomerId",
Width = 190
});
_grid.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = "Title", HeaderText = "Title", Width = 220 });
_grid.Columns.Add(Choice("Kind", "Kind", "Heating", "Plumbing", "Maintenance"));
_grid.Columns.Add(Choice("Status", "Status", "New", "Scheduled", "Done", "Invoiced"));
_grid.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = "Appointment", HeaderText = "Appointment", Width = 80 });
_grid.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = "Hours", HeaderText = "Hrs.", Width = 45 });
_grid.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = "Material", HeaderText = "Material", Width = 70 });
_grid.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = "InvoiceNumber", HeaderText = "Invoice", ReadOnly = true, Width = 100 });
}
private static DataGridViewComboBoxColumn Choice(string field, string title, params string[] values)
{
var column = new DataGridViewComboBoxColumn { DataPropertyName = field, HeaderText = title, Width = 90 };
column.Items.AddRange(values);
return column;
}
private void DefaultValues(object sender, DataGridViewRowEventArgs e)
{
e.Row.Cells[3].Value = "Heating";
e.Row.Cells[4].Value = "New";
e.Row.Cells[6].Value = 0m;
e.Row.Cells[7].Value = 0m;
}
private DataRow CurrentRow()
{
_grid.EndEdit();
BindingContext[_view].EndCurrentEdit();
var row = _grid.CurrentRow == null ? null : _grid.CurrentRow.DataBoundItem as DataRowView;
return row == null ? null : row.Row;
}
private bool Save()
{
try
{
CurrentRow();
Program.Data.Save();
return true;
}
catch (DBConcurrencyException)
{
MessageBox.Show(this, "Someone has changed this order in the meantime. The list will be reloaded.", Text);
Program.Data.Load();
}
catch (SqlException ex)
{
MessageBox.Show(this, "Saving is not possible: " + ex.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Error);
}
return false;
}
private void PrintInvoice()
{
DataRow order = CurrentRow();
if (order == null || order.RowState == DataRowState.Added)
{
MessageBox.Show(this, "Please select a saved order first.", Text);
return;
}
if ((string)order["Status"] == "New" || (string)order["Status"] == "Scheduled")
{
MessageBox.Show(this, "The order is not done yet.", Text);
return;
}
if (order.IsNull("InvoiceNumber"))
{
order["InvoiceNumber"] = string.Format("I-{0}-{1:D5}", DateTime.Today.Year, order["OrderId"]);
order["Status"] = "Invoiced";
if (!Save())
{
return;
}
}
using (var preview = new PrintPreviewDialog { Document = new InvoicePrint(order), WindowState = FormWindowState.Maximized })
{
preview.ShowDialog(this);
}
}
private void Export()
{
try
{
string invoices = Exports.InvoicesAsCsv(Program.Data.Data);
string maintenance = Exports.MaintenancePlanAsJson(Program.Data.Data);
MessageBox.Show(this, "Written:\n" + invoices + "\n" + maintenance, Text);
}
catch (System.IO.IOException ex)
{
MessageBox.Show(this, "The network drive is not reachable: " + ex.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
// BREAK B12: Window dimensions in the user's Windows registry.
private void ReadWindow()
{
using (RegistryKey key = Registry.CurrentUser.OpenSubKey(RegistryKeyPath))
{
if (key == null)
{
return;
}
ClientSize = new Size((int)key.GetValue("Width", ClientSize.Width), (int)key.GetValue("Height", ClientSize.Height));
}
}
private void WriteWindow()
{
using (RegistryKey key = Registry.CurrentUser.CreateSubKey(RegistryKeyPath))
{
key.SetValue("Width", ClientSize.Width);
key.SetValue("Height", ClientSize.Height);
}
}
}
}
The customers form shows the customer table and, next to it, a customer card in a WebBrowser control (B9): a small
HTML page with the address, a link to the map and the customer's orders, generated from the DataSet relation.
using System;
using System.Data;
using System.Drawing;
using System.Net;
using System.Text;
using System.Windows.Forms;
namespace Brandt.OrderBook
{
public sealed class CustomersForm : Form
{
private readonly DataGridView _grid = new DataGridView { Dock = DockStyle.Left, Width = 480, AutoGenerateColumns = true };
// BREAK B9: The browser control WebBrowser shows the customer card and the link to the map.
private readonly WebBrowser _card = new WebBrowser { Dock = DockStyle.Fill, ScriptErrorsSuppressed = true };
public CustomersForm()
{
Font = new Font("Microsoft Sans Serif", 8.25F);
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(900, 420);
Text = "Customers";
_grid.DataSource = Program.Data.Data.Tables["Customers"];
_grid.ReadOnly = !Permissions.CanEdit;
_grid.SelectionChanged += (s, e) => ShowCard();
_grid.DataBindingComplete += (s, e) => _grid.Columns["CustomerId"].Visible = false;
var save = new Button { Text = "Save", Dock = DockStyle.Bottom, Enabled = Permissions.CanEdit };
save.Click += (s, e) => Program.Data.Save();
Controls.Add(_card);
Controls.Add(_grid);
Controls.Add(save);
}
private void ShowCard()
{
var row = _grid.CurrentRow == null ? null : _grid.CurrentRow.DataBoundItem as DataRowView;
if (row == null)
{
return;
}
DataRow customer = row.Row;
var html = new StringBuilder("<html><body style=\"font-family:Segoe UI\">");
html.Append("<h2>").Append(WebUtility.HtmlEncode((string)customer["Name"])).Append("</h2>");
html.Append("<p>").Append(WebUtility.HtmlEncode((string)customer["Street"])).Append("<br>");
html.Append(WebUtility.HtmlEncode(customer["PostalCode"] + " " + customer["City"])).Append("</p>");
html.Append("<p><a href=\"https://www.openstreetmap.org/search?query=")
.Append(Uri.EscapeDataString(customer["Street"] + ", " + customer["PostalCode"] + " " + customer["City"]))
.Append("\">Show on map</a></p><h3>Orders</h3><ul>");
foreach (DataRow order in customer.GetChildRows("Customer_Orders"))
{
html.Append("<li>").Append(WebUtility.HtmlEncode(order["Title"] + " (" + order["Status"] + ")")).Append("</li>");
}
html.Append("</ul></body></html>");
_card.DocumentText = html.ToString();
}
}
}
The maintenance plan calls the stored procedure and creates orders for maintenance that is due.
using System;
using System.Data;
using System.Drawing;
using System.Windows.Forms;
namespace Brandt.OrderBook
{
public sealed class MaintenanceForm : Form
{
private readonly DateTimePicker _until = new DateTimePicker { Format = DateTimePickerFormat.Short, Value = DateTime.Today.AddDays(30) };
private readonly DataGridView _grid = new DataGridView
{
Dock = DockStyle.Fill,
ReadOnly = true,
AllowUserToAddRows = false,
SelectionMode = DataGridViewSelectionMode.FullRowSelect
};
public MaintenanceForm()
{
Font = new Font("Microsoft Sans Serif", 8.25F);
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(760, 380);
Text = "Maintenance plan";
var create = new Button { Text = "Create order", AutoSize = true, Enabled = Permissions.CanEdit };
var export = new Button { Text = "Export plan for technicians", AutoSize = true };
var bar = new FlowLayoutPanel { Dock = DockStyle.Top, AutoSize = true };
bar.Controls.AddRange(new Control[] { new Label { Text = "Due by:", AutoSize = true }, _until, create, export });
_until.ValueChanged += (s, e) => LoadRows();
create.Click += (s, e) => CreateOrder();
export.Click += (s, e) => MessageBox.Show(this, Exports.MaintenancePlanAsJson(Program.Data.Data), Text);
Controls.Add(_grid);
Controls.Add(bar);
Load += (s, e) => LoadRows();
}
private void LoadRows()
{
_grid.DataSource = Program.Data.MaintenanceDue(_until.Value.Date);
foreach (string column in new[] { "MaintenanceId", "CustomerId" })
{
_grid.Columns[column].Visible = false;
}
}
private void CreateOrder()
{
var row = _grid.CurrentRow == null ? null : _grid.CurrentRow.DataBoundItem as DataRowView;
if (row == null)
{
return;
}
Program.Data.CreateMaintenanceOrder(
(int)row["MaintenanceId"],
(int)row["CustomerId"],
(string)row["Asset"],
(DateTime)row["NextDue"]);
LoadRows();
}
}
}
The invoice print draws the invoice line by line with Graphics onto the paper (B7), through a PrintDocument that the print preview
understands as well.
using System.Data;
using System.Drawing;
using System.Drawing.Printing;
using System.Globalization;
namespace Brandt.OrderBook
{
// BREAK B7: PrintDocument and System.Drawing; the invoice is drawn line by line with Graphics.
internal sealed class InvoicePrint : PrintDocument
{
private readonly DataRow _order;
public InvoicePrint(DataRow order)
{
_order = order;
DocumentName = "Invoice " + order["InvoiceNumber"];
}
protected override void OnPrintPage(PrintPageEventArgs e)
{
DataRow customer = _order.GetParentRow("Customer_Orders");
Graphics g = e.Graphics;
float x = e.MarginBounds.Left;
float y = e.MarginBounds.Top;
using (var large = new Font("Arial", 16, FontStyle.Bold))
using (var normal = new Font("Arial", 10))
using (var right = new StringFormat { Alignment = StringAlignment.Far })
{
float lineHeight = normal.GetHeight(g) * 1.5f;
g.DrawString("Brandt Building Services", large, Brushes.Black, x, y);
y += large.GetHeight(g) * 2;
g.DrawString((string)customer["Name"], normal, Brushes.Black, x, y);
g.DrawString((string)customer["Street"], normal, Brushes.Black, x, y + lineHeight);
g.DrawString(customer["PostalCode"] + " " + customer["City"], normal, Brushes.Black, x, y + 2 * lineHeight);
y += 4 * lineHeight;
g.DrawString("Invoice " + _order["InvoiceNumber"], large, Brushes.Black, x, y);
y += large.GetHeight(g) * 1.5f;
g.DrawString((string)_order["Title"], normal, Brushes.Black, x, y);
y += 2 * lineHeight;
Line(g, normal, right, ref y, x, lineHeight, "Labor", MainForm.Labor(_order));
Line(g, normal, right, ref y, x, lineHeight, "Call-out", MainForm.CallOut(_order));
Line(g, normal, right, ref y, x, lineHeight, "Material", MainForm.MaterialWithMarkup(_order));
Line(g, normal, right, ref y, x, lineHeight, "Net", MainForm.Net(_order));
Line(g, normal, right, ref y, x, lineHeight, "Gross", MainForm.Gross(_order));
}
e.HasMorePages = false;
}
private static void Line(Graphics g, Font font, StringFormat right, ref float y, float x, float lineHeight, string text, decimal amount)
{
g.DrawString(text, font, Brushes.Black, x, y);
g.DrawString(amount.ToString("N2", CultureInfo.CurrentCulture), font, Brushes.Black, new RectangleF(x + 250, y, 150, lineHeight), right);
y += lineHeight;
}
}
}
The export writes two files into the folder on the network drive (B8): a CSV file for accounting in the Windows encoding
1252 and a JSON file with the maintenance plan for the field technicians' tablets. The accounting department uses German accounting software, so the CSV keeps
the German number format (de-DE). The "next maintenance" rule appears here a second time,
next to the database procedure (B6).
using System;
using System.Collections.Generic;
using System.Data;
using System.Globalization;
using System.IO;
using System.Text;
using Newtonsoft.Json;
namespace Brandt.OrderBook
{
// BREAK B8: Both files end up in a folder on the network drive; other programs read them there.
internal static class Exports
{
private static readonly CultureInfo German = new CultureInfo("de-DE");
// Accounting reads semicolon files in Windows-1252.
public static string InvoicesAsCsv(DataSet data)
{
Directory.CreateDirectory(Settings.ExportFolder);
string path = Path.Combine(Settings.ExportFolder, "invoices-" + DateTime.Today.ToString("yyyy-MM-dd") + ".csv");
var lines = new List<string> { "InvoiceNumber;Customer;Title;Net;Gross" };
foreach (DataRow order in data.Tables["Orders"].Select("Status = 'Invoiced'", "InvoiceNumber"))
{
decimal net = MainForm.Net(order);
decimal gross = MainForm.Gross(order);
lines.Add(string.Join(";", new[]
{
(string)order["InvoiceNumber"],
(string)order.GetParentRow("Customer_Orders")["Name"],
(string)order["Title"],
net.ToString("N2", German),
gross.ToString("N2", German)
}));
}
File.WriteAllLines(path, lines, Encoding.GetEncoding(1252));
return path;
}
// The field technicians read the plan on their tablets from this JSON file.
public static string MaintenancePlanAsJson(DataSet data)
{
Directory.CreateDirectory(Settings.ExportFolder);
string path = Path.Combine(Settings.ExportFolder, "maintenance-plan.json");
var entries = new List<object>();
foreach (DataRow row in data.Tables["MaintenancePlan"].Rows)
{
// BREAK B6: The same rule as in dbo.MaintenanceDue, written a second time.
DateTime nextDue = ((DateTime)row["LastMaintenance"]).AddMonths((int)row["IntervalMonths"]);
entries.Add(new
{
customer = (string)row.GetParentRow("Customer_MaintenancePlan")["Name"],
asset = (string)row["Asset"],
nextDue = nextDue.ToString("yyyy-MM-dd")
});
}
File.WriteAllText(path, JsonConvert.SerializeObject(entries, Formatting.Indented), new UTF8Encoding(false));
return path;
}
}
}
Step 6: build and start
Visual Studio and MSBuild restore a package from packages.config. In the Developer PowerShell of Visual Studio:
msbuild Brandt.OrderBook.sln -restore -p:RestorePackagesConfig=true
echo $LASTEXITCODE
Brandt.OrderBook -> ...\Brandt.OrderBook\bin\Debug\Brandt.OrderBook.exe
0
Start Brandt.OrderBook\bin\Debug\Brandt.OrderBook.exe. You see a window with an order list, above it a menu
(File, View) and a bar with filter, Save and "Print invoice". The customers form opens through "View".
The break points
The comments say where something breaks when the program moves to .NET 10, to the web or to Azure. A search shows them all:
Select-String -Path .\Brandt.OrderBook\*.cs,.\Brandt.OrderBook\*.csproj -Pattern 'BREAK B' |
ForEach-Object { "{0}:{1} {2}" -f $_.Filename, $_.LineNumber, $_.Line.Trim() }
| No. | Where | What breaks or has to change | Lesson |
|---|---|---|---|
| B1 | .csproj, packages.config |
classic project format, packages from packages.config |
M2.2 |
| B2 | Settings, App.config |
System.Configuration and App.config |
M2.4 |
| B3 | Permissions |
Windows sign-in and groups | M5.1, M5.2 |
| B4 | OrderData and all forms |
DataSet, adapters and CommandBuilder, bound directly | M3.2 |
| B5 | MainForm |
pricing rules in the form, called by the print | M3.1 |
| B6 | procedure, trigger, Exports |
the "next maintenance" rule lives in the database and a second time in the code | M3.2, M3.3 |
| B7 | InvoicePrint |
PrintDocument and System.Drawing |
M2.3, M4.5 |
| B8 | Exports, App.config |
network drive, file encoding 1252 | M2.3, M4.5 |
| B9 | CustomersForm |
WebBrowser control |
M2.3, M4.4 |
| B10 | all forms | fixed font and dimensions, layout depends on the font | M2.3 |
| B11 | Program, OrderData |
global state, blocking calls on the UI thread | M4.2, M4.3 |
| B12 | MainForm, Program |
registry and event log | M2.3 |
What can go wrong
- The database is not reachable. On the first access to LocalDB the instance has to be created and started first; Microsoft's documentation says that the connection attempt can run into a timeout in the process. Wait a few seconds and try again.
- Everything is read-only. The group from
DispatchGroupdoesn't exist on your machine. Empty the value inApp.config. - The build doesn't find
Newtonsoft.Json. The package was not restored: Build with-restoreand-p:RestorePackagesConfig=true, or in Visual Studio through "Restore NuGet Packages". - The export reports that the network drive is not reachable. Set
ExportFolderto a local folder. The place stays the same: The code knows only one path. - The
CustomerIdcolumn is read-only or collides. Then the setting of the provisional numbers inApplySchemais missing. - Saving reports that someone changed the order. Two windows edited the same order; the CommandBuilder detects the change because it compares all old values. That is intended.
Exercise 1
Build the Order Book: create the database, create the files, build, start. Before that, in App.config, set the group
to empty and ExportFolder to a local folder of your choice. Create a commit after each step of the lesson. Then go
through the program once: Create an order for "Ms. Petersen", set it to "Done", save, and print
the invoice in the preview. Open the customers form and the maintenance plan, create a maintenance order and export.
How to check your result: The build ends with exit code 0, the order is still in the list after a restart, and the
folder contains invoices-<date>.csv and maintenance-plan.json.
Solution 1
An order from "Ms. Petersen" with 2.25 hours and 48.90 material gives, with the values from App.config (hourly rate 68, call-out
29.50, value-added tax 0.19), 238.74 net and 284.10 gross: 2.25 times 68 is 153, plus 29.50 call-out and 48.90 times 1.15
(56.24 rounded). The invoice preview shows these lines. The CSV file contains the invoiced orders:
InvoiceNumber;Customer;Title;Net;Gross
I-SEED-00001;Sommer Bakery;Bleed the radiators in the sales room;145,30;172,91
After a maintenance order is done, the date of the last maintenance in the maintenance plan jumps forward. The database trigger does that, not the program. If you don't see it in the maintenance plan, reload the form.
Two traps when checking: The invoice number is only fixed after saving, which is why the form demands a saved
order. And Ms. Petersen's invoice shows the call-out that the maintenance order doesn't have, because the rule Kind == "Maintenance"
makes an exception for it.
Exercise 2
Create the list of break points yourself. Run the search from the section "The break points" and write
docs/break-points.md: one line per break point with the number, the file, a sentence about what breaks there, and the lesson in which you
expect it to be handled. Add at least one place that no BREAK comment marks.
How to check your result: The file has twelve lines plus your addition, and every line names a file that exists.
Solution 2
The search returns these lines (the line numbers may differ for you):
CustomersForm.cs:14 // BREAK B9: The browser control WebBrowser shows the customer card and the link to the map.
Exports.cs:11 // BREAK B8: Both files end up in a folder on the network drive; other programs read them there.
Exports.cs:50 // BREAK B6: The same rule as in dbo.MaintenanceDue, written a second time.
InvoicePrint.cs:8 // BREAK B7: PrintDocument and System.Drawing; the invoice is drawn line by line with Graphics.
MainForm.cs:23 // BREAK B10: Fixed font and font size; the layout relies on the dimensions of this font.
MainForm.cs:65 // BREAK B5: The pricing rules of the business live in the form; the invoice print calls them here.
MainForm.cs:221 // BREAK B12: Window dimensions in the user's Windows registry.
OrderData.cs:7 // BREAK B4: A DataSet with adapters, bound directly to the forms. There is no layer in between.
OrderData.cs:8 // BREAK B11: All calls run on the UI thread; Program.Data is the same state for all forms.
OrderData.cs:54 // BREAK B6: The "due" rule lives in a stored procedure.
Permissions.cs:5 // BREAK B3: Who is this, and in which group? Windows answers that, silently and without a sign-in page.
Program.cs:10 // BREAK B11: A single, global state for the whole program.
Program.cs:34 // BREAK B12: Errors go to the Windows event log.
Settings.cs:6 // BREAK B2: Everything comes from App.config, read through System.Configuration.
Brandt.OrderBook.csproj:3 <!-- BREAK B1: classic project format, every file listed individually, packages through packages.config -->
In docs/break-points.md these are the lines of the table above. Places without a comment that you can add:
- The form of the invoice number is in the main form, built from the year and the order number. Neither the database nor a business class knows this rule.
ScriptErrorsSuppressedin the customers form suppresses script errors of the browser control instead of fixing them.HoursandMaterialhave defaults in the database that the DataSet doesn't know (seeCreateMaintenanceOrder).
What you can do now
- You have built the Order Book and know how an order gets from the form through the DataSet into the database, the print and the export.
- You know the twelve break points B1 to B12 and the lessons that handle them.
- You have recorded the state in your repository and build with an exit code that counts.
In the next lesson you take an inventory: What is inside the program, and what of it can you find in ten minutes without reading it?
Further reading
- SQL Server Express LocalDB: starting LocalDB and connecting to the automatic instance.
- Overview of upgrading Windows Forms apps: what to watch for when lifting a Windows Forms program to .NET; the break points of this program are the reasons for it.
- Default templates for
dotnet new: the templateslnand its option--format. - Analyze your dependencies to port code from .NET Framework to .NET:
packages.configandPackageReference. - Overview of porting from .NET Framework to .NET: Windows desktop technologies and Windows-specific APIs.