Skip to content

Commit

Permalink
Deserializing with io-ts
Browse files Browse the repository at this point in the history
  • Loading branch information
tannaurus committed Aug 25, 2024
1 parent b42a61d commit c663366
Show file tree
Hide file tree
Showing 5 changed files with 92 additions and 23 deletions.
17 changes: 17 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
},
"dependencies": {
"firebase": "^10.12.5",
"fp-ts": "^2.16.9",
"io-ts": "^2.2.21",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
Expand Down
88 changes: 69 additions & 19 deletions src/api/firebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,49 @@ import {
doc,
onSnapshot,
updateDoc,
Timestamp,
} from 'firebase/firestore';
import { useEffect, useState } from 'react';
import { db } from './config';
import { getFutureDate } from '../utils';
import { User } from 'firebase/auth';

interface ListModel {
id: string;
path: string;
}
import * as t from 'io-ts';
import { isLeft } from 'fp-ts/lib/Either';
import { PathReporter } from 'io-ts/PathReporter';

const FirebaseTimestamp = new t.Type<
Timestamp,
{ seconds: number; nanoseconds: number },
unknown
>(
'FirebaseTimestamp',
(input): input is Timestamp => input instanceof Timestamp,
(input, context) => {
if (input instanceof Timestamp) {
return t.success(input);
}

return t.failure(input, context);
},
(timestamp) => ({
seconds: timestamp.seconds,
nanoseconds: timestamp.nanoseconds,
}),
);

const ListModel = t.type({
id: t.string,
path: t.string,
});

type ListModel = t.TypeOf<typeof ListModel>;

export interface List {
name: string;
path: string;
}

export interface ListItem {
itemName: string;
daysUntilNextPurchase: number;
}

/**
* A custom hook that subscribes to the user's shopping lists in our Firestore
* database and returns new data whenever the lists change.
Expand All @@ -48,19 +70,40 @@ export function useShoppingLists(userId: string, userEmail: string) {

onSnapshot(userDocRef, (docSnap) => {
if (docSnap.exists()) {
const listRefs = docSnap.data().sharedLists as ListModel[];
const newData = listRefs.map((listRef) => {
// We keep the list's id and path so we can use them later.
return { name: listRef.id, path: listRef.path };
// deserialize the list into a typed List
const data = docSnap.data().sharedLists.map((list: unknown) => {
const decoded = ListModel.decode(list);
if (isLeft(decoded)) {
throw Error(
`Could not validate data: ${PathReporter.report(decoded).join('\n')}`,
);
}

const model = decoded.right;
return {
name: model.id,
path: model.path,
};
});
setData(newData);
setData(data);
}
});
}, [userId, userEmail]);

return data;
}

const ListItemModel = t.type({
id: t.string,
name: t.string,
dateLastPurchased: t.union([FirebaseTimestamp, t.null]),
dateNextPurchased: FirebaseTimestamp,
totalPurchases: t.number,
dateCreated: FirebaseTimestamp,
});

export type ListItem = t.TypeOf<typeof ListItemModel>;

/**
* A custom hook that subscribes to a shopping list in our Firestore database
* and returns new data whenever the list changes.
Expand Down Expand Up @@ -88,8 +131,14 @@ export function useShoppingListData(listPath: string | null) {
// but it is very useful, so we add it to the data ourselves.
item.id = docSnapshot.id;

// todo: validate
return item as ListItem;
const decoded = ListItemModel.decode(item);
if (isLeft(decoded)) {
throw Error(
`Could not validate data: ${PathReporter.report(decoded).join('\n')}`,
);
}

return decoded.right;
});

// Update our React state with the new data.
Expand Down Expand Up @@ -182,12 +231,13 @@ export async function shareList(
* Add a new item to the user's list in Firestore.
* @param {string} listPath The path of the list we're adding to.
* @param {Object} itemData Information about the new item.
* @param {string} itemData.itemName The name of the item.
* @param {string} itemData.name The name of the item.
* @param {number} itemData.daysUntilNextPurchase The number of days until the user thinks they'll need to buy the item again.
*/
export async function addItem(
listPath: string,
{ itemName, daysUntilNextPurchase }: ListItem,
name: string,
daysUntilNextPurchase: number,
) {
const listCollectionRef = collection(db, listPath, 'items');
// TODO: Replace this call to console.log with the appropriate
Expand All @@ -198,7 +248,7 @@ export async function addItem(
// We'll use updateItem to put a Date here when the item is purchased!
dateLastPurchased: null,
dateNextPurchased: getFutureDate(daysUntilNextPurchase),
name: itemName,
name,
totalPurchases: 0,
});
}
Expand Down
6 changes: 3 additions & 3 deletions src/components/ListItem.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as api from '../api';
import './ListItem.css';

type Props = Pick<api.ListItem, 'itemName'>;
type Props = Pick<api.ListItem, 'name'>;

export function ListItem({ itemName }: Props) {
return <li className="ListItem">{itemName}</li>;
export function ListItem({ name }: Props) {
return <li className="ListItem">{name}</li>;
}
2 changes: 1 addition & 1 deletion src/views/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function List({ data }: Props) {
<ul>
{hasItem &&
data.map((item) => (
<ListItemComponent key={item.itemName} itemName={item.itemName} />
<ListItemComponent key={item.id} name={item.name} />
))}
</ul>
</>
Expand Down

0 comments on commit c663366

Please sign in to comment.