diff --git a/README.md b/README.md index a63adc1..c383e49 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,9 @@ Hello {$user?.uid} 3. Listen to realtime data. -Use the `$` as much as you want - it will only result in one Firebase read request. When all the subscriptions are removed, it will automatically unsubscribe. +Use the `$` as much as you want - it will only result in one Firebase read request. When all the subscriptions are removed, it will automatically unsubscribe. To get the data from stores, use the `data` property, if an +error occurs, the error property will be set with an Error instance. + ```svelte -{$post?.content} -{$post?.title} +{$post?.data?.content} +{$post?.data?.title} +{$post?.error?.message} ``` Or better yet, use the built in `Doc` and `Collection` components for Firestore, or `Node` and `NodeList` components for Realtime Database. See below. @@ -135,9 +138,9 @@ Subscribe to realtime data. The store will unsubscribe automatically to avoid un const posts = collectionStore(firestore, 'posts'); -{$post?.content} +{$post?.data?.content} -{#each $posts as post} +{#each $posts?.data as post} {/each} ``` @@ -301,16 +304,19 @@ Pass a `startWith` value to bypass the loading state. This is useful in SvelteKi ### Collection -Collections provides array of objects containing the document data, as well as the `id` and `ref` for each result. It also provides a `count` slot prop for number of docs in the query. +Collections provides array of objects containing the document data, as well as the `id` and `ref` for each result. It also provides a `count` slot prop for number of docs in the query. Errors can be handled with the `error` slot prop. ```svelte - +

Fetched {count} documents

{#each data as post} {post.id} {post.ref.path} {post.content} {/each} + {#if error} +

Error: {error.message}

+ {/if}
``` @@ -376,49 +382,55 @@ Fetch lists of nodes from the Realtime Database and listen to their data in real ### DownloadURL -DownloadURL provides a `link` to download a file from Firebase Storage and its `reference`. +DownloadURL provides a `link` to download a file from Firebase Storage and its `reference`. Errors can be handled with the `error` slot prop. ```svelte - - Download {ref?.name} + + Download {ref?.name} + {#if error} +

Error: {error.message}

+ {/if}
``` ### StorageList -StorageList provides a list of `items` and `prefixes` corresponding to the list of objects and sub-folders at a given Firebase Storage path. +StorageList provides a list of `items` and `prefixes` corresponding to the list of objects and sub-folders at a given Firebase Storage path. Errors can be handled with the `error` slot prop. ```svelte - -
    - {#if list === null} -
  • Loading...
  • - {:else if list.prefixes.length === 0 && list.items.length === 0} -
  • Empty
  • - {:else} - - {#each list.prefixes as prefix} -
  • - {prefix.name} -
  • - {/each} - - {#each list.items as item} -
  • - {item.name} -
  • - {/each} + +
      + {#if list === null} +
    • Loading...
    • + {:else if list.prefixes.length === 0 && list.items.length === 0} +
    • Empty
    • + {:else} + + {#each list.prefixes as prefix} +
    • + {prefix.name} +
    • + {/each} + + {#each list.items as item} +
    • + {item.name} +
    • + {/each} + {/if} +
    + {#if error} +

    Error: {error.message}

    {/if} -
``` ### UploadTask -Upload a file with progress tracking +Upload a file with progress tracking. If error occurs, the `error` slot prop will be defined. The `snapshot` slot prop provides access to the upload task's `state`, `ref`, and `progress` percentage. ```svelte - + {#if snapshot?.state === "running"} {progress}% uploaded {/if} @@ -428,6 +440,10 @@ Upload a file with progress tracking Download
{/if} + + {#if error} +

Error: {error.message}

+ {/if} ``` diff --git a/package-lock.json b/package-lock.json index e010b8b..4aab479 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sveltefire", - "version": "0.4.1", + "version": "0.4.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sveltefire", - "version": "0.4.1", + "version": "0.4.3", "devDependencies": { "@playwright/test": "^1.28.1", "@sveltejs/adapter-auto": "^2.0.0", diff --git a/src/lib/components/Collection.svelte b/src/lib/components/Collection.svelte index 14b373f..3258bba 100644 --- a/src/lib/components/Collection.svelte +++ b/src/lib/components/Collection.svelte @@ -18,6 +18,7 @@ interface $$Slots { default: { data: Data[]; + error: Error | null; ref: CollectionReference | Query | null; count: number; firestore?: Firestore; @@ -27,7 +28,7 @@ {#if $store !== undefined} - + {:else} {/if} diff --git a/src/lib/components/Doc.svelte b/src/lib/components/Doc.svelte index 4feac31..15d9c54 100644 --- a/src/lib/components/Doc.svelte +++ b/src/lib/components/Doc.svelte @@ -16,7 +16,8 @@ interface $$Slots { default: { - data: Data; + data: Data | null | undefined; + error: Error | null; ref: DocumentReference | null; firestore?: Firestore; }; @@ -25,7 +26,7 @@ {#if $store !== undefined && $store !== null} - + {:else} {/if} diff --git a/src/lib/components/DownloadURL.svelte b/src/lib/components/DownloadURL.svelte index f2349f9..1070f8b 100644 --- a/src/lib/components/DownloadURL.svelte +++ b/src/lib/components/DownloadURL.svelte @@ -9,13 +9,13 @@ const store = downloadUrlStore(storage!, ref); interface $$Slots { - default: { link: string | null; ref: StorageReference | null; storage?: FirebaseStorage }, + default: { link: string | null; error: Error | null; ref: StorageReference | null; storage?: FirebaseStorage }, loading: {}, } -{#if $store !== undefined} - +{#if $store !== undefined && $store !== null} + {:else} {/if} diff --git a/src/lib/components/StorageList.svelte b/src/lib/components/StorageList.svelte index 5b1966f..73105a2 100644 --- a/src/lib/components/StorageList.svelte +++ b/src/lib/components/StorageList.svelte @@ -9,13 +9,13 @@ const listStore = storageListStore(storage!, ref); interface $$Slots { - default: { list: ListResult | null; ref: StorageReference | null; storage?: FirebaseStorage }, + default: { list: ListResult | null; ref: StorageReference | null; storage?: FirebaseStorage, error: Error | null }, loading: {}, } -{#if $listStore !== undefined} - +{#if $listStore !== undefined && $listStore !== null} + {:else} {/if} diff --git a/src/lib/components/UploadTask.svelte b/src/lib/components/UploadTask.svelte index 0f7dbed..6009de1 100644 --- a/src/lib/components/UploadTask.svelte +++ b/src/lib/components/UploadTask.svelte @@ -19,6 +19,7 @@ interface $$Slots { default: { task: UploadTask | undefined; + error: Error | null; ref: StorageReference | null; snapshot: UploadTaskSnapshot | null; progress: number; @@ -26,9 +27,9 @@ }; } - $: progress = ($upload?.bytesTransferred! / $upload?.totalBytes!) * 100 ?? 0; + $: progress = ($upload?.data?.bytesTransferred! / $upload?.data?.totalBytes!) * 100 ?? 0; -{#if $upload !== undefined} - +{#if $upload !== undefined && $upload !== null} + {/if} diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts index 11b7a7b..0043725 100644 --- a/src/lib/stores/auth.ts +++ b/src/lib/stores/auth.ts @@ -1,5 +1,4 @@ import { writable } from "svelte/store"; -import { getFirebaseContext } from "./sdk.js"; import { onAuthStateChanged, type Auth } from "firebase/auth"; /** diff --git a/src/lib/stores/firestore.ts b/src/lib/stores/firestore.ts index 6bdd771..d369146 100644 --- a/src/lib/stores/firestore.ts +++ b/src/lib/stores/firestore.ts @@ -1,14 +1,21 @@ -import { writable } from "svelte/store"; +import { readable } from "svelte/store"; import { doc, collection, onSnapshot } from "firebase/firestore"; import type { Query, CollectionReference, DocumentReference, Firestore, + DocumentData, } from "firebase/firestore"; +import type { FirebaseError } from "firebase/app"; + +type DocValue = { + data: T | null | undefined; + error: Error | null; +}; interface DocStore { - subscribe: (cb: (value: T | null) => void) => void | (() => void); + subscribe: (cb: (value: DocValue) => void) => void | (() => void); ref: DocumentReference | null; id: string; } @@ -28,7 +35,7 @@ export function docStore( // Fallback for SSR if (!globalThis.window) { - const { subscribe } = writable(startWith); + const { subscribe } = readable({data: startWith, error: null}); return { subscribe, ref: null, @@ -38,10 +45,7 @@ export function docStore( // Fallback for missing SDK if (!firestore) { - console.warn( - "Firestore is not initialized. Are you missing FirebaseApp as a parent component?" - ); - const { subscribe } = writable(null); + const { subscribe } = readable({data: null, error: new Error("Firestore is not initialized. Are you missing FirebaseApp as a parent component?")}); return { subscribe, ref: null, @@ -49,16 +53,25 @@ export function docStore( }; } - const docRef = - typeof ref === "string" - ? (doc(firestore, ref) as DocumentReference) - : ref; + let docRef: DocumentReference; + + try { + docRef = typeof ref === "string" ? (doc(firestore, ref) as DocumentReference) : ref; + } catch (error) { + const { subscribe } = readable({data: null, error: new Error(`Failed to create DocumentReference from path "${ref}" : ${error}`)}); + return { + subscribe, + ref: null, + id: "", + }; + } - const { subscribe } = writable(startWith, (set) => { + const { subscribe } = readable>({data: startWith, error: null}, (set) => { unsubscribe = onSnapshot(docRef, (snapshot) => { - set((snapshot.data() as T) ?? null); + set({data: (snapshot.data() as T) ?? null, error: null}); + }, (error: FirebaseError) => { + set({data: null, error}); }); - return () => unsubscribe(); }); @@ -69,8 +82,13 @@ export function docStore( }; } +type CollectionValue = { + data: T | []; + error: Error | null; +}; + interface CollectionStore { - subscribe: (cb: (value: T | []) => void) => void | (() => void); + subscribe: (cb: (value: CollectionValue) => void) => void | (() => void); ref: CollectionReference | Query | null; } @@ -89,7 +107,7 @@ export function collectionStore( // Fallback for SSR if (!globalThis.window) { - const { subscribe } = writable(startWith); + const { subscribe } = readable({data: startWith, error: null}); return { subscribe, ref: null, @@ -101,21 +119,34 @@ export function collectionStore( console.warn( "Firestore is not initialized. Are you missing FirebaseApp as a parent component?" ); - const { subscribe } = writable([]); + const { subscribe } = readable({data: [], error: new Error("Firestore is not initialized. Are you missing FirebaseApp as a parent component?")}); return { subscribe, ref: null, }; } - const colRef = typeof ref === "string" ? collection(firestore, ref) : ref; + let colRef: CollectionReference | Query; + + try { + colRef = typeof ref === "string" ? collection(firestore, ref) : ref; + } catch (error) { + const { subscribe } = readable({data: [], error: new Error(`Failed to create DocumentReference from path "${ref}" : ${error}`)}); + return { + subscribe, + ref: null, + }; + } - const { subscribe } = writable(startWith, (set) => { - unsubscribe = onSnapshot(colRef, (snapshot) => { - const data = snapshot.docs.map((s) => { + const { subscribe } = readable>({data: startWith, error: null}, (set) => { + unsubscribe = onSnapshot(colRef, (snapshot): void => { + const data = snapshot.docs.map((s: DocumentData) => { return { id: s.id, ref: s.ref, ...s.data() } as T; }); - set(data); + set({data, error: null}); + }, + (error: FirebaseError) => { + set({data: [], error}); }); return () => unsubscribe(); diff --git a/src/lib/stores/storage.ts b/src/lib/stores/storage.ts index 168207a..8958f53 100644 --- a/src/lib/stores/storage.ts +++ b/src/lib/stores/storage.ts @@ -13,14 +13,20 @@ import type { UploadTaskSnapshot, UploadMetadata, } from "firebase/storage"; +import { error } from "@sveltejs/kit"; const defaultListResult: ListResult = { prefixes: [], items: [], }; +type StorageListValue = { + data: ListResult | null | undefined; + error: Error | null; +}; + interface StorageListStore { - subscribe: (cb: (value: ListResult) => void) => void | (() => void); + subscribe: (cb: (value: StorageListValue) => void) => void | (() => void); reference: StorageReference | null; } @@ -37,7 +43,7 @@ export function storageListStore( ): StorageListStore { // Fallback for SSR if (!globalThis.window) { - const { subscribe } = readable(startWith); + const { subscribe } = readable({ data: startWith, error: null }); return { subscribe, reference: null, @@ -46,22 +52,34 @@ export function storageListStore( // Fallback for missing SDK if (!storage) { - console.warn( - "Cloud Storage is not initialized. Are you missing FirebaseApp as a parent component?" - ); - const { subscribe } = readable(defaultListResult); + console.error("Cloud Storage is not initialized. Are you missing FirebaseApp as a parent component?"); + const { subscribe } = readable({data: null, error: new Error("Cloud Storage is not initialized. Are you missing FirebaseApp as a parent component?")}); return { subscribe, reference: null, }; } - const storageRef = - typeof reference === "string" ? ref(storage, reference) : reference; + let storageRef: StorageReference; + + try{ + storageRef = typeof reference === "string" ? ref(storage, reference) : reference; + } + catch(error) { + console.error(`Failed to create StorageReference from path "${reference}" : ${error}`); + const { subscribe } = readable({data: null, error: new Error(`Failed to create StorageReference from path "${reference}" : ${error}`)}); + return { + subscribe, + reference: null, + }; + } - const { subscribe } = readable(startWith, (set) => { + const { subscribe } = readable({data: startWith, error: null}, (set) => { list(storageRef).then((snapshot) => { - set(snapshot); + set({data: snapshot, error: null}); + }, + (error) => { + set({data: null, error}); }); }); @@ -71,8 +89,12 @@ export function storageListStore( }; } +type DownloadUrlValue = { + data: string | null; + error: Error | null; +} interface DownloadUrlStore { - subscribe: (cb: (value: string | null) => void) => void | (() => void); + subscribe: (cb: (value: DownloadUrlValue) => void) => void | (() => void); reference: StorageReference | null; } @@ -89,7 +111,7 @@ export function downloadUrlStore( ): DownloadUrlStore { // Fallback for SSR if (!globalThis.window) { - const { subscribe } = readable(startWith); + const { subscribe } = readable({data: startWith, error: null}); return { subscribe, reference: null, @@ -98,22 +120,36 @@ export function downloadUrlStore( // Fallback for missing SDK if (!storage) { - console.warn( + console.error( "Cloud Storage is not initialized. Are you missing FirebaseApp as a parent component?" ); - const { subscribe } = readable(null); + const { subscribe } = readable({data: null, error: new Error("Cloud Storage is not initialized. Are you missing FirebaseApp as a parent component?")}); return { subscribe, reference: null, }; } - const storageRef = - typeof reference === "string" ? ref(storage, reference) : reference; + let storageRef: StorageReference; + + try{ + storageRef = typeof reference === "string" ? ref(storage, reference) : reference; + } + catch(error) { + console.error(`Failed to create StorageReference from path "${reference}" : ${error}`); + const { subscribe } = readable({data: null, error: new Error(`Failed to create StorageReference from path "${reference}" : ${error}`)}); + return { + subscribe, + reference: null, + }; + } - const { subscribe } = readable(startWith, (set) => { + const { subscribe } = readable({data: startWith, error: null}, (set) => { getDownloadURL(storageRef).then((snapshot) => { - set(snapshot); + set({data: snapshot, error: null}); + }, + (error) => { + set({data: null, error}); }); }); @@ -123,9 +159,14 @@ export function downloadUrlStore( }; } +type UploadTaskValue = { + data: UploadTaskSnapshot | null; + error: Error | null; +} + interface UploadTaskStore { subscribe: ( - cb: (value: UploadTaskSnapshot | null) => void + cb: (value: UploadTaskValue) => void ) => void | (() => void); reference: StorageReference | null; } @@ -138,7 +179,7 @@ export function uploadTaskStore( ): UploadTaskStore { // Fallback for SSR if (!globalThis.window) { - const { subscribe } = readable(null); + const { subscribe } = readable({data: null, error: null}); return { subscribe, reference: null, @@ -150,31 +191,42 @@ export function uploadTaskStore( console.warn( "Cloud Storage is not initialized. Are you missing FirebaseApp as a parent component?" ); - const { subscribe } = readable(null); + const { subscribe } = readable({data: null, error: new Error("Cloud Storage is not initialized. Are you missing FirebaseApp as a parent component?")}); return { subscribe, reference: null, }; } - const storageRef = - typeof reference === "string" ? ref(storage, reference) : reference; + let storageRef: StorageReference; + + try{ + storageRef = typeof reference === "string" ? ref(storage, reference) : reference; + } + catch(error) { + console.error(`Failed to create StorageReference from path "${reference}" : ${error}`); + const { subscribe } = readable({data: null, error: new Error(`Failed to create StorageReference from path "${reference}" : ${error}`)}); + return { + subscribe, + reference: null, + }; + } let unsubscribe: () => void; - const { subscribe } = readable(null, (set) => { + const { subscribe } = readable({data: null, error: null}, (set) => { const task = uploadBytesResumable(storageRef, data, metadata); unsubscribe = task.on( "state_changed", (snapshot) => { - set(snapshot); + set({data: snapshot, error: null}); }, (error) => { console.error(error); - set(task.snapshot); + set({data: task.snapshot, error}); }, () => { - set(task.snapshot); + set({data: task.snapshot, error: null}); } ); return () => unsubscribe(); diff --git a/src/routes/firestore-test/+page.svelte b/src/routes/firestore-test/+page.svelte index 40d5894..7180459 100644 --- a/src/routes/firestore-test/+page.svelte +++ b/src/routes/firestore-test/+page.svelte @@ -69,4 +69,16 @@
+ +

Should show an error

+ +
+

Loading...

+
+ {#if error !== null} +

Error occured {error}

+ {:else} +

No error while retrieving data

+ {/if} +
diff --git a/src/routes/storage-test/+page.svelte b/src/routes/storage-test/+page.svelte index 13c251d..4dd31d8 100644 --- a/src/routes/storage-test/+page.svelte +++ b/src/routes/storage-test/+page.svelte @@ -16,6 +16,8 @@

Storage Test

+

Storage List

+ +

Error handling (trying to fetch an invalid ref)

+ + +

{error?.message}

+
+

Upload Task

diff --git a/tests/firestore.test.ts b/tests/firestore.test.ts index 9442d69..7f80734 100644 --- a/tests/firestore.test.ts +++ b/tests/firestore.test.ts @@ -1,21 +1,32 @@ -import { expect, test } from '@playwright/test'; +import { expect, test, type Page } from '@playwright/test'; -test('Renders a single document', async ({ page }) => { - await page.goto('/firestore-test'); - await expect(page.getByTestId('doc-data')).toContainText('Hi Mom'); -}); +test.describe.serial("Firestore", () => { + let page: Page; + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + await page.goto("/firestore-test"); + }); -test('Renders a collection of items for an authenticated user in realtime', async ({ page }) => { - await page.goto('/firestore-test'); - await page.getByRole('button', { 'name': 'Sign In'}).click({delay: 1000}); - await expect(page.getByTestId('count')).toContainText('0 posts'); - await page.getByRole('button', { name: 'Add Post' }).click(); - await expect(page.getByTestId('count')).toContainText('1 posts'); - await page.getByRole('button', { name: 'Add Post' }).click(); - await expect(page.getByTestId('count')).toContainText('2 posts'); - await expect(page.locator('li')).toHaveCount(2); - await expect(page.locator('li')).toContainText([ - 'firestore item', - 'firestore item' - ]); -}); + + test('Renders a single document', async () => { + await expect(page.getByTestId('doc-data')).toContainText('Hi Mom'); + }); + + test('Renders a collection of items for an authenticated user in realtime', async () => { + await page.getByRole('button', { 'name': 'Sign In'}).click(); + await expect(page.getByTestId('count')).toContainText('0 posts'); + await page.getByRole('button', { name: 'Add Post' }).click(); + await expect(page.getByTestId('count')).toContainText('1 posts'); + await page.getByRole('button', { name: 'Add Post' }).click(); + await expect(page.getByTestId('count')).toContainText('2 posts'); + await expect(page.locator('li')).toHaveCount(2); + await expect(page.locator('li')).toContainText([ + 'firestore item', + 'firestore item' + ]); + }); + + test('An error occurs when ', async () => { + await expect(page.getByTestId('error')).toContainText('Error occured'); + }); +}); \ No newline at end of file diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 4e5e523..b3a6211 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -1,16 +1,31 @@ -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('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'); + 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 () => { + 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" + ); + }); + + test("Handle error", async () => { + await expect(page.getByTestId("download-url-error")).toContainText("storage/object-not-found"); + }); }); \ No newline at end of file