From f52f4bd4b597f8c774369c7cdc0f736d9d8599dc Mon Sep 17 00:00:00 2001 From: Scott Clampet Date: Mon, 24 Nov 2025 11:06:59 -0600 Subject: [PATCH 1/2] make Project.Category optional and safely unwrap it --- KsApi/models/Project.swift | 2 +- .../adapters/Project+ProjectFragment.swift | 6 ++- KsApi/models/lenses/ProjectLenses.swift | 43 ++++++++++++++++++- KsApi/models/templates/ProjectTemplates.swift | 6 +-- .../Tracking/ProjectAnalyticsProperties.swift | 6 +-- .../ActivityFriendBackingViewModel.swift | 2 +- .../DiscoveryPostcardViewModel.swift | 12 +++--- .../DiscoveryProjectCardViewModel.swift | 6 +-- .../ProjectPamphletMainCellProperties.swift | 6 +-- .../ProjectPamphletMainCellViewModel.swift | 2 +- Library/ViewModels/ThanksViewModel.swift | 6 ++- 11 files changed, 73 insertions(+), 24 deletions(-) diff --git a/KsApi/models/Project.swift b/KsApi/models/Project.swift index 2d042f3694..7e5f408b60 100644 --- a/KsApi/models/Project.swift +++ b/KsApi/models/Project.swift @@ -5,7 +5,7 @@ import ReactiveSwift public struct Project { public var availableCardTypes: [String]? public var blurb: String - public var category: Category + public var category: Category? public var country: Country public var creator: User public var extendedProjectProperties: ExtendedProjectProperties? diff --git a/KsApi/models/graphql/adapters/Project+ProjectFragment.swift b/KsApi/models/graphql/adapters/Project+ProjectFragment.swift index fc56a56673..19e232f14e 100644 --- a/KsApi/models/graphql/adapters/Project+ProjectFragment.swift +++ b/KsApi/models/graphql/adapters/Project+ProjectFragment.swift @@ -25,7 +25,6 @@ extension Project { currency: projectFragment.currency.value ), let categoryFragment = projectFragment.category?.fragments.categoryFragment, - let category = Project.Category.category(from: categoryFragment), let dates = projectDates(from: projectFragment), let memberData = projectMemberData(from: projectFragment), let photo = projectPhoto(from: projectFragment), @@ -34,6 +33,11 @@ extension Project { let creator = User.user(from: userFragment) else { return nil } + var category: Category? + if let categoryFragment = projectFragment.category?.fragments.categoryFragment { + category = Project.Category.category(from: categoryFragment) + } + var location: Location? if let locationFragment = projectFragment.location?.fragments.locationFragment { location = Location.location(from: locationFragment) diff --git a/KsApi/models/lenses/ProjectLenses.swift b/KsApi/models/lenses/ProjectLenses.swift index 532664229c..2f58628620 100644 --- a/KsApi/models/lenses/ProjectLenses.swift +++ b/KsApi/models/lenses/ProjectLenses.swift @@ -104,7 +104,7 @@ extension Project { ) } ) - public static let category = Lens( + public static let category = Lens( view: { $0.category }, set: { Project( availableCardTypes: $1.availableCardTypes, blurb: $1.blurb, category: $0, country: $1.country, @@ -133,6 +133,47 @@ extension Project { ) } ) + public static let categoryName = Lens( + view: { $0.category?.name ?? "" }, + set: { newName, project in + let updatedCategory: Project.Category? = project.category.map { category in + Project.Category(id: category.id, name: newName) + } + + return Project( + availableCardTypes: project.availableCardTypes, + blurb: project.blurb, + category: updatedCategory, + country: project.country, + creator: project.creator, + extendedProjectProperties: project.extendedProjectProperties, + memberData: project.memberData, dates: project.dates, + displayPrelaunch: project.displayPrelaunch, flagging: project.flagging, id: project.id, + lastWave: project.lastWave, + location: project.location, + name: project.name, + pledgeManager: project.pledgeManager, + pledgeOverTimeCollectionPlanChargeExplanation: project + .pledgeOverTimeCollectionPlanChargeExplanation, + pledgeOverTimeCollectionPlanChargedAsNPayments: project + .pledgeOverTimeCollectionPlanChargedAsNPayments, + pledgeOverTimeCollectionPlanShortPitch: project.pledgeOverTimeCollectionPlanShortPitch, + pledgeOverTimeMinimumExplanation: project.pledgeOverTimeMinimumExplanation, + personalization: project.personalization, photo: project.photo, + isInPostCampaignPledgingPhase: project.isInPostCampaignPledgingPhase, + postCampaignPledgingEnabled: project.postCampaignPledgingEnabled, + prelaunchActivated: project.prelaunchActivated, + redemptionPageUrl: project.redemptionPageUrl, + rewardData: project.rewardData, + sendMetaCapiEvents: project.sendMetaCapiEvents, slug: project.slug, + staffPick: project.staffPick, state: project.state, stats: project.stats, tags: project.tags, + urls: project.urls, + video: project.video, watchesCount: project.watchesCount, + isPledgeOverTimeAllowed: project.isPledgeOverTimeAllowed + ) + } + ) + public static let country = Lens( view: { $0.country }, set: { Project( diff --git a/KsApi/models/templates/ProjectTemplates.swift b/KsApi/models/templates/ProjectTemplates.swift index 3b88795929..d81e936869 100644 --- a/KsApi/models/templates/ProjectTemplates.swift +++ b/KsApi/models/templates/ProjectTemplates.swift @@ -76,7 +76,7 @@ extension Project { |> Project.lens.photo.small .~ "https://i.kickstarter.com/assets/012/224/660/847bc4da31e6863e9351bee4e55b8005_original.jpg?fit=pad&height=90&origin=ugc&q=92&width=160&sig=rOHQ6Fif6TxwI%2BL8F9RQY0wUgN%2F4yusD%2FTGXhYW8w%2FQ%3D" |> Project.lens.name .~ "Today" |> Project.lens.blurb .~ "A 24-hour timepiece beautifully designed to change the way you see your day." - |> \.category.name .~ "Product Design" + |> Project.lens.categoryName .~ "Product Design" |> Project.lens.stats.backersCount .~ 1_090 |> Project.lens.stats.pledged .~ 212_870 |> Project.lens.stats.goal .~ 24_000 @@ -87,7 +87,7 @@ extension Project { |> Project.lens.photo.small .~ "https://i.kickstarter.com/assets/012/347/230/2eddca8c4a06ecb69b8787b985201b92_original.jpg?fit=contain&origin=ugc&q=92&width=460&sig=ewWbTA9q%2BTNYpB9KQnwXKCfjCJum57sWhpZkp%2FiwHKY%3D" |> Project.lens.name .~ "Cosmic Surgery" |> Project.lens.blurb .~ "Cosmic Surgery is a photo book, set in the not too distant future where the world of cosmetic surgery is about to be transformed." - |> \.category.name .~ "Photo Books" + |> Project.lens.categoryName .~ "Photo Books" |> Project.lens.stats.backersCount .~ 329 |> Project.lens.stats.pledged .~ 22_318 |> Project.lens.stats.goal .~ 22_000 @@ -114,7 +114,7 @@ extension Project { |> Project.lens.photo.med .~ "https://i.kickstarter.com/assets/005/055/025/6e0d27710c9ae20d661e2974e99fe239_original.jpg?fit=contain&origin=ugc&q=92&width=460&sig=C05wZhm%2Fm7cw9lbn9H05zOhA8ApoQ%2Bu%2FCAO%2FuGJDMo0%3D" |> Project.lens.name .~ "Charlie Kaufman's Anomalisa" |> Project.lens.blurb .~ "From writer Charlie Kaufman (Being John Malkovich, Eternal Sunshine of the Spotless Mind) and Duke Johnson (Moral Orel, Frankenhole) comes Anomalisa." - |> \.category.name .~ "Animation" + |> Project.lens.categoryName .~ "Animation" |> Project.lens.stats.backersCount .~ 5_770 |> Project.lens.stats.pledged .~ 406_237 |> Project.lens.stats.goal .~ 200_000 diff --git a/Library/Tracking/ProjectAnalyticsProperties.swift b/Library/Tracking/ProjectAnalyticsProperties.swift index 181dfbb4eb..bb09f7c34f 100644 --- a/Library/Tracking/ProjectAnalyticsProperties.swift +++ b/Library/Tracking/ProjectAnalyticsProperties.swift @@ -54,15 +54,15 @@ extension Project: HasProjectAnalyticsProperties { extension Project: ProjectAnalyticsProperties { public var categoryAnalyticsName: String? { - self.category.analyticsName + self.category?.analyticsName } public var categoryParentAnalyticsName: String? { - self.category.parentAnalyticsName + self.category?.parentAnalyticsName } public var categoryParentId: String? { - self.category.parentId.flatMap(String.init) + self.category?.parentId.flatMap(String.init) } public var countryCode: String { diff --git a/Library/ViewModels/ActivityFriendBackingViewModel.swift b/Library/ViewModels/ActivityFriendBackingViewModel.swift index 5ca2c6e3e1..9860fc956e 100644 --- a/Library/ViewModels/ActivityFriendBackingViewModel.swift +++ b/Library/ViewModels/ActivityFriendBackingViewModel.swift @@ -52,7 +52,7 @@ public final class ActivityFriendBackingViewModel: ActivityFriendBackingViewMode self.friendTitle = activity .map { activity in let stringCategoryId = ( - activity.project?.category.parentId ?? activity.project?.category.id + activity.project?.category?.parentId ?? activity.project?.category?.id ) .map(String.init) diff --git a/Library/ViewModels/DiscoveryPostcardViewModel.swift b/Library/ViewModels/DiscoveryPostcardViewModel.swift index cfbe3ab42f..48d8483a27 100644 --- a/Library/ViewModels/DiscoveryPostcardViewModel.swift +++ b/Library/ViewModels/DiscoveryPostcardViewModel.swift @@ -29,7 +29,7 @@ private enum PostcardMetadataType { iconAndTextColor: LegacyColors.ksr_create_700.uiColor() ) case .featured: - guard let rootCategory = project.category.parentName else { return nil } + guard let rootCategory = project.category?.parentName else { return nil } return PostcardMetadataData( iconImage: image(named: "metadata-featured"), labelText: Strings.discovery_baseball_card_metadata_featured_project( @@ -224,7 +224,7 @@ public final class DiscoveryPostcardViewModel: DiscoveryPostcardViewModelType, .map(fundingStatusText(forProject:)) self.projectCategoryName = configuredProject - .map { $0.category.name } + .map { $0.category?.name ?? "" } self.projectCategoryViewHidden = Signal.combineLatest( configuredProject, @@ -235,9 +235,11 @@ public final class DiscoveryPostcardViewModel: DiscoveryPostcardViewModelType, return false } - // if we are in a subcategory, compare categories - if !category.isRoot { - return Int(project.category.id) == category.intID + if let projectCategory = project.category { + // if we are in a subcategory, compare categories + if !category.isRoot { + return Int(projectCategory.id) == category.intID + } } // otherwise, always show category diff --git a/Library/ViewModels/DiscoveryProjectCardViewModel.swift b/Library/ViewModels/DiscoveryProjectCardViewModel.swift index fc41b10397..6effd92d2b 100644 --- a/Library/ViewModels/DiscoveryProjectCardViewModel.swift +++ b/Library/ViewModels/DiscoveryProjectCardViewModel.swift @@ -171,7 +171,7 @@ private func projectCategoryTagShouldHide(for project: Project, in category: KsA return false } - return project.category.id == category.intID + return project.category?.id == category.intID } private func projectPWLTagShouldHide(project: Project) -> Bool { @@ -194,10 +194,10 @@ private func projectTags(project: Project, category: KsApi.Category?) -> [Discov tags.append(pwlTag) } - if shouldShowCategoryTag { + if shouldShowCategoryTag, let category = project.category { let categoryTag = DiscoveryPillData( imageName: "icon--compass", - text: project.category.name, + text: category.name, type: .grey ) diff --git a/Library/ViewModels/ProjectPamphletMainCellProperties.swift b/Library/ViewModels/ProjectPamphletMainCellProperties.swift index 93ec801679..13cb08bcb1 100644 --- a/Library/ViewModels/ProjectPamphletMainCellProperties.swift +++ b/Library/ViewModels/ProjectPamphletMainCellProperties.swift @@ -28,7 +28,7 @@ public struct ProjectPamphletMainCellProperties { public let displayPrelaunch: Bool? public let isBacking: Bool? public let backersCount: Int - public let categoryName: String + public let categoryName: String? public let locationName: String public let deadline: TimeInterval? public let fxRate: Float @@ -58,7 +58,7 @@ public struct ProjectPamphletMainCellProperties { displayPrelaunch: Bool?, isBacking: Bool?, backersCount: Int, - categoryName: String, + categoryName: String?, locationName: String, deadline: TimeInterval?, fxRate: Float, @@ -127,7 +127,7 @@ extension Project: HasProjectPamphletMainCellProperties { displayPrelaunch: self.displayPrelaunch, isBacking: self.personalization.isBacking, backersCount: self.stats.backersCount, - categoryName: self.category.name, + categoryName: self.category?.name, locationName: self.location?.displayableName ?? "", deadline: self.dates.deadline, fxRate: self.stats.userCurrencyRate ?? self.stats.staticUsdRate, diff --git a/Library/ViewModels/ProjectPamphletMainCellViewModel.swift b/Library/ViewModels/ProjectPamphletMainCellViewModel.swift index 804b0a32d4..d62dcf695f 100644 --- a/Library/ViewModels/ProjectPamphletMainCellViewModel.swift +++ b/Library/ViewModels/ProjectPamphletMainCellViewModel.swift @@ -219,7 +219,7 @@ public final class ProjectPamphletMainCellViewModel: ProjectPamphletMainCellView self.backersTitleLabelText = backersTitleAndSubtitleText.map { title, _ in title ?? "" } self.backersSubtitleLabelText = backersTitleAndSubtitleText.map { _, subtitle in subtitle ?? "" } - self.categoryNameLabelText = properties.map { $0.categoryName } + self.categoryNameLabelText = properties.map { $0.categoryName ?? "" } let deadlineTitleAndSubtitle = properties.map { properties -> (String, String) in var durationValue = ("", "") diff --git a/Library/ViewModels/ThanksViewModel.swift b/Library/ViewModels/ThanksViewModel.swift index 100d9b7e22..433461278f 100644 --- a/Library/ViewModels/ThanksViewModel.swift +++ b/Library/ViewModels/ThanksViewModel.swift @@ -105,7 +105,7 @@ public final class ThanksViewModel: ThanksViewModelType, ThanksViewModelInputs, let shouldShowGamesAlert = project .map { project in - project.category.rootId == KsApi.Category.gamesId && + project.category?.rootId == KsApi.Category.gamesId && !(AppEnvironment.current.currentUser?.newsletters.games ?? false) && !AppEnvironment.current.userDefaults.hasSeenGamesNewsletterPrompt } @@ -138,7 +138,9 @@ public final class ThanksViewModel: ThanksViewModelType, ThanksViewModelInputs, } let rootCategory: Signal = project - .map { toBase64($0.category) } + .map { $0.category } + .skipNil() + .map(toBase64) .flatMap { AppEnvironment.current.apiService.fetchGraphCategory(id: $0) .ksr_delay(AppEnvironment.current.apiDelayInterval, on: AppEnvironment.current.scheduler) From a748afb265f99bca883d0b92f81b05b44af7bbeb Mon Sep 17 00:00:00 2001 From: Scott Clampet Date: Mon, 24 Nov 2025 13:06:24 -0600 Subject: [PATCH 2/2] update tests --- Library/Tracking/KSRAnalyticsTests.swift | 10 +++++-- .../DiscoveryPostcardViewModelTests.swift | 7 +++-- Library/ViewModels/PledgeViewModelTests.swift | 26 +++++++++++++------ 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/Library/Tracking/KSRAnalyticsTests.swift b/Library/Tracking/KSRAnalyticsTests.swift index 7b82078978..b66849a08b 100644 --- a/Library/Tracking/KSRAnalyticsTests.swift +++ b/Library/Tracking/KSRAnalyticsTests.swift @@ -302,7 +302,10 @@ final class KSRAnalyticsTests: TestCase { project.stats.percentFunded, segmentClientProperties?["project_percent_raised"] as? Int ) - XCTAssertEqual(project.category.analyticsName, segmentClientProperties?["project_subcategory"] as? String) + XCTAssertEqual( + project.category?.analyticsName, + segmentClientProperties?["project_subcategory"] as? String + ) XCTAssertEqual("Art", segmentClientProperties?["project_category"] as? String) XCTAssertEqual(String(project.creator.id), segmentClientProperties?["project_creator_uid"] as? String) XCTAssertEqual(24 * 15, segmentClientProperties?["project_hours_remaining"] as? Int) @@ -451,7 +454,10 @@ final class KSRAnalyticsTests: TestCase { let segmentClientProperties = segmentClient.properties.last - XCTAssertEqual(project.category.analyticsName, segmentClientProperties?["project_subcategory"] as? String) + XCTAssertEqual( + project.category?.analyticsName, + segmentClientProperties?["project_subcategory"] as? String + ) XCTAssertEqual("Art", segmentClientProperties?["project_category"] as? String) } diff --git a/Library/ViewModels/DiscoveryPostcardViewModelTests.swift b/Library/ViewModels/DiscoveryPostcardViewModelTests.swift index 4aa723f150..6c60503d4e 100644 --- a/Library/ViewModels/DiscoveryPostcardViewModelTests.swift +++ b/Library/ViewModels/DiscoveryPostcardViewModelTests.swift @@ -105,9 +105,8 @@ internal final class DiscoveryPostcardViewModelTests: TestCase { let backedProject = .template |> Project.lens.personalization.isBacking .~ true - let featuredProject = Project.template - |> \.category.parentId .~ Project.Category.art.id - |> \.category.parentName .~ Project.Category.art.name + let featuredProject = .template + |> \.category .~ Project.Category.art |> Project.lens.dates.featuredAt .~ featuredAt let backedColor: UIColor = LegacyColors.ksr_create_700.uiColor() @@ -143,7 +142,7 @@ internal final class DiscoveryPostcardViewModelTests: TestCase { self.metadataIconTintColor.assertValues([backedColor]) self.vm.inputs.configure(with: (featuredProject, nil, nil)) - guard let parentName = featuredProject.category.parentName else { return } + guard let parentName = featuredProject.category?.parentName else { return } self.metadataLabelText.assertValues( [ Strings.discovery_baseball_card_metadata_backer(), diff --git a/Library/ViewModels/PledgeViewModelTests.swift b/Library/ViewModels/PledgeViewModelTests.swift index cb53221ef3..6f265b722f 100644 --- a/Library/ViewModels/PledgeViewModelTests.swift +++ b/Library/ViewModels/PledgeViewModelTests.swift @@ -4398,11 +4398,16 @@ final class PledgeViewModelTests: TestCase { } func testTrackingEvents_PledgeScreenViewed_LoggedOut() { + let category = Project.Category( + analyticsName: Project.Category.illustration.name, + id: Project.Category.art.id, + name: Project.Category.illustration.name, + parentId: Project.Category.art.id, + parentName: Project.Category.art.name + ) + let project = Project.template - |> \.category.analyticsName .~ Project.Category.illustration.name - |> \.category.name .~ Project.Category.illustration.name - |> \.category.parentId .~ Project.Category.art.id - |> \.category.parentName .~ Project.Category.art.name + |> \.category .~ category let reward = Reward.template @@ -4460,11 +4465,16 @@ final class PledgeViewModelTests: TestCase { ) withEnvironment(currentUser: user, ksrAnalytics: ksrAnalytics) { + let category = Project.Category( + analyticsName: Project.Category.illustration.name, + id: Project.Category.art.id, + name: Project.Category.illustration.name, + parentId: Project.Category.art.id, + parentName: Project.Category.art.name + ) + let project = Project.template - |> \.category.name .~ Project.Category.illustration.name - |> \.category.analyticsName .~ Project.Category.illustration.name - |> \.category.parentId .~ Project.Category.art.id - |> \.category.parentName .~ Project.Category.art.name + |> \.category .~ category |> Project.lens.stats.userCurrency .~ "USD" |> \.personalization.isStarred .~ true