Skip to content

Commit

Permalink
Quick add card (#2)
Browse files Browse the repository at this point in the history
* Quick add card + Telegram SDK interaction refactoring
  • Loading branch information
kubk authored Oct 31, 2023
1 parent fd7de74 commit 2b8698d
Show file tree
Hide file tree
Showing 29 changed files with 457 additions and 176 deletions.
1 change: 1 addition & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ module.exports = {
rules: {
'react-refresh/only-export-components': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/ban-ts-comment': 'off'
},
}
1 change: 1 addition & 0 deletions .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,6 @@ jobs:
cache: 'npm'
- run: npm i
- run: npm run build --if-present
- run: npm run lint
- run: npm run test:api
- run: npm run test:frontend
56 changes: 56 additions & 0 deletions functions/add-card.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { handleError } from "./lib/handle-error/handle-error.ts";
import { getUser } from "./services/get-user.ts";
import { createAuthFailedResponse } from "./lib/json-response/create-auth-failed-response.ts";
import { createBadRequestResponse } from "./lib/json-response/create-bad-request-response.ts";
import { z } from "zod";
import { canEditDeck } from "./db/deck/can-edit-deck.ts";
import { envSchema } from "./env/env-schema.ts";
import { getDatabase } from "./db/get-database.ts";
import { tables } from "./db/tables.ts";
import { DatabaseException } from "./db/database-exception.ts";
import { createForbiddenRequestResponse } from "./lib/json-response/create-forbidden-request-response.ts";
import { createJsonResponse } from "./lib/json-response/create-json-response.ts";

const requestSchema = z.object({
deckId: z.number(),
card: z.object({
front: z.string(),
back: z.string(),
id: z.number().nullable().optional(),
}),
});

export type AddCardRequest = z.infer<typeof requestSchema>;
export type AddCardResponse = null;

export const onRequestPost = handleError(async ({ request, env }) => {
const user = await getUser(request, env);
if (!user) return createAuthFailedResponse();

const input = requestSchema.safeParse(await request.json());
if (!input.success) {
return createBadRequestResponse();
}

const envSafe = envSchema.parse(env);

const canEdit = await canEditDeck(envSafe, input.data.deckId, user.id);
if (!canEdit) {
return createForbiddenRequestResponse();
}

const db = getDatabase(envSafe);
const { data } = input;

const createCardsResult = await db.from(tables.deckCard).insert({
deck_id: data.deckId,
front: data.card.front,
back: data.card.back,
});

if (createCardsResult.error) {
throw new DatabaseException(createCardsResult.error);
}

return createJsonResponse<AddCardResponse>(null, 200);
});
24 changes: 24 additions & 0 deletions functions/db/deck/can-edit-deck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { EnvType } from "../../env/env-schema.ts";
import { getDatabase } from "../get-database.ts";
import { tables } from "../tables.ts";
import { DatabaseException } from "../database-exception.ts";

export const canEditDeck = async (
envSafe: EnvType,
deckId: number,
userId: number,
) => {
const db = getDatabase(envSafe);

const canEditDeckResult = await db
.from(tables.deck)
.select()
.eq("author_id", userId)
.eq("id", deckId);

if (canEditDeckResult.error) {
throw new DatabaseException(canEditDeckResult.error);
}

return !!canEditDeckResult.data;
};
29 changes: 11 additions & 18 deletions functions/upsert-deck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { createJsonResponse } from "./lib/json-response/create-json-response.ts"
import { deckSchema } from "./db/deck/decks-with-cards-schema.ts";
import { addDeckToMineDb } from "./db/deck/add-deck-to-mine-db.ts";
import { createForbiddenRequestResponse } from "./lib/json-response/create-forbidden-request-response.ts";
import { canEditDeck } from "./db/deck/can-edit-deck.ts";

const requestSchema = z.object({
id: z.number().nullable().optional(),
Expand Down Expand Up @@ -42,22 +43,13 @@ export const onRequestPost = handleError(async ({ request, env }) => {

// Check user can edit the deck
if (input.data.id) {
const canEditDeckResult = await db
.from(tables.deck)
.select()
.eq("author_id", user.id)
.eq("id", input.data.id);

if (canEditDeckResult.error) {
throw new DatabaseException(canEditDeckResult.error);
}

if (!canEditDeckResult.data) {
const result = await canEditDeck(envSafe, input.data.id, user.id);
if (!result) {
return createForbiddenRequestResponse();
}
}

const createDeckResult = await db
const upsertDeckResult = await db
.from(tables.deck)
.upsert({
id: input.data.id ? input.data.id : undefined,
Expand All @@ -68,18 +60,19 @@ export const onRequestPost = handleError(async ({ request, env }) => {
})
.select();

if (createDeckResult.error) {
throw new DatabaseException(createDeckResult.error);
if (upsertDeckResult.error) {
throw new DatabaseException(upsertDeckResult.error);
}

const newDeckArray = z.array(deckSchema).parse(createDeckResult.data);
// Supabase returns an array as a result of upsert, that's why it gets validated against an array here
const upsertedDecks = z.array(deckSchema).parse(upsertDeckResult.data);

const updateCardsResult = await db.from(tables.deckCard).upsert(
input.data.cards
.filter((card) => card.id)
.map((card) => ({
id: card.id,
deck_id: newDeckArray[0].id,
deck_id: upsertedDecks[0].id,
front: card.front,
back: card.back,
})),
Expand All @@ -93,7 +86,7 @@ export const onRequestPost = handleError(async ({ request, env }) => {
input.data.cards
.filter((card) => !card.id)
.map((card) => ({
deck_id: newDeckArray[0].id,
deck_id: upsertedDecks[0].id,
front: card.front,
back: card.back,
})),
Expand All @@ -106,7 +99,7 @@ export const onRequestPost = handleError(async ({ request, env }) => {
if (!input.data.id) {
await addDeckToMineDb(envSafe, {
user_id: user.id,
deck_id: newDeckArray[0].id,
deck_id: upsertedDecks[0].id,
});
}

Expand Down
7 changes: 4 additions & 3 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
<!-- Eruda is console for mobile browsers -->
<!-- <script src="https://cdn.jsdelivr.net/npm/eruda"></script>-->
<!-- <script>eruda.init();</script>-->
<!-- <script src="https://cdn.jsdelivr.net/npm/eruda"></script>-->
<!-- <script>-->
<!-- eruda.init();-->
<!-- </script>-->
</html>
5 changes: 5 additions & 0 deletions src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
ShareDeckResponse,
} from "../../functions/share-deck.ts";
import { GetSharedDeckResponse } from "../../functions/get-shared-deck.ts";
import { AddCardRequest, AddCardResponse } from "../../functions/add-card.ts";

export const healthRequest = () => {
return request<HealthResponse>("/health");
Expand Down Expand Up @@ -55,6 +56,10 @@ export const upsertDeckRequest = (body: UpsertDeckRequest) => {
);
};

export const addCardRequest = (body: AddCardRequest) => {
return request<AddCardResponse, AddCardRequest>("/add-card", "POST", body);
};

export const shareDeckRequest = (body: ShareDeckRequest) => {
return request<ShareDeckResponse, ShareDeckRequest>(
"/share-deck",
Expand Down
1 change: 0 additions & 1 deletion src/lib/mobx-form/validator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
// https://codesandbox.io/s/github/final-form/react-final-form/tree/master/examples/field-level-validation?file=/index.js

Expand Down
5 changes: 5 additions & 0 deletions src/lib/telegram/show-alert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import WebApp from "@twa-dev/sdk";

export const showAlert = (text: string) => {
WebApp.showAlert(text);
};
9 changes: 9 additions & 0 deletions src/lib/telegram/show-confirm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import WebApp from "@twa-dev/sdk";

export const showConfirm = (text: string): Promise<boolean> => {
return new Promise((resolve) => {
WebApp.showConfirm(text, (confirmed) => {
resolve(confirmed);
});
});
};
6 changes: 3 additions & 3 deletions src/lib/telegram/use-main-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import WebApp from "@twa-dev/sdk";
export const useMainButton = (
text: string,
onClick: () => void,
skipIf?: () => boolean,
condition?: () => boolean,
) => {
useMount(() => {
if (skipIf !== undefined) {
if (skipIf()) {
if (condition !== undefined) {
if (!condition()) {
return;
}
}
Expand Down
15 changes: 15 additions & 0 deletions src/lib/telegram/use-telegram-progress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { useMount } from "../react/use-mount.ts";
import { autorun } from "mobx";
import WebApp from "@twa-dev/sdk";

export const useTelegramProgress = (cb: () => boolean) => {
return useMount(() => {
return autorun(() => {
if (cb()) {
WebApp.MainButton.showProgress();
} else {
WebApp.MainButton.hideProgress();
}
});
});
};
4 changes: 3 additions & 1 deletion src/screens/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import { ReviewStoreProvider } from "../store/review-store-context.tsx";
import { Screen, screenStore } from "../store/screen-store.ts";
import { DeckFormScreen } from "./deck-form/deck-form-screen.tsx";
import { DeckFormStoreProvider } from "../store/deck-form-store-context.tsx";
import { QuickAddCardForm } from "./deck-form/quick-add-card-form.tsx";

export const App = observer(() => {
return (
<div>
{screenStore.screen === Screen.Main && <MainScreen />}
{screenStore.isDeckScreen && (
{screenStore.isDeckPreviewScreen && (
<ReviewStoreProvider>
<DeckScreen />
</ReviewStoreProvider>
Expand All @@ -20,6 +21,7 @@ export const App = observer(() => {
<DeckFormScreen />
</DeckFormStoreProvider>
)}
{screenStore.screen === Screen.CardQuickAddForm && <QuickAddCardForm />}
</div>
);
});
34 changes: 34 additions & 0 deletions src/screens/deck-form/card-form-view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { observer } from "mobx-react-lite";
import { css } from "@emotion/css";
import { Label } from "../../ui/label.tsx";
import { Input } from "../../ui/input.tsx";
import React from "react";
import { CardFormType } from "../../store/deck-form-store.ts";

type Props = {
cardForm: CardFormType;
};
export const CardFormView = observer((props: Props) => {
const { cardForm } = props;

return (
<div
className={css({
display: "flex",
flexDirection: "column",
gap: 6,
marginBottom: 16,
position: "relative",
})}
>
<h3 className={css({ textAlign: "center" })}>Add card</h3>
<Label text={"Front"}>
<Input {...cardForm.front.props} rows={7} type={"textarea"} />
</Label>

<Label text={"Back"}>
<Input {...cardForm.back.props} rows={7} type={"textarea"} />
</Label>
</div>
);
});
39 changes: 3 additions & 36 deletions src/screens/deck-form/card-form.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
import { observer } from "mobx-react-lite";
import { assert } from "../../lib/typescript/assert.ts";
import { css } from "@emotion/css";
import { Label } from "../../ui/label.tsx";
import { Input } from "../../ui/input.tsx";
import React from "react";
import { useMainButton } from "../../lib/telegram/use-main-button.tsx";
import { useDeckFormStore } from "../../store/deck-form-store-context.tsx";
import WebApp from "@twa-dev/sdk";
import { useBackButton } from "../../lib/telegram/use-back-button.tsx";
import { isFormEmpty } from "../../lib/mobx-form/form-has-error.ts";
import { CardFormView } from "./card-form-view.tsx";

export const CardForm = observer(() => {
const deckFormStore = useDeckFormStore();
Expand All @@ -18,38 +14,9 @@ export const CardForm = observer(() => {
useMainButton("Save", () => {
deckFormStore.saveCardForm();
});

useBackButton(() => {
if (isFormEmpty(cardForm)) {
deckFormStore.quitCardForm();
return;
}

WebApp.showConfirm("Quit editing card without saving?", (confirmed) => {
if (confirmed) {
deckFormStore.quitCardForm();
}
});
deckFormStore.onCardBack();
});

return (
<div
className={css({
display: "flex",
flexDirection: "column",
gap: 6,
marginBottom: 16,
position: "relative",
})}
>
<h3 className={css({ textAlign: "center" })}>Add card</h3>
<Label text={"Title"}>
<Input {...cardForm.front.props} rows={7} type={"textarea"} />
</Label>

<Label text={"Description"}>
<Input {...cardForm.back.props} rows={7} type={"textarea"} />
</Label>
</div>
);
return <CardFormView cardForm={cardForm} />;
});
Loading

0 comments on commit 2b8698d

Please sign in to comment.