Add support for item categories in registries

Introduced `RegistryItemCategory` entity for grouping and ordering items within registries. Updated `RegistryItem` and `Registry` entities to support categorization. Added database migrations for `RegistryItemCategories` and updated `RegistryItems` with `CategoryId` and `SortOrder`.

Implemented drag-and-drop functionality for reordering categories and items using JavaScript and Blazor. Enhanced `RegistryAdmin` and `RegistryPublic` components to manage and display categories with collapsible sections.

Updated `RegistryService` to handle category operations, including adding, renaming, removing, and reordering. Added new view models and updated CSS for category styling. Refactored logic to ensure proper ordering and fallback for unassigned items.
This commit is contained in:
Arne Moerman
2026-05-19 17:02:31 +02:00
parent fa704ab996
commit 09349cb7b7
21 changed files with 2334 additions and 252 deletions
@@ -25,6 +25,7 @@ public sealed class RegistryCreateModel
public sealed class RegistryItemEditModel
{
public Guid? Id { get; set; }
public Guid? CategoryId { get; set; }
public string Name { get; set; } = string.Empty;
public string? PictureUrl { get; set; }
public string? ProductUrl { get; set; }
@@ -92,12 +93,25 @@ public sealed class RegistryPublicViewModel
public IReadOnlyList<ContributionAmountQrCodeModel> ContributionAmountQrCodes { get; init; } = [];
public string? CurrentUserId { get; init; }
public bool IsAdmin { get; init; }
public IReadOnlyList<RegistryPublicCategoryViewModel> Categories { get; init; } = [];
public IReadOnlyList<RegistryPublicItemViewModel> Items { get; init; } = [];
}
public sealed class RegistryPublicCategoryViewModel
{
public Guid Id { get; init; }
public string Name { get; init; } = string.Empty;
public int SortOrder { get; init; }
public IReadOnlyList<RegistryPublicItemViewModel> Items { get; init; } = [];
}
public sealed class RegistryPublicItemViewModel
{
public Guid Id { get; init; }
public Guid CategoryId { get; init; }
public string CategoryName { get; init; } = string.Empty;
public int CategorySortOrder { get; init; }
public int SortOrder { get; init; }
public string Name { get; init; } = string.Empty;
public string? PictureUrl { get; init; }
public string? ProductUrl { get; init; }
@@ -123,6 +137,14 @@ public sealed class RegistryAdminDisplayModel
public string DisplayName { get; init; } = string.Empty;
}
public sealed class RegistryAccessibleUserAddressViewModel
{
public string UserId { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string? Email { get; init; }
public string? Address { get; init; }
}
public sealed class UrlMetadataResult
{
public string? NormalizedUrl { get; init; }
@@ -144,3 +166,11 @@ public sealed class RegistryActionLogViewModel
public string? Details { get; init; }
public DateTimeOffset CreatedAtUtc { get; init; }
}
public sealed class RegistryItemCategoryEditModel
{
public Guid Id { get; init; }
public string Name { get; init; } = string.Empty;
public int SortOrder { get; init; }
public IReadOnlyList<RegistryItemEditModel> Items { get; init; } = [];
}
@@ -10,6 +10,8 @@ namespace BirthList.Web.Features.Registries;
internal sealed class RegistryService(RegistryDbContext registryDbContext, ApplicationDbContext applicationDbContext)
{
private const string DefaultCategoryName = "General";
public async Task<Guid> CreateRegistryAsync(string userId, RegistryCreateModel model, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
@@ -43,6 +45,14 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
{
RegistryId = registry.Id
});
registryDbContext.RegistryItemCategories.Add(new RegistryItemCategory
{
Id = Guid.NewGuid(),
RegistryId = registry.Id,
Name = DefaultCategoryName,
SortOrder = 0,
CreatedAtUtc = DateTimeOffset.UtcNow
});
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return registry.Id;
@@ -125,6 +135,8 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
var registry = await registryDbContext.Registries
.Include(x => x.Items)
.ThenInclude(x => x.Category)
.Include(x => x.ItemCategories)
.Include(x => x.Visits)
.Include(x => x.Admins)
.FirstOrDefaultAsync(x => x.PublicLinkCode == code.Trim(), cancellationToken)
@@ -164,12 +176,12 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
var itemIds = registry.Items.Select(x => x.Id).ToList();
var userIds = new HashSet<string>();
var purchases = await registryDbContext.ItemPurchases
.Where(x => itemIds.Contains(x.RegistryItemId))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var contributions = await registryDbContext.ItemContributions
.Where(x => itemIds.Contains(x.RegistryItemId))
.ToListAsync(cancellationToken)
@@ -204,6 +216,77 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
var isAdmin = !string.IsNullOrWhiteSpace(userId) && registry.Admins.Any(x => x.UserId == userId);
var mappedItems = registry.Items
.OrderBy(x => x.Category.SortOrder)
.ThenBy(x => x.SortOrder)
.ThenBy(x => x.Name)
.Select(x => new RegistryPublicItemViewModel
{
Id = x.Id,
CategoryId = x.CategoryId,
CategoryName = x.Category.Name,
CategorySortOrder = x.Category.SortOrder,
SortOrder = x.SortOrder,
Name = x.Name,
PictureUrl = x.PictureUrl,
ProductUrl = x.ProductUrl,
Description = x.Description,
PriceAmount = x.PriceAmount,
CurrencyCode = x.CurrencyCode,
DesiredQuantity = x.DesiredQuantity,
PurchasedQuantity = x.PurchasedQuantity,
ParticipationAllowed = x.ParticipationAllowed,
ParticipationTargetAmount = x.ParticipationTargetAmount,
MoneyFulfilledAmount = x.MoneyFulfilledAmount,
PreferSecondHand = x.PreferSecondHand,
IsGiven = x.IsGiven,
CanViewPurchasers = isAdmin || (purchasesByItemId.TryGetValue(x.Id, out var itemPurchases) && itemPurchases.Any(p => p.UserId == userId)),
Purchasers = purchasesByItemId.TryGetValue(x.Id, out var itemPurchases2)
? itemPurchases2
.GroupBy(p => p.UserId)
.Select(g => new ItemContributorViewModel
{
UserId = g.Key,
DisplayName = usersById.TryGetValue(g.Key, out var displayName) ? displayName : g.Key,
Quantity = g.Sum(p => p.Quantity)
})
.ToList()
: [],
Contributors = contributionsByItemId.TryGetValue(x.Id, out var itemContributions)
? itemContributions
.GroupBy(c => c.UserId)
.Select(g => new ItemContributorViewModel
{
UserId = g.Key,
DisplayName = usersById.TryGetValue(g.Key, out var displayName) ? displayName : g.Key,
Amount = g.Sum(c => c.Amount)
})
.ToList()
: [],
CurrentUserPurchasedQuantity = purchasesByItemId.TryGetValue(x.Id, out var itemPurchases3)
? itemPurchases3
.Where(p => p.UserId == userId)
.Sum(p => p.Quantity)
: 0
})
.ToList();
var mappedCategories = registry.ItemCategories
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Name)
.Select(category => new RegistryPublicCategoryViewModel
{
Id = category.Id,
Name = category.Name,
SortOrder = category.SortOrder,
Items = mappedItems
.Where(item => item.CategoryId == category.Id)
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Name)
.ToList()
})
.ToList();
return new RegistryPublicViewModel
{
Id = registry.Id,
@@ -223,54 +306,8 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
ContributionAmountQrCodes = ParseContributionAmountQrCodes(settings?.ContributionAmountQrCodesJson),
CurrentUserId = userId,
IsAdmin = isAdmin,
Items = registry.Items
.OrderBy(x => x.Name)
.Select(x => new RegistryPublicItemViewModel
{
Id = x.Id,
Name = x.Name,
PictureUrl = x.PictureUrl,
ProductUrl = x.ProductUrl,
Description = x.Description,
PriceAmount = x.PriceAmount,
CurrencyCode = x.CurrencyCode,
DesiredQuantity = x.DesiredQuantity,
PurchasedQuantity = x.PurchasedQuantity,
ParticipationAllowed = x.ParticipationAllowed,
ParticipationTargetAmount = x.ParticipationTargetAmount,
MoneyFulfilledAmount = x.MoneyFulfilledAmount,
PreferSecondHand = x.PreferSecondHand,
IsGiven = x.IsGiven,
CanViewPurchasers = isAdmin || (purchasesByItemId.TryGetValue(x.Id, out var itemPurchases) && itemPurchases.Any(p => p.UserId == userId)),
Purchasers = purchasesByItemId.TryGetValue(x.Id, out var itemPurchases2)
? itemPurchases2
.GroupBy(p => p.UserId)
.Select(g => new ItemContributorViewModel
{
UserId = g.Key,
DisplayName = usersById.TryGetValue(g.Key, out var displayName) ? displayName : g.Key,
Quantity = g.Sum(p => p.Quantity)
})
.ToList()
: [],
Contributors = contributionsByItemId.TryGetValue(x.Id, out var itemContributions)
? itemContributions
.GroupBy(c => c.UserId)
.Select(g => new ItemContributorViewModel
{
UserId = g.Key,
DisplayName = usersById.TryGetValue(g.Key, out var displayName) ? displayName : g.Key,
Amount = g.Sum(c => c.Amount)
})
.ToList()
: [],
CurrentUserPurchasedQuantity = purchasesByItemId.TryGetValue(x.Id, out var itemPurchases3)
? itemPurchases3
.Where(p => p.UserId == userId)
.Sum(p => p.Quantity)
: 0
})
.ToList()
Categories = mappedCategories,
Items = mappedItems
};
}
@@ -402,12 +439,22 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
public async Task<IReadOnlyList<RegistryItemEditModel>> GetRegistryItemsAsync(Guid registryId, CancellationToken cancellationToken)
{
var defaultCategory = await EnsureDefaultCategoryAsync(registryId, cancellationToken).ConfigureAwait(false);
var items = await registryDbContext.RegistryItems
.Where(x => x.RegistryId == registryId)
.OrderBy(x => x.Name)
.Include(x => x.Category)
.OrderBy(x => x.Category.SortOrder)
.ThenBy(x => x.SortOrder)
.ThenBy(x => x.Name)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var item in items.Where(x => x.CategoryId == Guid.Empty))
{
item.CategoryId = defaultCategory.Id;
}
var itemIds = items.Select(x => x.Id).ToList();
var userIds = new HashSet<string>();
@@ -452,6 +499,7 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
.Select(x => new RegistryItemEditModel
{
Id = x.Id,
CategoryId = x.CategoryId,
Name = x.Name,
PictureUrl = x.PictureUrl,
ProductUrl = x.ProductUrl,
@@ -533,6 +581,7 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
return new RegistryItemEditModel
{
Id = item.Id,
CategoryId = item.CategoryId,
Name = item.Name,
PictureUrl = item.PictureUrl,
ProductUrl = item.ProductUrl,
@@ -573,6 +622,8 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
throw new ArgumentException("Item name is required.", nameof(model));
}
var defaultCategory = await EnsureDefaultCategoryAsync(registryId, cancellationToken).ConfigureAwait(false);
RegistryItem entity;
if (model.Id is { } itemId)
{
@@ -582,15 +633,45 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
}
else
{
var categoryId = model.CategoryId ?? defaultCategory.Id;
var nextSortOrder = await registryDbContext.RegistryItems
.Where(x => x.RegistryId == registryId && x.CategoryId == categoryId)
.Select(x => (int?)x.SortOrder)
.MaxAsync(cancellationToken)
.ConfigureAwait(false);
entity = new RegistryItem
{
Id = Guid.NewGuid(),
RegistryId = registryId,
CategoryId = categoryId,
SortOrder = (nextSortOrder ?? -1) + 1,
CreatedAtUtc = DateTimeOffset.UtcNow
};
registryDbContext.RegistryItems.Add(entity);
}
if (model.CategoryId.HasValue && model.CategoryId.Value != entity.CategoryId)
{
var categoryExists = await registryDbContext.RegistryItemCategories
.AnyAsync(x => x.RegistryId == registryId && x.Id == model.CategoryId.Value, cancellationToken)
.ConfigureAwait(false);
if (!categoryExists)
{
throw new InvalidOperationException("Category not found.");
}
var nextSortOrder = await registryDbContext.RegistryItems
.Where(x => x.RegistryId == registryId && x.CategoryId == model.CategoryId.Value && x.Id != entity.Id)
.Select(x => (int?)x.SortOrder)
.MaxAsync(cancellationToken)
.ConfigureAwait(false);
entity.CategoryId = model.CategoryId.Value;
entity.SortOrder = (nextSortOrder ?? -1) + 1;
}
entity.Name = model.Name.Trim();
entity.PictureUrl = string.IsNullOrWhiteSpace(model.PictureUrl) ? null : model.PictureUrl.Trim();
entity.ProductUrl = string.IsNullOrWhiteSpace(model.ProductUrl) ? null : model.ProductUrl.Trim();
@@ -939,4 +1020,425 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
return userId;
}
public async Task<IReadOnlyList<RegistryAccessibleUserAddressViewModel>> GetRegistryAccessibleUserAddressesAsync(Guid registryId, CancellationToken cancellationToken)
{
var userIds = new HashSet<string>(StringComparer.Ordinal);
var adminIds = await registryDbContext.RegistryAdmins
.Where(x => x.RegistryId == registryId)
.Select(x => x.UserId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var adminId in adminIds)
{
userIds.Add(adminId);
}
var visitorIds = await registryDbContext.RegistryVisits
.Where(x => x.RegistryId == registryId)
.Select(x => x.UserId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var visitorId in visitorIds)
{
userIds.Add(visitorId);
}
var itemIds = await registryDbContext.RegistryItems
.Where(x => x.RegistryId == registryId)
.Select(x => x.Id)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (itemIds.Count > 0)
{
var purchaserIds = await registryDbContext.ItemPurchases
.Where(x => itemIds.Contains(x.RegistryItemId))
.Select(x => x.UserId)
.Distinct()
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var purchaserId in purchaserIds)
{
userIds.Add(purchaserId);
}
var contributorIds = await registryDbContext.ItemContributions
.Where(x => itemIds.Contains(x.RegistryItemId))
.Select(x => x.UserId)
.Distinct()
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
foreach (var contributorId in contributorIds)
{
userIds.Add(contributorId);
}
}
if (userIds.Count == 0)
{
return [];
}
var users = await applicationDbContext.Users
.Where(x => userIds.Contains(x.Id))
.Select(x => new
{
x.Id,
x.Email,
x.FirstName,
x.LastName,
x.Address
})
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return users
.Select(x => new RegistryAccessibleUserAddressViewModel
{
UserId = x.Id,
DisplayName = BuildUserDisplayName(x.FirstName, x.LastName, x.Email, x.Id),
Email = x.Email,
Address = string.IsNullOrWhiteSpace(x.Address) ? null : x.Address.Trim()
})
.OrderBy(x => x.DisplayName)
.ToList();
}
public async Task<IReadOnlyList<RegistryItemCategoryEditModel>> GetRegistryItemCategoriesAsync(Guid registryId, CancellationToken cancellationToken)
{
var categories = await registryDbContext.RegistryItemCategories
.Where(x => x.RegistryId == registryId)
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Name)
.Select(x => new RegistryItemCategoryEditModel
{
Id = x.Id,
Name = x.Name,
SortOrder = x.SortOrder
})
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (categories.Count > 0)
{
return categories;
}
var createdCategory = await EnsureDefaultCategoryAsync(registryId, cancellationToken).ConfigureAwait(false);
return
[
new RegistryItemCategoryEditModel
{
Id = createdCategory.Id,
Name = createdCategory.Name,
SortOrder = createdCategory.SortOrder,
Items = []
}
];
}
public async Task AddRegistryItemCategoryAsync(Guid registryId, string categoryName, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(categoryName))
{
throw new ArgumentException("Category name is required.", nameof(categoryName));
}
var normalizedName = categoryName.Trim();
var exists = await registryDbContext.RegistryItemCategories
.AnyAsync(x => x.RegistryId == registryId && x.Name == normalizedName, cancellationToken)
.ConfigureAwait(false);
if (exists)
{
throw new InvalidOperationException("A category with this name already exists.");
}
var sortOrder = await registryDbContext.RegistryItemCategories
.Where(x => x.RegistryId == registryId)
.Select(x => (int?)x.SortOrder)
.MaxAsync(cancellationToken)
.ConfigureAwait(false);
registryDbContext.RegistryItemCategories.Add(new RegistryItemCategory
{
Id = Guid.NewGuid(),
RegistryId = registryId,
Name = normalizedName,
SortOrder = (sortOrder ?? -1) + 1,
CreatedAtUtc = DateTimeOffset.UtcNow
});
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
public async Task RenameRegistryItemCategoryAsync(Guid registryId, Guid categoryId, string categoryName, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(categoryName))
{
throw new ArgumentException("Category name is required.", nameof(categoryName));
}
var normalizedName = categoryName.Trim();
var category = await registryDbContext.RegistryItemCategories
.FirstOrDefaultAsync(x => x.RegistryId == registryId && x.Id == categoryId, cancellationToken)
.ConfigureAwait(false) ?? throw new InvalidOperationException("Category not found.");
var exists = await registryDbContext.RegistryItemCategories
.AnyAsync(x => x.RegistryId == registryId && x.Id != categoryId && x.Name == normalizedName, cancellationToken)
.ConfigureAwait(false);
if (exists)
{
throw new InvalidOperationException("A category with this name already exists.");
}
category.Name = normalizedName;
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
public async Task RemoveRegistryItemCategoryAsync(Guid registryId, Guid categoryId, CancellationToken cancellationToken)
{
var categoryCount = await registryDbContext.RegistryItemCategories
.Where(x => x.RegistryId == registryId)
.CountAsync(cancellationToken)
.ConfigureAwait(false);
if (categoryCount <= 1)
{
throw new InvalidOperationException("At least one category is required.");
}
var category = await registryDbContext.RegistryItemCategories
.FirstOrDefaultAsync(x => x.RegistryId == registryId && x.Id == categoryId, cancellationToken)
.ConfigureAwait(false);
if (category is null)
{
return;
}
var fallbackCategory = await registryDbContext.RegistryItemCategories
.Where(x => x.RegistryId == registryId && x.Id != categoryId)
.OrderBy(x => x.SortOrder)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (fallbackCategory is null)
{
throw new InvalidOperationException("At least one category is required.");
}
var categoryItems = await registryDbContext.RegistryItems
.Where(x => x.RegistryId == registryId && x.CategoryId == categoryId)
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var destinationSortOrder = await registryDbContext.RegistryItems
.Where(x => x.RegistryId == registryId && x.CategoryId == fallbackCategory.Id)
.Select(x => (int?)x.SortOrder)
.MaxAsync(cancellationToken)
.ConfigureAwait(false);
var nextSort = (destinationSortOrder ?? -1) + 1;
foreach (var item in categoryItems)
{
item.CategoryId = fallbackCategory.Id;
item.SortOrder = nextSort;
nextSort++;
}
registryDbContext.RegistryItemCategories.Remove(category);
await NormalizeCategoryOrderAsync(registryId, cancellationToken).ConfigureAwait(false);
await NormalizeItemOrderAsync(registryId, fallbackCategory.Id, cancellationToken).ConfigureAwait(false);
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
public async Task MoveRegistryItemAsync(Guid registryId, Guid itemId, Guid categoryId, int targetIndex, CancellationToken cancellationToken)
{
var item = await registryDbContext.RegistryItems
.FirstOrDefaultAsync(x => x.RegistryId == registryId && x.Id == itemId, cancellationToken)
.ConfigureAwait(false) ?? throw new InvalidOperationException("Item not found.");
var categoryExists = await registryDbContext.RegistryItemCategories
.AnyAsync(x => x.RegistryId == registryId && x.Id == categoryId, cancellationToken)
.ConfigureAwait(false);
if (!categoryExists)
{
throw new InvalidOperationException("Category not found.");
}
var sourceCategoryId = item.CategoryId;
var targetItems = await registryDbContext.RegistryItems
.Where(x => x.RegistryId == registryId && x.CategoryId == categoryId && x.Id != itemId)
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (targetIndex < 0)
{
targetIndex = 0;
}
if (targetIndex > targetItems.Count)
{
targetIndex = targetItems.Count;
}
targetItems.Insert(targetIndex, item);
for (var i = 0; i < targetItems.Count; i++)
{
targetItems[i].CategoryId = categoryId;
targetItems[i].SortOrder = i;
}
if (sourceCategoryId != categoryId)
{
await NormalizeItemOrderAsync(registryId, sourceCategoryId, cancellationToken).ConfigureAwait(false);
}
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
public async Task MoveRegistryCategoryAsync(Guid registryId, Guid categoryId, int targetIndex, CancellationToken cancellationToken)
{
var categories = await registryDbContext.RegistryItemCategories
.Where(x => x.RegistryId == registryId)
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var category = categories.FirstOrDefault(x => x.Id == categoryId);
if (category is null)
{
return;
}
categories.Remove(category);
if (targetIndex < 0)
{
targetIndex = 0;
}
if (targetIndex > categories.Count)
{
targetIndex = categories.Count;
}
categories.Insert(targetIndex, category);
for (var i = 0; i < categories.Count; i++)
{
categories[i].SortOrder = i;
}
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
private async Task<RegistryItemCategory> EnsureDefaultCategoryAsync(Guid registryId, CancellationToken cancellationToken)
{
var categories = await registryDbContext.RegistryItemCategories
.Where(x => x.RegistryId == registryId)
.OrderBy(x => x.SortOrder)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
RegistryItemCategory defaultCategory;
if (categories.Count == 0)
{
defaultCategory = new RegistryItemCategory
{
Id = Guid.NewGuid(),
RegistryId = registryId,
Name = DefaultCategoryName,
SortOrder = 0,
CreatedAtUtc = DateTimeOffset.UtcNow
};
registryDbContext.RegistryItemCategories.Add(defaultCategory);
}
else
{
defaultCategory = categories.FirstOrDefault(x => x.Name == DefaultCategoryName) ?? categories[0];
}
var unassignedItems = await registryDbContext.RegistryItems
.Where(x => x.RegistryId == registryId && x.CategoryId == Guid.Empty)
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Name)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (unassignedItems.Count > 0)
{
var nextSortOrder = await registryDbContext.RegistryItems
.Where(x => x.RegistryId == registryId && x.CategoryId == defaultCategory.Id)
.Select(x => (int?)x.SortOrder)
.MaxAsync(cancellationToken)
.ConfigureAwait(false);
var next = (nextSortOrder ?? -1) + 1;
foreach (var item in unassignedItems)
{
item.CategoryId = defaultCategory.Id;
item.SortOrder = next;
next++;
}
}
await NormalizeCategoryOrderAsync(registryId, cancellationToken).ConfigureAwait(false);
await NormalizeItemOrderAsync(registryId, defaultCategory.Id, cancellationToken).ConfigureAwait(false);
if (registryDbContext.ChangeTracker.HasChanges())
{
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
return defaultCategory;
}
private async Task NormalizeCategoryOrderAsync(Guid registryId, CancellationToken cancellationToken)
{
var categories = await registryDbContext.RegistryItemCategories
.Where(x => x.RegistryId == registryId)
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.CreatedAtUtc)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
for (var i = 0; i < categories.Count; i++)
{
categories[i].SortOrder = i;
}
}
private async Task NormalizeItemOrderAsync(Guid registryId, Guid categoryId, CancellationToken cancellationToken)
{
var items = await registryDbContext.RegistryItems
.Where(x => x.RegistryId == registryId && x.CategoryId == categoryId)
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.CreatedAtUtc)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
for (var i = 0; i < items.Count; i++)
{
items[i].SortOrder = i;
}
}
}