diff --git a/README.md b/README.md index a63adc1..59e5fed 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ import { getDatabase } from "firebase/database"; import { getAuth } from "firebase/auth"; // Initialize Firebase -const app = initializeApp(/* your firebase config */); +export const app = initializeApp(/* your firebase config */); export const db = getFirestore(app); export const rtdb = getDatabase(app); export const auth = getAuth(app); @@ -431,7 +431,65 @@ Upload a file with progress tracking ``` -### Using Components Together +### RemoteConfig + +Instantiate a `RemoteConfig` instance from your initialized `FirebaseApp` and fetch Firebase Remote Config variables. It's possible to configure a `defaultValue` to give a fallback value for non-available variables and a `minimumFetchIntervalMillis` to let RemoteConfig caches the values for a given time. *Be careful not to fetch too often, as your requests might be throttled.* +This components takes care of intializing the RemoteConfig instance from a client side context only, as RemoteConfig is brower-dependant. + +```svelte + + + + + + + +``` + +When initialized, you can use the `remoteConfig` store to access your remote configurations by using a `RemoteConfigBoolean`, `RemoteConfigNumber`, `RemoteConfigString` or `RemoteConfigValue`. + +### RemoteConfigBoolean, RemoteConfigNumber, RemoteConfigString + +Get a typed value from your RemoteConfig instance. + +```svelte + + + + +

Greeting text: {configValue}

+
+ + +

Is active? {configValue}

+
+ + +

Answer: {configValue}

+
+
+
+``` + +## Using Components Together These components can be combined to build complex realtime apps. It's especially powerful when fetching data that requires the current user's UID or a related document's path. @@ -487,5 +545,5 @@ These components can be combined to build complex realtime apps. It's especially - ~~Add support for Firebase Storage~~ (Added in latest release!) - ~~Add support for Firebase RTDB~~ (Added in latest release!) + - Add support for Firebase Analytics in SvelteKit -- Find a way to make TS generics with with Doc/Collection components diff --git a/firebase.json b/firebase.json index a590c4b..506e1c2 100644 --- a/firebase.json +++ b/firebase.json @@ -13,7 +13,7 @@ "port": 9199 }, "hosting": { - "port": 5000 + "port": 5001 }, "ui": { "enabled": true diff --git a/src/lib/components/FirebaseApp.svelte b/src/lib/components/FirebaseApp.svelte index 2c138d4..09f1dc3 100644 --- a/src/lib/components/FirebaseApp.svelte +++ b/src/lib/components/FirebaseApp.svelte @@ -1,16 +1,18 @@ diff --git a/src/lib/components/remote-config/RemoteConfig.svelte b/src/lib/components/remote-config/RemoteConfig.svelte new file mode 100644 index 0000000..396617f --- /dev/null +++ b/src/lib/components/remote-config/RemoteConfig.svelte @@ -0,0 +1,39 @@ + + +{#if remoteConfig !== undefined && $configActivated === true} + +{:else} + +{/if} diff --git a/src/lib/components/remote-config/RemoteConfigBoolean.svelte b/src/lib/components/remote-config/RemoteConfigBoolean.svelte new file mode 100644 index 0000000..b3133e0 --- /dev/null +++ b/src/lib/components/remote-config/RemoteConfigBoolean.svelte @@ -0,0 +1,21 @@ + + +{#if $store !== undefined} + +{:else} + +{/if} diff --git a/src/lib/components/remote-config/RemoteConfigNumber.svelte b/src/lib/components/remote-config/RemoteConfigNumber.svelte new file mode 100644 index 0000000..e949837 --- /dev/null +++ b/src/lib/components/remote-config/RemoteConfigNumber.svelte @@ -0,0 +1,23 @@ + + +{#if $store !== undefined} + +{:else} + +{/if} diff --git a/src/lib/components/remote-config/RemoteConfigString.svelte b/src/lib/components/remote-config/RemoteConfigString.svelte new file mode 100644 index 0000000..c165b94 --- /dev/null +++ b/src/lib/components/remote-config/RemoteConfigString.svelte @@ -0,0 +1,23 @@ + + +{#if $store !== undefined} + +{:else} + +{/if} diff --git a/src/lib/components/remote-config/RemoteConfigValue.svelte b/src/lib/components/remote-config/RemoteConfigValue.svelte new file mode 100644 index 0000000..bf6f0a1 --- /dev/null +++ b/src/lib/components/remote-config/RemoteConfigValue.svelte @@ -0,0 +1,21 @@ + + +{#if $store !== undefined} + +{:else} + +{/if} diff --git a/src/lib/stores/remote-config.ts b/src/lib/stores/remote-config.ts new file mode 100644 index 0000000..c0ba512 --- /dev/null +++ b/src/lib/stores/remote-config.ts @@ -0,0 +1,147 @@ +import { readable } from "svelte/store"; + +import { fetchAndActivate, getBoolean, getNumber, getString, getValue, isSupported, type RemoteConfig } from "firebase/remote-config"; + + +/** + * @param {RemoteConfig} remoteConfig firebase remoteConfig instance + * @param {any} defaultValue optional default data. + * @returns a store with the fallback remote config value as an object + */ +function fallbacks(remoteConfig: RemoteConfig, defaultValue: any | undefined = undefined){ + // Fallback for SSR + if (!globalThis.window) { + const { subscribe } = readable(defaultValue); + return { + subscribe, + }; + } + + // Fallback for missing SDK + if (!remoteConfig) { + console.warn( + "Firebase RemoteConfig is not initialized. Are you missing FirebaseApp as a parent component?" + ); + const { subscribe } = readable(null); + return { + subscribe, + }; + } +} + +/** + * @param {RemoteConfig} remoteConfig firebase remoteConfig instance + * @returns a store with the remote config activation status as a boolean +*/ +export function remoteConfigActivationStore(remoteConfig: RemoteConfig) { + + const fallbackValue = fallbacks(remoteConfig, undefined); + + if(fallbackValue){ + return fallbackValue; + } + + const { subscribe } = readable(undefined, (set) => { + isSupported().then(async (isSupported) => { + if (isSupported) { + fetchAndActivate(remoteConfig).then(() => { set(true) }); + } + }); + }); + + return { + subscribe, + }; +} + +/** + * @param {RemoteConfig} remoteConfig firebase remoteConfig instance + * @param {string} configKey the key of the remote config value + * @param {any} defaultValue optional default data. + * @returns a store with the requested remote config value as an object + */ +export function valueConfigStore(remoteConfig: RemoteConfig, configKey: string, defaultValue: any | undefined = undefined) { + + const fallbackValue = fallbacks(remoteConfig, defaultValue); + + if(fallbackValue){ + return fallbackValue; + } + + const { subscribe } = readable(defaultValue, (set) => { + set(getValue(remoteConfig, configKey)); + }); + + return { + subscribe, + }; +} + +/** + * @param {RemoteConfig} remoteConfig firebase remoteConfig instance + * @param {string} configKey the key of the remote config value + * @param {boolean} defaultValue optional default data. + * @returns a store with the requested remote config value as a boolean + */ +export function booleanConfigStore(remoteConfig: RemoteConfig, configKey: string, defaultValue: boolean | undefined = undefined) { + + const fallbackValue = fallbacks(remoteConfig, defaultValue); + + if(fallbackValue){ + return fallbackValue; + } + + const { subscribe } = readable(defaultValue, (set) => { + set(getBoolean(remoteConfig, configKey)); + }); + + return { + subscribe, + }; +} + +/** + * @param {RemoteConfig} remoteConfig firebase remoteConfig instance + * @param {string} configKey the key of the remote config value + * @param {string | undefined} defaultValue optional default data. + * @returns a store with the requested remote config value as a string + */ +export function stringConfigStore(remoteConfig: RemoteConfig, configKey: string, defaultValue: string | undefined = undefined) { + + const fallbackValue = fallbacks(remoteConfig, defaultValue); + + if(fallbackValue){ + return fallbackValue; + } + + const { subscribe } = readable(defaultValue, (set) => { + set(getString(remoteConfig, configKey)); + }); + + return { + subscribe, + }; +} + +/** + * @param {RemoteConfig} remoteConfig firebase remoteConfig instance + * @param {string} configKey the key of the remote config value + * @param {number | undefined} defaultValue optional default data. + * @returns a store with the requested remote config value as a number + */ +export function numberConfigStore(remoteConfig: RemoteConfig, configKey: string, defaultValue: number | undefined = undefined) { + + const fallbackValue = fallbacks(remoteConfig, defaultValue); + + if(fallbackValue){ + return fallbackValue; + } + + const { subscribe } = readable(defaultValue, (set) => { + set(getNumber(remoteConfig, configKey)); + }); + + return { + subscribe, + }; +} diff --git a/src/lib/stores/sdk.ts b/src/lib/stores/sdk.ts index 5509624..179ed11 100644 --- a/src/lib/stores/sdk.ts +++ b/src/lib/stores/sdk.ts @@ -3,8 +3,10 @@ import type { Database } from "firebase/database"; import type { Auth } from "firebase/auth"; import { getContext, setContext } from "svelte"; import type { FirebaseStorage } from "firebase/storage"; +import type { FirebaseApp } from "firebase/app"; export interface FirebaseSDKContext { + app?: FirebaseApp; auth?: Auth; firestore?: Firestore; rtdb?: Database; @@ -22,4 +24,4 @@ export function setFirebaseContext(sdks: FirebaseSDKContext) { */ export function getFirebaseContext(): FirebaseSDKContext { return getContext(contextKey); -} +} \ No newline at end of file diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 8067999..834e8f2 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,8 +1,8 @@ - - + + diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index ba43911..7ae8d1e 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -12,8 +12,10 @@
  • Realtime Database Test
  • SSR Test
  • Storage Test
  • +
  • Remote Config Test
    • +
    • Firebase App Context: {!!ctx.app}
    • Auth Context: {!!ctx.auth}
    • Firestore Context: {!!ctx.firestore}
    • Realtime Database Context: {!!ctx.rtdb}
    • diff --git a/src/routes/auth-test/+page.svelte b/src/routes/auth-test/+page.svelte index cacf096..113ac0c 100644 --- a/src/routes/auth-test/+page.svelte +++ b/src/routes/auth-test/+page.svelte @@ -13,6 +13,6 @@ -

      Signed Out

      +

      Signed Out

      diff --git a/src/routes/remote-config-test/+page.svelte b/src/routes/remote-config-test/+page.svelte new file mode 100644 index 0000000..c3d65ec --- /dev/null +++ b/src/routes/remote-config-test/+page.svelte @@ -0,0 +1,34 @@ + + +

      Remote Config Test

      + + + +

      Remote Config String: {configValue}

      +
      + + +

      Remote Config Boolean: {configValue}

      +
      + + +

      Remote Config Number: {configValue}

      +
      + + +

      Remote Config Value: {configValue.getSource()}

      +
      +
      \ No newline at end of file diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 3f766c2..70ec345 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -16,9 +16,8 @@ test.describe.serial("Auth", () => { }); test("User can sign in and out", async () => { - await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible(); - await page.getByRole("button", { name: "Sign In" }).click({delay: 1000}); + await page.getByRole("button", { name: "Sign In" }).click({ delay: 1000 }); await expect(page.getByRole("button", { name: "Sign Out" })).toBeVisible(); await page.getByRole("button", { name: "Sign Out" }).click(); diff --git a/tests/remote-config.test.ts b/tests/remote-config.test.ts new file mode 100644 index 0000000..b72acbb --- /dev/null +++ b/tests/remote-config.test.ts @@ -0,0 +1,21 @@ +import { expect, test, type Page } from "@playwright/test"; + +test.describe.serial("Remote Config", () => { + let page: Page; + + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + await page.goto("/remote-config-test"); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test("Displays default values", async () => { + await expect(page.getByTestId('string-config')).toContainText('Hello World'); + await expect(page.getByTestId('number-config')).toContainText('123.456'); + await expect(page.getByTestId('value-config')).toContainText('default'); + await expect(page.getByTestId('boolean-config')).toContainText('true'); + }); +}); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 4e5e523..36792fa 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -1,16 +1,27 @@ -import { expect, test } from '@playwright/test'; +import { expect, test, type Page } from "@playwright/test"; +test.describe.serial("Storage", () => { + let page: Page; + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + await page.goto("/storage-test"); + }); -test('Renders download links', async ({ page }) => { - await page.goto('/storage-test'); - await page.waitForSelector('[data-testid="download-link"]'); - const linksCount = await page.getByTestId('download-link').count() - expect( linksCount ).toBeGreaterThan(0); -}); + test.afterAll(async () => { + await page.close(); + }); + + test("Renders download links", async () => { + await page.waitForSelector('[data-testid="download-link"]'); + const linksCount = await page.getByTestId("download-link").count(); + expect(linksCount).toBeGreaterThan(0); + }); -test('Uploads a file', async ({ page }) => { - await page.goto('/storage-test'); - await page.getByRole('button', { name: 'Make File' }).click(); - await expect(page.getByTestId('progress')).toContainText('100% uploaded'); - await expect(page.getByTestId('download-link2')).toContainText('test-upload.txt'); -}); \ No newline at end of file + test("Uploads a file", async () => { + await page.getByRole("button", { name: "Make File" }).click(); + await expect(page.getByTestId("progress")).toContainText("100% uploaded"); + await expect(page.getByTestId("download-link2")).toContainText( + "test-upload.txt" + ); + }); +});