From 64f8b0cf0dc618eef175d5762c17cdc9f373db74 Mon Sep 17 00:00:00 2001 From: dadachi Date: Mon, 27 Apr 2026 18:15:44 +0900 Subject: [PATCH] Add client-side length caps + truncation for Shop name/description Ports https://github.com/nativeapptemplate/NativeAppTemplate-iOS/pull/60. Mirror the ItemTag pattern from PR #49 for Shop. Server has no caps on Shop name/description; this is a client-only UX guard. - Constants: maximumShopNameLength = 100, maximumShopDescriptionLength = 1_000. New strings: shopNameIsInvalid, shopDescriptionIsInvalid, plus shopNameHelp(maximumLength:) / shopDescriptionHelp(maximumLength:) parametric helpers. - ShopCreateViewModel + ShopBasicSettingsViewModel: split hasInvalidData into hasInvalidDataName + hasInvalidDataDescription, expose maximumNameLength / maximumDescriptionLength, add validateNameLength() / validateDescriptionLength(). - ShopCreateView + ShopBasicSettingsView: wire .onChange truncation on Name and Description; switch to two-line footer (always-visible help + conditional red "is invalid" text), matching ItemTagCreateView. - Tests: added maximumNameLength, maximumDescriptionLength, parametric nameValidation / descriptionValidation, and the two truncation tests on both viewmodel test suites; replaced the old simple hasInvalidData(name:) parametric test in ShopCreateViewModelTest. Co-Authored-By: Claude Opus 4.7 (1M context) --- NativeAppTemplate/Constants.swift | 16 +++ .../UI/Shop List/ShopCreateView.swift | 27 +++- .../UI/Shop List/ShopCreateViewModel.swift | 32 ++++- .../Shop Settings/ShopBasicSettingsView.swift | 24 +++- .../ShopBasicSettingsViewModel.swift | 36 +++++- .../Shop List/ShopCreateViewModelTest.swift | 81 +++++++++++- .../ShopBasicSettingsViewModelTest.swift | 121 ++++++++++++++++++ 7 files changed, 327 insertions(+), 10 deletions(-) diff --git a/NativeAppTemplate/Constants.swift b/NativeAppTemplate/Constants.swift index a4c130a..893c929 100644 --- a/NativeAppTemplate/Constants.swift +++ b/NativeAppTemplate/Constants.swift @@ -72,6 +72,11 @@ enum NativeAppTemplateConstants { static let shadowRadius: CGFloat = 8 } + // MARK: - Shop + + static let maximumShopNameLength = 100 + static let maximumShopDescriptionLength = 1_000 + // MARK: - ItemTag static let maximumItemTagNameLength = 100 @@ -146,6 +151,17 @@ extension String { static let addShopDescription = "Add a new shop." static let deleteShop = "Delete Shop" static let shopNameIsRequired = "Shop name is required." + static let shopNameIsInvalid = "Shop name is invalid." + static let shopDescriptionIsInvalid = "Shop description is too long." + + static func shopNameHelp(maximumLength: Int) -> String { + "Name must be 1–\(maximumLength) characters." + } + + static func shopDescriptionHelp(maximumLength: Int) -> String { + "Description can be up to \(maximumLength) characters." + } + static let timeZone = "Time Zone" static let createShopsLabel = "Create shops" static let tapShopBelow = "Tap a shop below." diff --git a/NativeAppTemplate/UI/Shop List/ShopCreateView.swift b/NativeAppTemplate/UI/Shop List/ShopCreateView.swift index 970a6a1..dadf7f6 100644 --- a/NativeAppTemplate/UI/Shop List/ShopCreateView.swift +++ b/NativeAppTemplate/UI/Shop List/ShopCreateView.swift @@ -36,14 +36,37 @@ struct ShopCreateView: View { Form { Section { TextField(String.name, text: $viewModel.name) + .onChange(of: viewModel.name) { + viewModel.validateNameLength() + } + } header: { + Text(String.shopName) } footer: { - Text(String.shopNameIsRequired) - .foregroundStyle(viewModel.hasInvalidData ? .validationError : .clear) + VStack(alignment: .leading) { + Text(String.shopNameHelp(maximumLength: viewModel.maximumNameLength)) + .font(.uiFootnote) + Text(String.shopNameIsInvalid) + .font(.uiFootnote) + .foregroundStyle(viewModel.hasInvalidDataName ? .validationError : .clear) + } } Section { TextField(String.descriptionString, text: $viewModel.description, axis: .vertical) .lineLimit(10, reservesSpace: true) + .onChange(of: viewModel.description) { + viewModel.validateDescriptionLength() + } + } header: { + Text(String.descriptionString) + } footer: { + VStack(alignment: .leading) { + Text(String.shopDescriptionHelp(maximumLength: viewModel.maximumDescriptionLength)) + .font(.uiFootnote) + Text(String.shopDescriptionIsInvalid) + .font(.uiFootnote) + .foregroundStyle(viewModel.hasInvalidDataDescription ? .validationError : .clear) + } } Section { diff --git a/NativeAppTemplate/UI/Shop List/ShopCreateViewModel.swift b/NativeAppTemplate/UI/Shop List/ShopCreateViewModel.swift index 9ba91f1..5adc44a 100644 --- a/NativeAppTemplate/UI/Shop List/ShopCreateViewModel.swift +++ b/NativeAppTemplate/UI/Shop List/ShopCreateViewModel.swift @@ -31,7 +31,37 @@ final class ShopCreateViewModel { } var hasInvalidData: Bool { - Utility.isBlank(name) + hasInvalidDataName || hasInvalidDataDescription + } + + var hasInvalidDataName: Bool { + if Utility.isBlank(name) { + return true + } + if name.count > maximumNameLength { + return true + } + return false + } + + var hasInvalidDataDescription: Bool { + description.count > maximumDescriptionLength + } + + var maximumNameLength: Int { + NativeAppTemplateConstants.maximumShopNameLength + } + + var maximumDescriptionLength: Int { + NativeAppTemplateConstants.maximumShopDescriptionLength + } + + func validateNameLength() { + name = String(name.prefix(maximumNameLength)) + } + + func validateDescriptionLength() { + description = String(description.prefix(maximumDescriptionLength)) } func createShop() { diff --git a/NativeAppTemplate/UI/Shop Settings/ShopBasicSettingsView.swift b/NativeAppTemplate/UI/Shop Settings/ShopBasicSettingsView.swift index 5551d09..4a04fc7 100644 --- a/NativeAppTemplate/UI/Shop Settings/ShopBasicSettingsView.swift +++ b/NativeAppTemplate/UI/Shop Settings/ShopBasicSettingsView.swift @@ -45,19 +45,37 @@ private extension ShopBasicSettingsView { Form { Section { TextField(String.shopName, text: $viewModel.name) + .onChange(of: viewModel.name) { + viewModel.validateNameLength() + } } header: { Text(String.shopName) } footer: { - Text(String.shopNameIsRequired) - .font(.uiFootnote) - .foregroundStyle(Utility.isBlank(viewModel.name) ? .validationError : .clear) + VStack(alignment: .leading) { + Text(String.shopNameHelp(maximumLength: viewModel.maximumNameLength)) + .font(.uiFootnote) + Text(String.shopNameIsInvalid) + .font(.uiFootnote) + .foregroundStyle(viewModel.hasInvalidDataName ? .validationError : .clear) + } } Section { TextField(String.descriptionString, text: $viewModel.description, axis: .vertical) .lineLimit(10, reservesSpace: true) + .onChange(of: viewModel.description) { + viewModel.validateDescriptionLength() + } } header: { Text(String.descriptionString) + } footer: { + VStack(alignment: .leading) { + Text(String.shopDescriptionHelp(maximumLength: viewModel.maximumDescriptionLength)) + .font(.uiFootnote) + Text(String.shopDescriptionIsInvalid) + .font(.uiFootnote) + .foregroundStyle(viewModel.hasInvalidDataDescription ? .validationError : .clear) + } } Section { diff --git a/NativeAppTemplate/UI/Shop Settings/ShopBasicSettingsViewModel.swift b/NativeAppTemplate/UI/Shop Settings/ShopBasicSettingsViewModel.swift index bba5758..e373746 100644 --- a/NativeAppTemplate/UI/Shop Settings/ShopBasicSettingsViewModel.swift +++ b/NativeAppTemplate/UI/Shop Settings/ShopBasicSettingsViewModel.swift @@ -39,7 +39,11 @@ final class ShopBasicSettingsViewModel { } var hasInvalidData: Bool { - if Utility.isBlank(name) { + if hasInvalidDataName { + return true + } + + if hasInvalidDataDescription { return true } @@ -54,6 +58,36 @@ final class ShopBasicSettingsViewModel { return false } + var hasInvalidDataName: Bool { + if Utility.isBlank(name) { + return true + } + if name.count > maximumNameLength { + return true + } + return false + } + + var hasInvalidDataDescription: Bool { + description.count > maximumDescriptionLength + } + + var maximumNameLength: Int { + NativeAppTemplateConstants.maximumShopNameLength + } + + var maximumDescriptionLength: Int { + NativeAppTemplateConstants.maximumShopDescriptionLength + } + + func validateNameLength() { + name = String(name.prefix(maximumNameLength)) + } + + func validateDescriptionLength() { + description = String(description.prefix(maximumDescriptionLength)) + } + func reload() { Task { @MainActor in isFetching = true diff --git a/NativeAppTemplateTests/UI/Shop List/ShopCreateViewModelTest.swift b/NativeAppTemplateTests/UI/Shop List/ShopCreateViewModelTest.swift index ce489f0..3eadf87 100644 --- a/NativeAppTemplateTests/UI/Shop List/ShopCreateViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Shop List/ShopCreateViewModelTest.swift @@ -27,8 +27,36 @@ struct ShopCreateViewModelTest { #expect(viewModel.isCreating == false) } - @Test("Has invalid data", arguments: ["", "Shop Name 1"]) - func hasInvalidData(name: String) { + @Test + func maximumNameLength() { + let viewModel = ShopCreateViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + messageBus: messageBus + ) + + #expect(viewModel.maximumNameLength == 100) + } + + @Test + func maximumDescriptionLength() { + let viewModel = ShopCreateViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + messageBus: messageBus + ) + + #expect(viewModel.maximumDescriptionLength == 1_000) + } + + @Test("Name validation", arguments: [ + ("", true), // blank → invalid + ("a", false), // 1 char → valid + ("Shop Name 1", false), // normal → valid + (String(repeating: "a", count: 100), false), // exactly 100 → valid + (String(repeating: "a", count: 101), true) // 101 → invalid + ]) + func nameValidation(name: String, shouldBeInvalid: Bool) { let viewModel = ShopCreateViewModel( sessionController: sessionController, shopRepository: shopRepository, @@ -36,7 +64,54 @@ struct ShopCreateViewModelTest { ) viewModel.name = name - #expect(viewModel.hasInvalidData == (name == "" ? true : false)) + + #expect(viewModel.hasInvalidDataName == shouldBeInvalid) + } + + @Test("Description validation", arguments: [ + ("", false), // empty → valid + ("Short note.", false), // short → valid + (String(repeating: "x", count: 1000), false), // exactly 1000 → valid + (String(repeating: "x", count: 1001), true) // 1001 → invalid + ]) + func descriptionValidation(description: String, shouldBeInvalid: Bool) { + let viewModel = ShopCreateViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + messageBus: messageBus + ) + + viewModel.description = description + + #expect(viewModel.hasInvalidDataDescription == shouldBeInvalid) + } + + @Test + func validateNameLengthTruncatesCorrectly() { + let viewModel = ShopCreateViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + messageBus: messageBus + ) + + viewModel.name = String(repeating: "a", count: 100) + "EXTRA" + viewModel.validateNameLength() + + #expect(viewModel.name == String(repeating: "a", count: 100)) + } + + @Test + func validateDescriptionLengthTruncatesCorrectly() { + let viewModel = ShopCreateViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + messageBus: messageBus + ) + + viewModel.description = String(repeating: "x", count: 1500) + viewModel.validateDescriptionLength() + + #expect(viewModel.description.count == 1_000) } @Test diff --git a/NativeAppTemplateTests/UI/Shop Settings/ShopBasicSettingsViewModelTest.swift b/NativeAppTemplateTests/UI/Shop Settings/ShopBasicSettingsViewModelTest.swift index e06b42c..fd9c580 100644 --- a/NativeAppTemplateTests/UI/Shop Settings/ShopBasicSettingsViewModelTest.swift +++ b/NativeAppTemplateTests/UI/Shop Settings/ShopBasicSettingsViewModelTest.swift @@ -85,6 +85,127 @@ struct ShopBasicSettingsViewModelTest { #expect(viewModel.hasInvalidData == (name == "Shop 1" ? true : false)) } + @Test + func maximumNameLength() { + let viewModel = ShopBasicSettingsViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + messageBus: messageBus, + shopId: shopId + ) + + #expect(viewModel.maximumNameLength == 100) + } + + @Test + func maximumDescriptionLength() { + let viewModel = ShopBasicSettingsViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + messageBus: messageBus, + shopId: shopId + ) + + #expect(viewModel.maximumDescriptionLength == 1_000) + } + + @Test("Name validation", arguments: [ + ("", true), // blank → invalid + ("a", false), // 1 char → valid + ("Shop Name 1", false), // normal → valid + (String(repeating: "a", count: 100), false), // exactly 100 → valid + (String(repeating: "a", count: 101), true) // 101 → invalid + ]) + func nameValidation(name: String, shouldBeInvalid: Bool) async { + shopRepository.setShops(shops: shops) + + let viewModel = ShopBasicSettingsViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + messageBus: messageBus, + shopId: shopId + ) + + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + viewModel.name = name + + #expect(viewModel.hasInvalidDataName == shouldBeInvalid) + } + + @Test("Description validation", arguments: [ + ("", false), // empty → valid + ("Short note.", false), // short → valid + (String(repeating: "x", count: 1000), false), // exactly 1000 → valid + (String(repeating: "x", count: 1001), true) // 1001 → invalid + ]) + func descriptionValidation(description: String, shouldBeInvalid: Bool) async { + shopRepository.setShops(shops: shops) + + let viewModel = ShopBasicSettingsViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + messageBus: messageBus, + shopId: shopId + ) + + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + viewModel.description = description + + #expect(viewModel.hasInvalidDataDescription == shouldBeInvalid) + } + + @Test + func validateNameLengthTruncatesCorrectly() async { + shopRepository.setShops(shops: shops) + + let viewModel = ShopBasicSettingsViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + messageBus: messageBus, + shopId: shopId + ) + + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + viewModel.name = String(repeating: "a", count: 100) + "EXTRA" + viewModel.validateNameLength() + + #expect(viewModel.name == String(repeating: "a", count: 100)) + } + + @Test + func validateDescriptionLengthTruncatesCorrectly() async { + shopRepository.setShops(shops: shops) + + let viewModel = ShopBasicSettingsViewModel( + sessionController: sessionController, + shopRepository: shopRepository, + messageBus: messageBus, + shopId: shopId + ) + + let reloadTask = Task { + viewModel.reload() + } + await reloadTask.value + + viewModel.description = String(repeating: "x", count: 1500) + viewModel.validateDescriptionLength() + + #expect(viewModel.description.count == 1_000) + } + @Test func reload() async throws { shopRepository.setShops(shops: shops)