@simplybusiness/mobius 6.3.3 → 6.4.1

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.
package/dist/esm/index.js CHANGED
@@ -1778,14 +1778,21 @@ var LoqateAddressLookupService = class {
1778
1778
  * 2 or 3 character ISO country codes
1779
1779
  */
1780
1780
  #countries;
1781
+ /**
1782
+ * Optional filters for the Loqate API
1783
+ * E.g., { AdministrativeArea: "CA", PostalCode: "90210" }
1784
+ */
1785
+ #filters;
1781
1786
  constructor({
1782
1787
  baseUrl,
1783
1788
  apiKey,
1784
- countries
1789
+ countries,
1790
+ filters
1785
1791
  }) {
1786
1792
  this.#apiKey = apiKey;
1787
1793
  this.#baseUrl = baseUrl || LOQATE_BASE_URL;
1788
1794
  this.#countries = countries || DEFAULT_COUNTRIES;
1795
+ this.#filters = filters;
1789
1796
  }
1790
1797
  fetchFromApi(url) {
1791
1798
  return fetch(`${this.#baseUrl}${url}`).then((response) => response.json()).then((json) => {
@@ -1795,12 +1802,28 @@ var LoqateAddressLookupService = class {
1795
1802
  return json;
1796
1803
  });
1797
1804
  }
1805
+ /**
1806
+ * Builds the Filters query parameter for Loqate API requests.
1807
+ * - Filter keys (e.g., "AdministrativeArea", "PostalCode") are predefined by Loqate API (no need to encode)
1808
+ * - Filter values (e.g., "New York", "90210") contain user input that may have special characters (need encoding)
1809
+ *
1810
+ * @returns Empty string if no filters, otherwise "&Filters=key1:value1&key2:value2" (Loqate's expected format for Filters)
1811
+ */
1812
+ buildFiltersQuery() {
1813
+ if (!this.#filters || Object.keys(this.#filters).length === 0) {
1814
+ return "";
1815
+ }
1816
+ const encodedFilters = Object.entries(this.#filters).map(([key, value]) => `${key}:${encodeURIComponent(value)}`).join("&");
1817
+ return `&Filters=${encodedFilters}`;
1818
+ }
1798
1819
  search(searchTerm) {
1799
- const url = `${LOQATE_FIND_URL}?Key=${this.#apiKey}&Text=${searchTerm}&Countries=${this.#countries?.join(",")}`;
1820
+ let url = `${LOQATE_FIND_URL}?Key=${this.#apiKey}&Text=${searchTerm}&Countries=${this.#countries?.join(",")}`;
1821
+ url += this.buildFiltersQuery();
1800
1822
  return this.fetchFromApi(url);
1801
1823
  }
1802
1824
  findById(id) {
1803
- const url = `${LOQATE_FIND_URL}?Key=${this.#apiKey}&Container=${id}&Countries=${this.#countries?.join(",")}`;
1825
+ let url = `${LOQATE_FIND_URL}?Key=${this.#apiKey}&Container=${id}&Countries=${this.#countries?.join(",")}`;
1826
+ url += this.buildFiltersQuery();
1804
1827
  return this.fetchFromApi(url);
1805
1828
  }
1806
1829
  async get(id) {
@@ -1,12 +1,21 @@
1
1
  import type { LoqateAddressDetailsItem, LoqateSearchResponse } from "./types";
2
2
  export declare class LoqateAddressLookupService {
3
3
  #private;
4
- constructor({ baseUrl, apiKey, countries, }: {
4
+ constructor({ baseUrl, apiKey, countries, filters, }: {
5
5
  baseUrl?: string;
6
6
  apiKey?: string;
7
7
  countries?: string[];
8
+ filters?: Record<string, string>;
8
9
  });
9
10
  private fetchFromApi;
11
+ /**
12
+ * Builds the Filters query parameter for Loqate API requests.
13
+ * - Filter keys (e.g., "AdministrativeArea", "PostalCode") are predefined by Loqate API (no need to encode)
14
+ * - Filter values (e.g., "New York", "90210") contain user input that may have special characters (need encoding)
15
+ *
16
+ * @returns Empty string if no filters, otherwise "&Filters=key1:value1&key2:value2" (Loqate's expected format for Filters)
17
+ */
18
+ private buildFiltersQuery;
10
19
  search(searchTerm: string): Promise<LoqateSearchResponse>;
11
20
  findById(id: string): Promise<LoqateSearchResponse>;
12
21
  get(id: string): Promise<LoqateAddressDetailsItem>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@simplybusiness/mobius",
3
3
  "license": "UNLICENSED",
4
- "version": "6.3.3",
4
+ "version": "6.4.1",
5
5
  "description": "Core library of Mobius react components",
6
6
  "repository": {
7
7
  "type": "git",
@@ -6,6 +6,53 @@ describe("LoqateAddressLookupService", () => {
6
6
  const mockBaseUrl = "https://test-api.com";
7
7
  const mockCountries = ["GB", "USA"];
8
8
 
9
+ // Helper functions for mock responses
10
+ const createEmptyMockResponse = () => ({ items: [] });
11
+
12
+ const createAddressDetailsMockResponse = (overrides = {}) => ({
13
+ Items: [
14
+ {
15
+ Id: "address123",
16
+ Type: "Address",
17
+ City: "Test City",
18
+ Line1: "Test Line 1",
19
+ Line2: "Test Line 2",
20
+ PostalCode: "12345",
21
+ Label: "Test Label",
22
+ ...overrides,
23
+ },
24
+ ],
25
+ });
26
+
27
+ const createErrorMockResponse = (overrides = {}) => ({
28
+ Items: [
29
+ {
30
+ Error: "Test error code",
31
+ Description: "Test error",
32
+ Cause: "Test cause",
33
+ Resolution: "Test resolution",
34
+ ...overrides,
35
+ },
36
+ ],
37
+ });
38
+
39
+ // Helper function for creating service with filters
40
+ const createServiceWithFilters = (filters: Record<string, string>) => {
41
+ return new LoqateAddressLookupService({
42
+ baseUrl: mockBaseUrl,
43
+ apiKey: mockApiKey,
44
+ countries: mockCountries,
45
+ filters,
46
+ });
47
+ };
48
+
49
+ // Helper function for mocking fetch responses
50
+ const mockFetchResponse = (response: unknown) => {
51
+ (global.fetch as jest.Mock).mockResolvedValueOnce({
52
+ json: () => Promise.resolve(response),
53
+ });
54
+ };
55
+
9
56
  beforeEach(() => {
10
57
  global.fetch = jest.fn();
11
58
  service = new LoqateAddressLookupService({
@@ -23,10 +70,8 @@ describe("LoqateAddressLookupService", () => {
23
70
  const serviceWithDefaultUrl = new LoqateAddressLookupService({
24
71
  apiKey: mockApiKey,
25
72
  });
26
- const mockResponse = { items: [] };
27
- (global.fetch as jest.Mock).mockResolvedValueOnce({
28
- json: () => Promise.resolve(mockResponse),
29
- });
73
+ const mockResponse = createEmptyMockResponse();
74
+ mockFetchResponse(mockResponse);
30
75
 
31
76
  await serviceWithDefaultUrl.search("test");
32
77
 
@@ -37,25 +82,21 @@ describe("LoqateAddressLookupService", () => {
37
82
 
38
83
  describe("find", () => {
39
84
  it("calls the correct URL with search term", async () => {
40
- const mockResponse = { items: [] };
41
- (global.fetch as jest.Mock).mockResolvedValueOnce({
42
- json: () => Promise.resolve(mockResponse),
43
- });
85
+ const mockResponse = createEmptyMockResponse();
86
+ mockFetchResponse(mockResponse);
44
87
 
45
- await service.search("test address");
88
+ await service.search("123 Main Street");
46
89
 
47
90
  expect(global.fetch).toHaveBeenCalledWith(
48
- `${mockBaseUrl}/Find/v1.00/json3.ws?Key=${mockApiKey}&Text=test address&Countries=GB,USA`,
91
+ `${mockBaseUrl}/Find/v1.00/json3.ws?Key=${mockApiKey}&Text=123 Main Street&Countries=GB,USA`,
49
92
  );
50
93
  });
51
94
  });
52
95
 
53
96
  describe("findById", () => {
54
97
  it("calls the correct URL with container ID", async () => {
55
- const mockResponse = { items: [] };
56
- (global.fetch as jest.Mock).mockResolvedValueOnce({
57
- json: () => Promise.resolve(mockResponse),
58
- });
98
+ const mockResponse = createEmptyMockResponse();
99
+ mockFetchResponse(mockResponse);
59
100
 
60
101
  await service.findById("container123");
61
102
 
@@ -67,22 +108,8 @@ describe("LoqateAddressLookupService", () => {
67
108
 
68
109
  describe("get", () => {
69
110
  it("calls the correct URL with ID", async () => {
70
- const mockResponse = {
71
- Items: [
72
- {
73
- Id: "address123",
74
- Type: "Address",
75
- City: "Test City",
76
- Line1: "Test Line 1",
77
- Line2: "Test Line 2",
78
- PostalCode: "12345",
79
- Label: "Test Label",
80
- },
81
- ],
82
- };
83
- (global.fetch as jest.Mock).mockResolvedValueOnce({
84
- json: () => Promise.resolve(mockResponse),
85
- });
111
+ const mockResponse = createAddressDetailsMockResponse();
112
+ mockFetchResponse(mockResponse);
86
113
 
87
114
  await service.get("address123");
88
115
 
@@ -92,21 +119,131 @@ describe("LoqateAddressLookupService", () => {
92
119
  });
93
120
  });
94
121
 
122
+ describe("filters functionality", () => {
123
+ it("includes no filters when filters are not provided", async () => {
124
+ const mockResponse = createEmptyMockResponse();
125
+ mockFetchResponse(mockResponse);
126
+
127
+ await service.search("456 Oak Avenue");
128
+
129
+ expect(global.fetch).toHaveBeenCalledWith(
130
+ `${mockBaseUrl}/Find/v1.00/json3.ws?Key=${mockApiKey}&Text=456 Oak Avenue&Countries=GB,USA`,
131
+ );
132
+ });
133
+
134
+ it("includes single filter in search URL", async () => {
135
+ const serviceWithFilters = createServiceWithFilters({
136
+ AdministrativeArea: "NY",
137
+ });
138
+
139
+ const mockResponse = createEmptyMockResponse();
140
+ mockFetchResponse(mockResponse);
141
+
142
+ await serviceWithFilters.search("789 Broadway");
143
+
144
+ expect(global.fetch).toHaveBeenCalledWith(
145
+ `${mockBaseUrl}/Find/v1.00/json3.ws?Key=${mockApiKey}&Text=789 Broadway&Countries=GB,USA&Filters=AdministrativeArea:NY`,
146
+ );
147
+ });
148
+
149
+ it("includes multiple filters in search URL", async () => {
150
+ const serviceWithFilters = createServiceWithFilters({
151
+ AdministrativeArea: "CA",
152
+ PostalCode: "90210",
153
+ });
154
+
155
+ const mockResponse = createEmptyMockResponse();
156
+ mockFetchResponse(mockResponse);
157
+
158
+ await serviceWithFilters.search("321 Sunset Boulevard");
159
+
160
+ expect(global.fetch).toHaveBeenCalledWith(
161
+ `${mockBaseUrl}/Find/v1.00/json3.ws?Key=${mockApiKey}&Text=321 Sunset Boulevard&Countries=GB,USA&Filters=AdministrativeArea:CA&PostalCode:90210`,
162
+ );
163
+ });
164
+
165
+ it("encodes filter values with special characters", async () => {
166
+ const serviceWithFilters = createServiceWithFilters({
167
+ AdministrativeArea: "New York",
168
+ CustomFilter: "100% Match & More",
169
+ });
170
+
171
+ const mockResponse = createEmptyMockResponse();
172
+ mockFetchResponse(mockResponse);
173
+
174
+ await serviceWithFilters.search("654 Fifth Avenue");
175
+
176
+ expect(global.fetch).toHaveBeenCalledWith(
177
+ `${mockBaseUrl}/Find/v1.00/json3.ws?Key=${mockApiKey}&Text=654 Fifth Avenue&Countries=GB,USA&Filters=AdministrativeArea:New%20York&CustomFilter:100%25%20Match%20%26%20More`,
178
+ );
179
+ });
180
+
181
+ it("does not encode filter keys", async () => {
182
+ const serviceWithFilters = createServiceWithFilters({
183
+ AdministrativeArea: "NY",
184
+ });
185
+
186
+ const mockResponse = createEmptyMockResponse();
187
+ mockFetchResponse(mockResponse);
188
+
189
+ await serviceWithFilters.search("987 Park Avenue");
190
+
191
+ const calledUrl = (global.fetch as jest.Mock).mock.calls[0][0];
192
+ expect(calledUrl).toContain("AdministrativeArea:NY");
193
+ expect(calledUrl).not.toContain("AdministrativeArea%3ANY");
194
+ });
195
+
196
+ it("includes filters in findById URL", async () => {
197
+ const serviceWithFilters = createServiceWithFilters({
198
+ AdministrativeArea: "TX",
199
+ });
200
+
201
+ const mockResponse = createEmptyMockResponse();
202
+ mockFetchResponse(mockResponse);
203
+
204
+ await serviceWithFilters.findById("container123");
205
+
206
+ expect(global.fetch).toHaveBeenCalledWith(
207
+ `${mockBaseUrl}/Find/v1.00/json3.ws?Key=${mockApiKey}&Container=container123&Countries=GB,USA&Filters=AdministrativeArea:TX`,
208
+ );
209
+ });
210
+
211
+ it("handles empty filters object", async () => {
212
+ const serviceWithEmptyFilters = createServiceWithFilters({});
213
+
214
+ const mockResponse = createEmptyMockResponse();
215
+ mockFetchResponse(mockResponse);
216
+
217
+ await serviceWithEmptyFilters.search("147 Wall Street");
218
+
219
+ expect(global.fetch).toHaveBeenCalledWith(
220
+ `${mockBaseUrl}/Find/v1.00/json3.ws?Key=${mockApiKey}&Text=147 Wall Street&Countries=GB,USA`,
221
+ );
222
+ });
223
+
224
+ it("does not include filters in get URL (retrieve endpoint)", async () => {
225
+ const serviceWithFilters = createServiceWithFilters({
226
+ AdministrativeArea: "FL",
227
+ });
228
+
229
+ const mockResponse = createAddressDetailsMockResponse();
230
+ mockFetchResponse(mockResponse);
231
+
232
+ await serviceWithFilters.get("address123");
233
+
234
+ expect(global.fetch).toHaveBeenCalledWith(
235
+ `${mockBaseUrl}/Retrieve/v1.2/json3.ws?Key=${mockApiKey}&Id=address123`,
236
+ );
237
+
238
+ const calledUrl = (global.fetch as jest.Mock).mock.calls[0][0];
239
+ expect(calledUrl).not.toContain("Filters");
240
+ });
241
+ });
242
+
95
243
  describe("error handling", () => {
96
244
  it("propagates API errors", async () => {
97
- const errorResponse = {
98
- Items: [
99
- {
100
- Error: "Test error code",
101
- Description: "Test error",
102
- Cause: "Test cause",
103
- Resolution: "Test resolution",
104
- },
105
- ],
106
- };
107
- (global.fetch as jest.Mock).mockResolvedValueOnce({
108
- json: () => Promise.resolve(errorResponse),
109
- });
245
+ const errorResponse = createErrorMockResponse();
246
+ mockFetchResponse(errorResponse);
110
247
 
111
248
  await expect(service.search("test")).rejects.toThrow();
112
249
  });
@@ -27,18 +27,27 @@ export class LoqateAddressLookupService {
27
27
  */
28
28
  #countries?: string[];
29
29
 
30
+ /**
31
+ * Optional filters for the Loqate API
32
+ * E.g., { AdministrativeArea: "CA", PostalCode: "90210" }
33
+ */
34
+ #filters?: Record<string, string>;
35
+
30
36
  constructor({
31
37
  baseUrl,
32
38
  apiKey,
33
39
  countries,
40
+ filters,
34
41
  }: {
35
42
  baseUrl?: string;
36
43
  apiKey?: string;
37
44
  countries?: string[];
45
+ filters?: Record<string, string>;
38
46
  }) {
39
47
  this.#apiKey = apiKey;
40
48
  this.#baseUrl = baseUrl || LOQATE_BASE_URL;
41
49
  this.#countries = countries || DEFAULT_COUNTRIES;
50
+ this.#filters = filters;
42
51
  }
43
52
 
44
53
  private fetchFromApi<TResponse = unknown>(url: string): Promise<TResponse> {
@@ -52,13 +61,35 @@ export class LoqateAddressLookupService {
52
61
  });
53
62
  }
54
63
 
64
+ /**
65
+ * Builds the Filters query parameter for Loqate API requests.
66
+ * - Filter keys (e.g., "AdministrativeArea", "PostalCode") are predefined by Loqate API (no need to encode)
67
+ * - Filter values (e.g., "New York", "90210") contain user input that may have special characters (need encoding)
68
+ *
69
+ * @returns Empty string if no filters, otherwise "&Filters=key1:value1&key2:value2" (Loqate's expected format for Filters)
70
+ */
71
+ private buildFiltersQuery(): string {
72
+ if (!this.#filters || Object.keys(this.#filters).length === 0) {
73
+ return "";
74
+ }
75
+
76
+ // Transform Record<string, string> to Loqate's "key:value&key:value" format
77
+ const encodedFilters = Object.entries(this.#filters)
78
+ .map(([key, value]) => `${key}:${encodeURIComponent(value)}`)
79
+ .join("&");
80
+
81
+ return `&Filters=${encodedFilters}`;
82
+ }
83
+
55
84
  search(searchTerm: string): Promise<LoqateSearchResponse> {
56
- const url = `${LOQATE_FIND_URL}?Key=${this.#apiKey}&Text=${searchTerm}&Countries=${this.#countries?.join(",")}`;
85
+ let url = `${LOQATE_FIND_URL}?Key=${this.#apiKey}&Text=${searchTerm}&Countries=${this.#countries?.join(",")}`;
86
+ url += this.buildFiltersQuery();
57
87
  return this.fetchFromApi<LoqateSearchResponse>(url);
58
88
  }
59
89
 
60
90
  findById(id: string): Promise<LoqateSearchResponse> {
61
- const url = `${LOQATE_FIND_URL}?Key=${this.#apiKey}&Container=${id}&Countries=${this.#countries?.join(",")}`;
91
+ let url = `${LOQATE_FIND_URL}?Key=${this.#apiKey}&Container=${id}&Countries=${this.#countries?.join(",")}`;
92
+ url += this.buildFiltersQuery();
62
93
  return this.fetchFromApi<LoqateSearchResponse>(url);
63
94
  }
64
95
 
@@ -2,7 +2,8 @@
2
2
  display: flex;
3
3
  flex-grow: 1;
4
4
  align-items: center;
5
- font-size: var(--font-size-2);
5
+ font-size: var(--font-size-regular);
6
+ line-height: var(--line-height-normal);
6
7
  color: var(--color-text);
7
8
  border: 2px solid;
8
9
  border-color: inherit;
@@ -11,6 +11,7 @@ const meta: Meta<typeof Alert> = {
11
11
  variant: {
12
12
  control: { type: "radio" },
13
13
  options: ["info", "success", "warning", "error"],
14
+ header: { control: "text" },
14
15
  },
15
16
  },
16
17
  };
@@ -33,17 +34,13 @@ export const Default: StoryType = {
33
34
 
34
35
  export const WithHeader: StoryType = {
35
36
  render: (args: AlertProps) => (
36
- <Alert {...args}>
37
- Today is{" "}
38
- {new Intl.DateTimeFormat("en-GB", {
39
- dateStyle: "long",
40
- }).format(new Date())}
41
- </Alert>
37
+ <Alert {...args}>{args.children || "This is an alert with a header"}</Alert>
42
38
  ),
43
39
  args: {
44
40
  variant: "info",
45
41
  show: true,
46
42
  header: "Information",
43
+ children: "This is an alert with a header",
47
44
  },
48
45
  };
49
46