@salesforce/ui-bundle-template-app-react-sample-b2x 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/CHANGELOG.md +9 -0
  2. package/dist/README.md +77 -27
  3. package/dist/force-app/main/default/applications/PropertyManagement.app-meta.xml +33 -0
  4. package/dist/force-app/main/default/data/Application__c.json +10 -10
  5. package/dist/force-app/main/default/data/data-plan.json +0 -6
  6. package/dist/force-app/main/default/objects/Application__c/fields/User__c.field-meta.xml +2 -2
  7. package/dist/force-app/main/default/objects/Property__c/Property__c.object-meta.xml +1 -1
  8. package/dist/force-app/main/default/permissionsets/Property_Management_Access.permissionset-meta.xml +76 -0
  9. package/dist/force-app/main/default/permissionsets/Property_Rental_Guest_User_Access.permissionset-meta.xml +217 -0
  10. package/dist/force-app/main/default/permissionsets/Tenant_Maintenance_Access.permissionset-meta.xml +1 -1
  11. package/dist/force-app/main/default/profiles/Property Rental Prospect Profile.profile-meta.xml +295 -0
  12. package/dist/force-app/main/default/roles/Admin.role-meta.xml +9 -0
  13. package/dist/force-app/main/default/sharingrules/Property__c.sharingRules-meta.xml +17 -0
  14. package/dist/force-app/main/default/tabs/Agent__c.tab-meta.xml +5 -0
  15. package/dist/force-app/main/default/tabs/Application__c.tab-meta.xml +5 -0
  16. package/dist/force-app/main/default/tabs/KPI_Snapshot__c.tab-meta.xml +5 -0
  17. package/dist/force-app/main/default/tabs/Lease__c.tab-meta.xml +5 -0
  18. package/dist/force-app/main/default/tabs/Maintenance_Request__c.tab-meta.xml +5 -0
  19. package/dist/force-app/main/default/tabs/Maintenance_Worker__c.tab-meta.xml +5 -0
  20. package/dist/force-app/main/default/tabs/Notification__c.tab-meta.xml +5 -0
  21. package/dist/force-app/main/default/tabs/Payment__c.tab-meta.xml +5 -0
  22. package/dist/force-app/main/default/tabs/Property_Cost__c.tab-meta.xml +5 -0
  23. package/dist/force-app/main/default/tabs/Property_Feature__c.tab-meta.xml +5 -0
  24. package/dist/force-app/main/default/tabs/Property_Image__c.tab-meta.xml +5 -0
  25. package/dist/force-app/main/default/tabs/Property_Listing__c.tab-meta.xml +5 -0
  26. package/dist/force-app/main/default/tabs/Property_Management_Company__c.tab-meta.xml +5 -0
  27. package/dist/force-app/main/default/tabs/Property_Owner__c.tab-meta.xml +5 -0
  28. package/dist/force-app/main/default/tabs/Property_Sale__c.tab-meta.xml +5 -0
  29. package/dist/force-app/main/default/tabs/Property__c.tab-meta.xml +5 -0
  30. package/dist/force-app/main/default/tabs/Tenant__c.tab-meta.xml +5 -0
  31. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/api/applications/applicationApi.ts +5 -6
  32. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/api/graphql-operations-types.ts +14751 -2937
  33. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/api/maintenanceRequests/maintenanceRequestApi.ts +4 -0
  34. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/api/query/tenantProperties.graphql +24 -0
  35. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/api/tenants/tenantApi.ts +23 -0
  36. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/alerts/status-alert.tsx +11 -8
  37. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/components/ui/input.tsx +1 -1
  38. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/context/TenantAccessContext.tsx +24 -12
  39. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/api/userProfileApi.ts +2 -1
  40. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/authenticationConfig.ts +9 -9
  41. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/context/AuthContext.tsx +21 -4
  42. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/forms/auth-form.tsx +15 -1
  43. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/hooks/form.tsx +1 -1
  44. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/layouts/privateRouteLayout.tsx +2 -11
  45. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/pages/ChangePassword.tsx +20 -4
  46. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/pages/ForgotPassword.tsx +19 -4
  47. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/pages/Login.tsx +19 -4
  48. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/pages/Profile.tsx +80 -43
  49. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/pages/Register.tsx +15 -4
  50. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/pages/ResetPassword.tsx +19 -4
  51. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/utils/helpers.ts +15 -52
  52. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/object-search/__examples__/pages/AccountObjectDetailPage.tsx +2 -4
  53. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/object-search/__examples__/pages/AccountSearch.tsx +7 -15
  54. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/object-search/components/filters/NumericRangeFilter.tsx +9 -5
  55. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/object-search/components/filters/SearchFilter.tsx +5 -3
  56. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/object-search/components/filters/TextFilter.tsx +5 -3
  57. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/object-search/hooks/useAsyncData.ts +11 -4
  58. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/hooks/useAsyncData.ts +67 -0
  59. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/hooks/useGeocode.ts +5 -9
  60. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/hooks/useMaintenanceRequests.ts +3 -11
  61. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/hooks/usePropertyDetail.ts +6 -17
  62. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/hooks/usePropertyMapMarkers.ts +43 -34
  63. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/hooks/useWeather.ts +2 -3
  64. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/pages/Application.tsx +45 -44
  65. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/pages/Contact.tsx +5 -9
  66. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/pages/Home.tsx +2 -3
  67. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/pages/Maintenance.tsx +43 -15
  68. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/pages/PropertySearch.tsx +21 -19
  69. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/routes.tsx +19 -25
  70. package/dist/package-lock.json +2 -2
  71. package/dist/package.json +1 -1
  72. package/dist/scripts/org-setup.config.json +18 -3
  73. package/dist/scripts/org-setup.mjs +528 -44
  74. package/package.json +1 -1
  75. package/dist/force-app/main/default/data/Contact.json +0 -44
  76. package/dist/force-app/main/default/scripts/org-setup.config.json +0 -9
  77. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/api/query/tenantAccess.graphql +0 -13
  78. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/api/tenantApi.ts +0 -12
  79. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/layout/card-skeleton.tsx +0 -38
  80. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/authentication/layouts/authenticationRouteLayout.tsx +0 -21
  81. package/dist/force-app/main/default/uiBundles/propertyrentalapp/src/features/object-search/hooks/useCachedAsyncData.ts +0 -188
@@ -2,14 +2,14 @@ import { useState, useEffect } from "react";
2
2
  import { z } from "zod";
3
3
 
4
4
  import { CenteredPageLayout } from "../layout/centered-page-layout";
5
- import { CardSkeleton } from "../layout/card-skeleton";
6
5
  import { AuthForm } from "../forms/auth-form";
7
6
  import { useAppForm } from "../hooks/form";
8
7
  import { ROUTES } from "../authenticationConfig";
9
8
  import { emailSchema } from "../authHelpers";
10
- import { getErrorMessage } from "../utils/helpers";
11
9
  import { useUser } from "../context/AuthContext";
12
10
  import { fetchUserProfile, updateUserProfile } from "../api/userProfileApi";
11
+ import { Skeleton } from "../../../components/ui/skeleton";
12
+ import { Field, FieldLabel } from "../../../components/ui/field";
13
13
 
14
14
  const optionalString = z
15
15
  .string()
@@ -32,6 +32,38 @@ const profileSchema = z.object({
32
32
 
33
33
  type ProfileFormValues = z.infer<typeof profileSchema>;
34
34
 
35
+ function FieldSkeleton({ label }: { label: string }) {
36
+ return (
37
+ <Field>
38
+ <FieldLabel>{label}</FieldLabel>
39
+ <Skeleton className="h-9 w-full rounded-md" />
40
+ </Field>
41
+ );
42
+ }
43
+
44
+ function ProfileFieldsSkeleton() {
45
+ return (
46
+ <div role="status" aria-live="polite">
47
+ <span className="sr-only">Loading profile…</span>
48
+ <FieldSkeleton label="Email" />
49
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 items-start">
50
+ <FieldSkeleton label="First name" />
51
+ <FieldSkeleton label="Last name" />
52
+ </div>
53
+ <FieldSkeleton label="Phone" />
54
+ <FieldSkeleton label="Street" />
55
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 items-start">
56
+ <FieldSkeleton label="City" />
57
+ <FieldSkeleton label="State" />
58
+ </div>
59
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 items-start">
60
+ <FieldSkeleton label="Postal Code" />
61
+ <FieldSkeleton label="Country" />
62
+ </div>
63
+ </div>
64
+ );
65
+ }
66
+
35
67
  export default function Profile() {
36
68
  const user = useUser();
37
69
  const [profile, setProfile] = useState<ProfileFormValues | null>(null);
@@ -62,7 +94,8 @@ export default function Profile() {
62
94
  setSuccess(true);
63
95
  window.scrollTo({ top: 0, behavior: "smooth" });
64
96
  } catch (err) {
65
- setSubmitError(getErrorMessage(err, "Failed to update profile"));
97
+ console.error("Failed to update profile", err);
98
+ setSubmitError("Failed to update profile");
66
99
  }
67
100
  },
68
101
  onSubmitInvalid: () => {},
@@ -88,10 +121,9 @@ export default function Profile() {
88
121
  }
89
122
  })
90
123
  .catch((err: any) => {
124
+ console.error("Failed to load profile", err);
91
125
  if (mounted) {
92
- setLoadError(getErrorMessage(err, "Failed to load profile"));
93
- } else {
94
- console.error("Failed to load profile", err);
126
+ setLoadError("Failed to load profile");
95
127
  }
96
128
  });
97
129
  return () => {
@@ -106,9 +138,7 @@ export default function Profile() {
106
138
  }
107
139
  }, [profile, form]);
108
140
 
109
- if (!profile && !loadError) {
110
- return <CardSkeleton contentMaxWidth="md" loadingText="Loading profile…" />;
111
- }
141
+ const loading = !profile && !loadError;
112
142
 
113
143
  return (
114
144
  <CenteredPageLayout contentMaxWidth="md" title={ROUTES.PROFILE.TITLE}>
@@ -116,44 +146,51 @@ export default function Profile() {
116
146
  <AuthForm
117
147
  title="Profile"
118
148
  description="Update your account information"
149
+ showAlreadyLoggedIn={false}
119
150
  error={loadError ?? submitError}
120
151
  success={success && "Profile updated!"}
121
- submit={{ text: "Save Changes", loadingText: "Saving…" }}
152
+ submit={{ text: "Save Changes", loadingText: "Saving…", disabled: loading }}
122
153
  footer={{ link: ROUTES.CHANGE_PASSWORD.PATH, linkText: "Change password" }}
123
154
  >
124
- <form.AppField name="Email">
125
- {(field) => <field.EmailField label="Email" disabled />}
126
- </form.AppField>
127
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 items-start">
128
- <form.AppField name="FirstName">
129
- {(field) => <field.TextField label="First name" autoComplete="given-name" />}
130
- </form.AppField>
131
- <form.AppField name="LastName">
132
- {(field) => <field.TextField label="Last name" autoComplete="family-name" />}
133
- </form.AppField>
134
- </div>
135
- <form.AppField name="Phone">
136
- {(field) => <field.TextField label="Phone" type="tel" autoComplete="tel" />}
137
- </form.AppField>
138
- <form.AppField name="Street">
139
- {(field) => <field.TextField label="Street" autoComplete="street-address" />}
140
- </form.AppField>
141
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 items-start">
142
- <form.AppField name="City">
143
- {(field) => <field.TextField label="City" autoComplete="address-level2" />}
144
- </form.AppField>
145
- <form.AppField name="State">
146
- {(field) => <field.TextField label="State" autoComplete="address-level1" />}
147
- </form.AppField>
148
- </div>
149
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 items-start">
150
- <form.AppField name="PostalCode">
151
- {(field) => <field.TextField label="Postal Code" autoComplete="postal-code" />}
152
- </form.AppField>
153
- <form.AppField name="Country">
154
- {(field) => <field.TextField label="Country" autoComplete="country-name" />}
155
- </form.AppField>
156
- </div>
155
+ {loading ? (
156
+ <ProfileFieldsSkeleton />
157
+ ) : (
158
+ <>
159
+ <form.AppField name="Email">
160
+ {(field) => <field.EmailField label="Email" disabled />}
161
+ </form.AppField>
162
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 items-start">
163
+ <form.AppField name="FirstName">
164
+ {(field) => <field.TextField label="First name" autoComplete="given-name" />}
165
+ </form.AppField>
166
+ <form.AppField name="LastName">
167
+ {(field) => <field.TextField label="Last name" autoComplete="family-name" />}
168
+ </form.AppField>
169
+ </div>
170
+ <form.AppField name="Phone">
171
+ {(field) => <field.TextField label="Phone" type="tel" autoComplete="tel" />}
172
+ </form.AppField>
173
+ <form.AppField name="Street">
174
+ {(field) => <field.TextField label="Street" autoComplete="street-address" />}
175
+ </form.AppField>
176
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 items-start">
177
+ <form.AppField name="City">
178
+ {(field) => <field.TextField label="City" autoComplete="address-level2" />}
179
+ </form.AppField>
180
+ <form.AppField name="State">
181
+ {(field) => <field.TextField label="State" autoComplete="address-level1" />}
182
+ </form.AppField>
183
+ </div>
184
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 items-start">
185
+ <form.AppField name="PostalCode">
186
+ {(field) => <field.TextField label="Postal Code" autoComplete="postal-code" />}
187
+ </form.AppField>
188
+ <form.AppField name="Country">
189
+ {(field) => <field.TextField label="Country" autoComplete="country-name" />}
190
+ </form.AppField>
191
+ </div>
192
+ </>
193
+ )}
157
194
  </AuthForm>
158
195
  </form.AppForm>
159
196
  </CenteredPageLayout>
@@ -7,7 +7,7 @@ import { useAppForm } from "../hooks/form";
7
7
  import { createDataSDK } from "@salesforce/platform-sdk-data";
8
8
  import { ROUTES, AUTH_PLACEHOLDERS } from "../authenticationConfig";
9
9
  import { emailSchema, passwordSchema, getStartUrl, type AuthResponse } from "../authHelpers";
10
- import { handleApiResponse, getErrorMessage } from "../utils/helpers";
10
+ import { ApiError, handleApiResponse } from "../utils/helpers";
11
11
 
12
12
  const registerSchema = z
13
13
  .object({
@@ -26,7 +26,7 @@ const registerSchema = z
26
26
  export default function Register() {
27
27
  const navigate = useNavigate();
28
28
  const [searchParams] = useSearchParams();
29
- const [submitError, setSubmitError] = useState<string | null>(null);
29
+ const [submitError, setSubmitError] = useState<React.ReactNode>(null);
30
30
 
31
31
  const form = useAppForm({
32
32
  defaultValues: {
@@ -56,7 +56,7 @@ export default function Register() {
56
56
  Accept: "application/json",
57
57
  },
58
58
  });
59
- const result = await handleApiResponse<AuthResponse>(response, "Registration failed");
59
+ const result = await handleApiResponse<AuthResponse>(response);
60
60
  if (result?.redirectUrl) {
61
61
  // Hard navigate to the URL which logs the new user in
62
62
  window.location.replace(result.redirectUrl);
@@ -65,7 +65,18 @@ export default function Register() {
65
65
  navigate(ROUTES.LOGIN.PATH, { replace: true });
66
66
  }
67
67
  } catch (err) {
68
- setSubmitError(getErrorMessage(err, "Registration failed"));
68
+ console.error("Registration failed", err);
69
+ if (err instanceof ApiError) {
70
+ setSubmitError(
71
+ <ul>
72
+ {err.errors.map((e, i) => (
73
+ <li key={i}>{e}</li>
74
+ ))}
75
+ </ul>,
76
+ );
77
+ } else {
78
+ setSubmitError("Registration failed");
79
+ }
69
80
  }
70
81
  },
71
82
  onSubmitInvalid: () => {},
@@ -8,13 +8,13 @@ import { useAppForm } from "../hooks/form";
8
8
  import { createDataSDK } from "@salesforce/platform-sdk-data";
9
9
  import { ROUTES, AUTH_PLACEHOLDERS } from "../authenticationConfig";
10
10
  import { newPasswordSchema } from "../authHelpers";
11
- import { handleApiResponse, getErrorMessage } from "../utils/helpers";
11
+ import { ApiError, handleApiResponse } from "../utils/helpers";
12
12
 
13
13
  export default function ResetPassword() {
14
14
  const [searchParams] = useSearchParams();
15
15
  const token = searchParams.get("token");
16
16
  const [success, setSuccess] = useState(false);
17
- const [submitError, setSubmitError] = useState<string | null>(null);
17
+ const [submitError, setSubmitError] = useState<React.ReactNode>(null);
18
18
 
19
19
  const form = useAppForm({
20
20
  defaultValues: { newPassword: "", confirmPassword: "" },
@@ -34,12 +34,27 @@ export default function ResetPassword() {
34
34
  Accept: "application/json",
35
35
  },
36
36
  });
37
- await handleApiResponse(response, "Password reset failed");
37
+ await handleApiResponse(response);
38
38
  setSuccess(true);
39
39
  // Scroll to top of page after successful submission so user sees it
40
40
  window.scrollTo({ top: 0, behavior: "smooth" });
41
41
  } catch (err) {
42
- setSubmitError(getErrorMessage(err, "Password reset failed"));
42
+ console.error("Password reset failed", err);
43
+ if (err instanceof ApiError) {
44
+ setSubmitError(
45
+ err.errors.length === 1 ? (
46
+ err.errors[0]
47
+ ) : (
48
+ <ul>
49
+ {err.errors.map((e, i) => (
50
+ <li key={i}>{e}</li>
51
+ ))}
52
+ </ul>
53
+ ),
54
+ );
55
+ } else {
56
+ setSubmitError("Password reset failed");
57
+ }
43
58
  }
44
59
  },
45
60
  onSubmitInvalid: () => {},
@@ -1,29 +1,22 @@
1
1
  /**
2
- * MAINTAINABILITY: Robust error extraction.
3
- * Handles strings, objects, and standard Error instances.
4
- *
5
- * @param err - The error object (unknown type)
6
- * @param fallback - Fallback message if error doesn't have a message property
7
- * @returns The error message string
2
+ * Error thrown when the API returns a non-OK response with structured error messages.
3
+ * The `errors` array contains user-facing messages that are safe to display —
4
+ * backend Apex classes guarantee that system exceptions are never exposed.
8
5
  */
9
- export function getErrorMessage(err: unknown, fallback: string): string {
10
- if (err instanceof Error) return err.message;
11
- if (typeof err === "string") return err;
12
- // Check if it's an object with a message property
13
- if (typeof err === "object" && err !== null && "message" in err) {
14
- return String((err as { message: unknown }).message);
6
+ export class ApiError extends Error {
7
+ errors: string[];
8
+ constructor(errors: string[]) {
9
+ super(errors[0]);
10
+ this.name = "ApiError";
11
+ this.errors = errors;
15
12
  }
16
- return fallback;
17
13
  }
18
14
 
19
15
  /**
20
16
  * [Dev Note] Helper to parse the fetch Response.
21
17
  * It handles the distinction between success (JSON) and failure (throwing Error).
22
18
  */
23
- export async function handleApiResponse<T = unknown>(
24
- response: Response,
25
- fallbackError: string,
26
- ): Promise<T> {
19
+ export async function handleApiResponse<T = unknown>(response: Response): Promise<T> {
27
20
  // 1. Robustness: Handle 204 No Content gracefully
28
21
  if (response.status === 204) {
29
22
  return {} as T;
@@ -41,9 +34,11 @@ export async function handleApiResponse<T = unknown>(
41
34
  }
42
35
 
43
36
  if (!response.ok) {
44
- // [Dev Note] Throwing here allows the calling component to catch and
45
- // display the error via getErrorMessage()
46
- throw new Error(parseApiResponseError(data, fallbackError));
37
+ console.error("API request failed", data);
38
+ if (data?.errors?.length) {
39
+ throw new ApiError(data.errors);
40
+ }
41
+ throw new Error("An unexpected error occurred");
47
42
  }
48
43
 
49
44
  return data as T;
@@ -87,35 +82,3 @@ export function flattenGraphQLRecord<T>(
87
82
  ]),
88
83
  ) as T;
89
84
  }
90
-
91
- /**
92
- * [Dev Note] Salesforce APIs may return errors as an array or a single object.
93
- * This helper standardizes the extraction of the error message string.
94
- *
95
- * @param data - The response data.
96
- * @param fallbackError - Fallback error message if response doesn't have a message property
97
- * @returns The error message string
98
- */
99
- function parseApiResponseError(
100
- data: any,
101
- fallbackError: string = "An unknown error occurred",
102
- ): string {
103
- if (data?.message) {
104
- return data.message;
105
- }
106
- if (data?.error) {
107
- return data.error;
108
- }
109
- if (data?.errors && Array.isArray(data.errors) && data.errors.length > 0) {
110
- return data.errors.join(" ") || fallbackError;
111
- }
112
- if (Array.isArray(data) && data.length > 0) {
113
- return (
114
- data
115
- .map((e) => e?.message)
116
- .filter(Boolean)
117
- .join(" ") || fallbackError
118
- );
119
- }
120
- return fallbackError;
121
- }
@@ -18,7 +18,7 @@ import {
18
18
  } from "../../../../components/ui/collapsible";
19
19
  import { Separator } from "../../../../components/ui/separator";
20
20
  import { Skeleton } from "../../../../components/ui/skeleton";
21
- import { useCachedAsyncData } from "../../hooks/useCachedAsyncData";
21
+ import { useAsyncData } from "../../hooks/useAsyncData";
22
22
  import { ObjectBreadcrumb } from "../../components/ObjectBreadcrumb";
23
23
 
24
24
  type AccountNode = NonNullable<
@@ -49,9 +49,7 @@ export default function AccountObjectDetail() {
49
49
  data: account,
50
50
  loading,
51
51
  error,
52
- } = useCachedAsyncData(() => fetchAccountDetail(recordId!), [recordId], {
53
- key: `account:${recordId}`,
54
- });
52
+ } = useAsyncData(() => fetchAccountDetail(recordId!), [recordId]);
55
53
 
56
54
  return (
57
55
  <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
@@ -6,7 +6,7 @@ import {
6
6
  fetchDistinctIndustries,
7
7
  fetchDistinctTypes,
8
8
  } from "../api/accountSearchService";
9
- import { useCachedAsyncData } from "../../hooks/useCachedAsyncData";
9
+ import { useAsyncData } from "../../hooks/useAsyncData";
10
10
  import { fieldValue } from "../../utils/fieldUtils";
11
11
  import { useObjectSearchParams } from "../../hooks/useObjectSearchParams";
12
12
  import { Alert, AlertTitle, AlertDescription } from "../../../../components/ui/alert";
@@ -42,8 +42,8 @@ import PaginationControls from "../../components/PaginationControls";
42
42
  import type { PaginationConfig } from "../../hooks/useObjectSearchParams";
43
43
 
44
44
  const PAGINATION_CONFIG: PaginationConfig = {
45
- defaultPageSize: 6,
46
- validPageSizes: [6, 12, 24, 48],
45
+ defaultPageSize: 7,
46
+ validPageSizes: [7, 14, 28, 42],
47
47
  };
48
48
 
49
49
  type AccountNode = NonNullable<
@@ -77,22 +77,15 @@ const ACCOUNT_SORT_CONFIGS: SortFieldConfig<keyof Account_OrderBy>[] = [
77
77
 
78
78
  export default function AccountSearch() {
79
79
  const [filtersOpen, setFiltersOpen] = useState(true);
80
- const { data: industryOptions } = useCachedAsyncData(fetchDistinctIndustries, [], {
81
- key: "distinctIndustries",
82
- ttl: 300_000,
83
- });
84
- const { data: typeOptions } = useCachedAsyncData(fetchDistinctTypes, [], {
85
- key: "distinctTypes",
86
- ttl: 300_000,
87
- });
80
+ const { data: industryOptions } = useAsyncData(fetchDistinctIndustries, []);
81
+ const { data: typeOptions } = useAsyncData(fetchDistinctTypes, []);
88
82
 
89
83
  const { filters, sort, query, pagination, resetAll } = useObjectSearchParams<
90
84
  Account_Filter,
91
85
  Account_OrderBy
92
86
  >(FILTER_CONFIGS, ACCOUNT_SORT_CONFIGS, PAGINATION_CONFIG);
93
87
 
94
- const searchKey = `accounts:${JSON.stringify({ where: query.where, orderBy: query.orderBy, first: pagination.pageSize, after: pagination.afterCursor })}`;
95
- const { data, loading, error } = useCachedAsyncData(
88
+ const { data, loading, error } = useAsyncData(
96
89
  () =>
97
90
  searchAccounts({
98
91
  where: query.where,
@@ -101,7 +94,6 @@ export default function AccountSearch() {
101
94
  after: pagination.afterCursor,
102
95
  }),
103
96
  [query.where, query.orderBy, pagination.pageSize, pagination.afterCursor],
104
- { key: searchKey },
105
97
  );
106
98
 
107
99
  const pageInfo = data?.pageInfo;
@@ -196,7 +188,7 @@ export default function AccountSearch() {
196
188
  <ActiveFilters filters={filters.active} onRemove={filters.remove} />
197
189
  </div>
198
190
 
199
- <div className="min-h-112">
191
+ <div className="min-h-132">
200
192
  {/* Loading state */}
201
193
  {loading && (
202
194
  <>
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from "react";
1
+ import { useState } from "react";
2
2
  import { Input } from "../../../../components/ui/input";
3
3
 
4
4
  import { useFilterField } from "../FilterContext";
@@ -67,12 +67,16 @@ export function NumericRangeFilterInputs({
67
67
 
68
68
  const externalMin = value?.min ?? "";
69
69
  const externalMax = value?.max ?? "";
70
- useEffect(() => {
70
+ const [prevExternalMin, setPrevExternalMin] = useState(externalMin);
71
+ const [prevExternalMax, setPrevExternalMax] = useState(externalMax);
72
+ if (prevExternalMin !== externalMin) {
73
+ setPrevExternalMin(externalMin);
71
74
  setLocalMin(externalMin);
72
- }, [externalMin]);
73
- useEffect(() => {
75
+ }
76
+ if (prevExternalMax !== externalMax) {
77
+ setPrevExternalMax(externalMax);
74
78
  setLocalMax(externalMax);
75
- }, [externalMax]);
79
+ }
76
80
 
77
81
  const isOutOfBounds = (v: string) => {
78
82
  if (v === "") return false;
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from "react";
1
+ import { useState } from "react";
2
2
 
3
3
  import { SearchBar } from "../SearchBar";
4
4
  import { useFilterField } from "../FilterContext";
@@ -22,9 +22,11 @@ export function SearchFilter({
22
22
  const [localValue, setLocalValue] = useState(value?.value ?? "");
23
23
 
24
24
  const externalValue = value?.value ?? "";
25
- useEffect(() => {
25
+ const [prevExternalValue, setPrevExternalValue] = useState(externalValue);
26
+ if (prevExternalValue !== externalValue) {
27
+ setPrevExternalValue(externalValue);
26
28
  setLocalValue(externalValue);
27
- }, [externalValue]);
29
+ }
28
30
 
29
31
  const debouncedOnChange = useDebouncedCallback((v: string) => {
30
32
  if (v) {
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from "react";
1
+ import { useState } from "react";
2
2
  import { Input } from "../../../../components/ui/input";
3
3
  import { cn } from "../../../../lib/utils";
4
4
  import { useFilterField } from "../FilterContext";
@@ -62,9 +62,11 @@ export function TextFilterInput({
62
62
  const [localValue, setLocalValue] = useState(value?.value ?? "");
63
63
 
64
64
  const externalValue = value?.value ?? "";
65
- useEffect(() => {
65
+ const [prevExternalValue, setPrevExternalValue] = useState(externalValue);
66
+ if (prevExternalValue !== externalValue) {
67
+ setPrevExternalValue(externalValue);
66
68
  setLocalValue(externalValue);
67
- }, [externalValue]);
69
+ }
68
70
 
69
71
  const debouncedOnChange = useDebouncedCallback((v: string) => {
70
72
  if (v) {
@@ -22,16 +22,24 @@ export function useAsyncData<T>(
22
22
  const [data, setData] = useState<T | null>(null);
23
23
  const [loading, setLoading] = useState(true);
24
24
  const [error, setError] = useState<string | null>(null);
25
+ const [generation, setGeneration] = useState(0);
25
26
 
26
27
  const fetcherRef = useRef(fetcher);
27
28
  useEffect(() => {
28
29
  fetcherRef.current = fetcher;
29
30
  });
30
31
 
32
+ // Detect dep changes during render to reset loading state and bump generation
33
+ const [prevDeps, setPrevDeps] = useState(deps);
34
+ if (deps.length !== prevDeps.length || deps.some((d, i) => d !== prevDeps[i])) {
35
+ setPrevDeps(deps);
36
+ setGeneration((g) => g + 1);
37
+ if (!loading) setLoading(true);
38
+ if (error !== null) setError(null);
39
+ }
40
+
31
41
  useEffect(() => {
32
42
  let cancelled = false;
33
- setLoading(true);
34
- setError(null);
35
43
 
36
44
  fetcherRef
37
45
  .current()
@@ -49,8 +57,7 @@ export function useAsyncData<T>(
49
57
  return () => {
50
58
  cancelled = true;
51
59
  };
52
- // eslint-disable-next-line react-hooks/exhaustive-deps --- deps are explicitly managed by the caller
53
- }, deps);
60
+ }, [generation]);
54
61
 
55
62
  return { data, loading, error };
56
63
  }
@@ -0,0 +1,67 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+
3
+ interface UseAsyncDataResult<T> {
4
+ data: T | null;
5
+ loading: boolean;
6
+ error: string | null;
7
+ }
8
+
9
+ /**
10
+ * Runs an async fetcher on mount and whenever `deps` change.
11
+ * Returns the loading/error/data state. Does not cache — every call
12
+ * to the fetcher hits the source directly.
13
+ *
14
+ * A cleanup flag prevents state updates if the component unmounts
15
+ * or deps change before the fetch completes (avoids React warnings
16
+ * and stale updates from out-of-order responses).
17
+ */
18
+ export function useAsyncData<T>(
19
+ fetcher: () => Promise<T>,
20
+ deps: React.DependencyList
21
+ ): UseAsyncDataResult<T> {
22
+ const [data, setData] = useState<T | null>(null);
23
+ const [loading, setLoading] = useState(true);
24
+ const [error, setError] = useState<string | null>(null);
25
+ const [generation, setGeneration] = useState(0);
26
+
27
+ const fetcherRef = useRef(fetcher);
28
+ useEffect(() => {
29
+ fetcherRef.current = fetcher;
30
+ });
31
+
32
+ // Detect dep changes during render to reset loading state and bump generation
33
+ const [prevDeps, setPrevDeps] = useState(deps);
34
+ if (
35
+ deps.length !== prevDeps.length ||
36
+ deps.some((d, i) => d !== prevDeps[i])
37
+ ) {
38
+ setPrevDeps(deps);
39
+ setGeneration(g => g + 1);
40
+ if (!loading) setLoading(true);
41
+ if (error !== null) setError(null);
42
+ }
43
+
44
+ useEffect(() => {
45
+ let cancelled = false;
46
+
47
+ fetcherRef
48
+ .current()
49
+ .then(result => {
50
+ if (!cancelled) setData(result);
51
+ })
52
+ .catch(err => {
53
+ console.error(err);
54
+ if (!cancelled)
55
+ setError(err instanceof Error ? err.message : 'An error occurred');
56
+ })
57
+ .finally(() => {
58
+ if (!cancelled) setLoading(false);
59
+ });
60
+
61
+ return () => {
62
+ cancelled = true;
63
+ };
64
+ }, [generation]);
65
+
66
+ return { data, loading, error };
67
+ }
@@ -1,5 +1,5 @@
1
1
  import { geocodeAddress, getStateZipFromAddress, type GeocodeResult } from "@/utils/geocode";
2
- import { useCachedAsyncData } from "@/features/object-search/hooks/useCachedAsyncData";
2
+ import { useAsyncData } from "@/hooks/useAsyncData";
3
3
 
4
4
  async function geocodeWithFallback(address: string): Promise<GeocodeResult | null> {
5
5
  const normalized = address.replace(/\n/g, ", ").trim();
@@ -20,14 +20,10 @@ export function useGeocode(address: string | null | undefined): {
20
20
  } {
21
21
  const trimmed = address?.trim() ?? "";
22
22
 
23
- const { data: coords, loading } = useCachedAsyncData(
24
- () => {
25
- if (!trimmed) return Promise.resolve(null);
26
- return geocodeWithFallback(trimmed);
27
- },
28
- [trimmed],
29
- { key: `geocode:${trimmed}`, ttl: 600_000 },
30
- );
23
+ const { data: coords, loading } = useAsyncData(() => {
24
+ if (!trimmed) return Promise.resolve(null);
25
+ return geocodeWithFallback(trimmed);
26
+ }, [trimmed]);
31
27
 
32
28
  return { coords, loading };
33
29
  }