Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion examples/react/src/firebase/firebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import { initializeApp, getApps } from "firebase/app";
import { firebaseConfig } from "./config";
import { connectAuthEmulator, getAuth } from "firebase/auth";
import { autoAnonymousLogin, initializeUI, oneTapSignIn } from "@firebase-ui/core";
import { autoAnonymousLogin, initializeUI, oneTapSignIn, countryCodes } from "@firebase-ui/core";

export const firebaseApp = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];

Expand All @@ -32,6 +32,10 @@ export const ui = initializeUI({
oneTapSignIn({
clientId: "200312857118-lscdui98fkaq7ffr81446blafjn5o6r0.apps.googleusercontent.com",
}),
Comment on lines 33 to 34
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
clientId: "200312857118-lscdui98fkaq7ffr81446blafjn5o6r0.apps.googleusercontent.com",
}),
clientId: "",
}),

countryCodes({
allowedCountries: ["US", "CA", "GB"],
defaultCountry: "GB",
}),
],
});

Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
},
"dependencies": {
"@firebase-ui/translations": "workspace:*",
"libphonenumber-js": "^1.12.23",
"nanostores": "catalog:",
"qrcode-generator": "^2.0.4",
"zod": "catalog:"
Expand Down
222 changes: 222 additions & 0 deletions packages/core/src/behaviors/country-codes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { describe, it, expect, vi } from "vitest";
import { countryCodesHandler, CountryCodesOptions } from "./country-codes";
import { countryData } from "../country-data";

describe("countryCodesHandler", () => {
describe("default behavior", () => {
it("should return all countries when no options provided", () => {
const result = countryCodesHandler();

expect(result.allowedCountries).toEqual(countryData);
expect(result.defaultCountry).toEqual(countryData.find((country) => country.code === "US"));
});

it("should return all countries when empty options provided", () => {
const result = countryCodesHandler({});

expect(result.allowedCountries).toEqual(countryData);
expect(result.defaultCountry).toEqual(countryData.find((country) => country.code === "US"));
});
});

describe("allowedCountries filtering", () => {
it("should filter countries based on allowedCountries", () => {
const options: CountryCodesOptions = {
allowedCountries: ["US", "GB", "CA"],
};

const result = countryCodesHandler(options);

expect(result.allowedCountries).toHaveLength(3);
// Order is preserved from original countryData array, not from allowedCountries
expect(result.allowedCountries.map((c) => c.code)).toEqual(["CA", "GB", "US"]);
});

it("should handle single allowed country", () => {
const options: CountryCodesOptions = {
allowedCountries: ["US"],
};

const result = countryCodesHandler(options);

expect(result.allowedCountries).toHaveLength(1);
expect(result.allowedCountries[0]!.code).toBe("US");
});

it("should handle empty allowedCountries array", () => {
const options: CountryCodesOptions = {
allowedCountries: [],
};

const result = countryCodesHandler(options);

expect(result.allowedCountries).toEqual(countryData);
});
});

describe("defaultCountry setting", () => {
it("should set default country when provided", () => {
const options: CountryCodesOptions = {
defaultCountry: "GB",
};

const result = countryCodesHandler(options);

expect(result.defaultCountry.code).toBe("GB");
expect(result.defaultCountry.name).toBe("United Kingdom");
});

it("should default to US when no defaultCountry provided", () => {
const result = countryCodesHandler();

expect(result.defaultCountry.code).toBe("US");
});

it("should default to US when defaultCountry is undefined", () => {
const options: CountryCodesOptions = {
defaultCountry: undefined,
};

const result = countryCodesHandler(options);

expect(result.defaultCountry.code).toBe("US");
});
});

describe("defaultCountry validation with allowedCountries", () => {
it("should keep defaultCountry when it's in allowedCountries", () => {
const options: CountryCodesOptions = {
allowedCountries: ["US", "GB", "CA"],
defaultCountry: "GB",
};

const result = countryCodesHandler(options);

expect(result.defaultCountry.code).toBe("GB");
expect(result.allowedCountries.map((c) => c.code)).toEqual(["CA", "GB", "US"]);
});

it("should override defaultCountry when it's not in allowedCountries", () => {
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

const options: CountryCodesOptions = {
allowedCountries: ["US", "GB", "CA"],
defaultCountry: "FR", // France is not in allowed countries
};

const result = countryCodesHandler(options);

expect(result.defaultCountry.code).toBe("CA"); // Should default to first allowed country (CA comes first in original array)
expect(result.allowedCountries.map((c) => c.code)).toEqual(["CA", "GB", "US"]);
expect(consoleSpy).toHaveBeenCalledWith(
'The "defaultCountry" option is not in the "allowedCountries" list, the default country has been set to CA'
);

consoleSpy.mockRestore();
});

it("should override defaultCountry to first allowed country when not in list", () => {
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

const options: CountryCodesOptions = {
allowedCountries: ["GB", "CA", "AU"], // US is not in this list
defaultCountry: "US",
};

const result = countryCodesHandler(options);

expect(result.defaultCountry.code).toBe("AU"); // Should default to first allowed country (AU comes first in original array)
expect(consoleSpy).toHaveBeenCalledWith(
'The "defaultCountry" option is not in the "allowedCountries" list, the default country has been set to AU'
);

consoleSpy.mockRestore();
});

it("should not warn when defaultCountry is in allowedCountries", () => {
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

const options: CountryCodesOptions = {
allowedCountries: ["US", "GB", "CA"],
defaultCountry: "CA",
};

const result = countryCodesHandler(options);

expect(result.defaultCountry.code).toBe("CA");
expect(consoleSpy).not.toHaveBeenCalled();

consoleSpy.mockRestore();
});
});

describe("edge cases", () => {
it("should handle invalid country codes gracefully", () => {
const options: CountryCodesOptions = {
allowedCountries: ["US", "INVALID", "GB"] as any,
};

const result = countryCodesHandler(options);

// Should only include valid countries
expect(result.allowedCountries).toHaveLength(2);
expect(result.allowedCountries.map((c) => c.code)).toEqual(["GB", "US"]);
});

it("should handle case sensitivity", () => {
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

const options: CountryCodesOptions = {
allowedCountries: ["us", "gb"] as any, // lowercase
defaultCountry: "US", // This will trigger the validation logic
};

const result = countryCodesHandler(options);

// Should fall back to all countries when no matches found
expect(result.allowedCountries).toEqual(countryData);
expect(consoleSpy).toHaveBeenCalledWith(
'No countries matched the "allowedCountries" list, falling back to all countries'
);

consoleSpy.mockRestore();
});

it("should handle special country codes like Kosovo", () => {
const options: CountryCodesOptions = {
allowedCountries: ["XK", "US", "GB"],
};

const result = countryCodesHandler(options);

expect(result.allowedCountries.length).toBeGreaterThan(2); // Kosovo has multiple entries
expect(result.allowedCountries.some((c) => c.code === "XK")).toBe(true);
expect(result.allowedCountries.some((c) => c.code === "US")).toBe(true);
expect(result.allowedCountries.some((c) => c.code === "GB")).toBe(true);
});
});

describe("return type validation", () => {
it("should return objects with correct structure", () => {
const result = countryCodesHandler();

expect(result).toHaveProperty("allowedCountries");
expect(result).toHaveProperty("defaultCountry");
expect(Array.isArray(result.allowedCountries)).toBe(true);
expect(typeof result.defaultCountry).toBe("object");

// Check structure of country objects
result.allowedCountries.forEach((country) => {
expect(country).toHaveProperty("name");
expect(country).toHaveProperty("dialCode");
expect(country).toHaveProperty("code");
expect(country).toHaveProperty("emoji");
});

expect(result.defaultCountry).toHaveProperty("name");
expect(result.defaultCountry).toHaveProperty("dialCode");
expect(result.defaultCountry).toHaveProperty("code");
expect(result.defaultCountry).toHaveProperty("emoji");
});
});
});
41 changes: 41 additions & 0 deletions packages/core/src/behaviors/country-codes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { CountryCode, countryData } from "../country-data";

export type CountryCodesOptions = {
// The allowed countries are the countries that will be shown in the country selector
// or `getCountries` is called.
allowedCountries?: CountryCode[];
// The default country is the country that will be selected by default when
// the country selector is rendered, or `getDefaultCountry` is called.
defaultCountry?: CountryCode;
};

export const countryCodesHandler = (options?: CountryCodesOptions) => {
// Determine allowed countries
let allowedCountries = options?.allowedCountries?.length
? countryData.filter((country) => options.allowedCountries!.includes(country.code))
: countryData;

// If no countries match, fall back to all countries
if (options?.allowedCountries?.length && allowedCountries.length === 0) {
console.warn(`No countries matched the "allowedCountries" list, falling back to all countries`);
allowedCountries = countryData;
}

// Determine default country
let defaultCountry = options?.defaultCountry
? countryData.find((country) => country.code === options.defaultCountry)!
: countryData.find((country) => country.code === "US")!;

// If default country is not in allowed countries, use first allowed country
if (!allowedCountries.some((country) => country.code === defaultCountry.code)) {
defaultCountry = allowedCountries[0]!;
console.warn(
`The "defaultCountry" option is not in the "allowedCountries" list, the default country has been set to ${defaultCountry.code}`
);
}

return {
allowedCountries,
defaultCountry,
};
};
5 changes: 3 additions & 2 deletions packages/core/src/behaviors/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,8 +246,9 @@ describe("requireDisplayName", () => {
describe("defaultBehaviors", () => {
it("should include recaptchaVerification by default", () => {
expect(defaultBehaviors).toHaveProperty("recaptchaVerification");
expect(defaultBehaviors.recaptchaVerification).toHaveProperty("type", "callable");
expect(typeof defaultBehaviors.recaptchaVerification.handler).toBe("function");
expect(defaultBehaviors).toHaveProperty("providerSignInStrategy");
expect(defaultBehaviors).toHaveProperty("providerLinkStrategy");
expect(defaultBehaviors).toHaveProperty("countryCodes");
});

it("should not include other behaviors by default", () => {
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/behaviors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as recaptchaHandlers from "./recaptcha";
import * as providerStrategyHandlers from "./provider-strategy";
import * as oneTapSignInHandlers from "./one-tap";
import * as requireDisplayNameHandlers from "./require-display-name";
import * as countryCodesHandlers from "./country-codes";
import {
callableBehavior,
initBehavior,
Expand Down Expand Up @@ -35,6 +36,7 @@ type Registry = {
(ui: FirebaseUIConfiguration) => ReturnType<typeof oneTapSignInHandlers.oneTapSignInHandler>
>;
requireDisplayName: CallableBehavior<typeof requireDisplayNameHandlers.requireDisplayNameHandler>;
countryCodes: CallableBehavior<typeof countryCodesHandlers.countryCodesHandler>;
};

export type Behavior<T extends keyof Registry = keyof Registry> = Pick<Registry, T>;
Expand Down Expand Up @@ -106,6 +108,12 @@ export function requireDisplayName(): Behavior<"requireDisplayName"> {
};
}

export function countryCodes(options?: countryCodesHandlers.CountryCodesOptions): Behavior<"countryCodes"> {
return {
countryCodes: callableBehavior(() => countryCodesHandlers.countryCodesHandler(options)),
};
}

export function hasBehavior<T extends keyof Registry>(ui: FirebaseUIConfiguration, key: T): boolean {
return !!ui.behaviors[key];
}
Expand All @@ -121,4 +129,5 @@ export function getBehavior<T extends keyof Registry>(ui: FirebaseUIConfiguratio
export const defaultBehaviors: Behavior<"recaptchaVerification"> = {
...recaptchaVerification(),
...providerRedirectStrategy(),
...countryCodes(),
};
Loading