Skip to content

Commit

Permalink
feat: Support field deletion and creation of fields from within a que…
Browse files Browse the repository at this point in the history
…st step (#117)
  • Loading branch information
evadecker authored Sep 30, 2024
1 parent 64475c3 commit 20f5f39
Show file tree
Hide file tree
Showing 19 changed files with 300 additions and 74 deletions.
5 changes: 5 additions & 0 deletions .changeset/little-planets-protect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"namesake": minor
---

Support deleting fields, allow adding fields directly from quest steps, constrain width of app
1 change: 0 additions & 1 deletion convex/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,6 @@ export const JURISDICTIONS = {
CT: "Connecticut",
DC: "District of Columbia",
DE: "Delaware",
FED: "Federal",
FL: "Florida",
GA: "Georgia",
HI: "Hawaii",
Expand Down
42 changes: 42 additions & 0 deletions convex/questFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,45 @@ export const createField = userMutation({
});
},
});

export const getFieldUsageCount = query({
args: { fieldId: v.id("questFields") },
handler: async (ctx, args) => {
const quests = await ctx.db
.query("questSteps")
.filter((q) => q.field("fields") !== null)
.collect();

return quests.filter((quest) => quest.fields?.includes(args.fieldId))
.length;
},
});

export const deleteField = userMutation({
args: { fieldId: v.id("questFields") },
handler: async (ctx, args) => {
const usageCount = await getFieldUsageCount(ctx, args);
if (usageCount > 0) {
throw new Error("Field is in use and cannot be deleted.");
}
await ctx.db.patch(args.fieldId, { deletionTime: Date.now() });
},
});

export const undeleteField = userMutation({
args: { fieldId: v.id("questFields") },
handler: async (ctx, args) => {
await ctx.db.patch(args.fieldId, { deletionTime: undefined });
},
});

export const permanentlyDeleteField = userMutation({
args: { fieldId: v.id("questFields") },
handler: async (ctx, args) => {
const usageCount = await getFieldUsageCount(ctx, args);
if (usageCount > 0) {
throw new Error("Field is in use and cannot be deleted.");
}
await ctx.db.delete(args.fieldId);
},
});
12 changes: 11 additions & 1 deletion convex/questSteps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,17 @@ export const getStepsForQuest = query({

const steps = await Promise.all(
quest.steps.map(async (stepId) => {
return await ctx.db.get(stepId);
const step = await ctx.db.get(stepId);
if (!step) return;

const fields = step.fields
? await Promise.all(step.fields.map((fieldId) => ctx.db.get(fieldId)))
: [];

return {
...step,
fields: fields.filter((field) => field !== null),
};
}),
);
return steps;
Expand Down
5 changes: 5 additions & 0 deletions convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const quests = defineTable({

/**
* Represents a single step in a quest.
* @param questId - The quest this step belongs to.
* @param creationUser - The user who created the step.
* @param title - The title of the step. (e.g. "Fill out form")
* @param description - A description of the step.
* @param fields - An array of form fields to complete the step.
Expand All @@ -40,13 +42,16 @@ const questSteps = defineTable({
* to pre-fill fields that point to the same data in future quests.
* @param type - The type of field. (e.g. "text", "select")
* @param label - The label for the field. (e.g. "First Name")
* @param slug - A unique identifier for the field, camel cased. (e.g. "firstName")
* @param helpText - Additional help text for the field.
* @param deletionTime - Time in ms since epoch when the field was deleted.
*/
const questFields = defineTable({
type: field,
label: v.string(),
slug: v.string(),
helpText: v.optional(v.string()),
deletionTime: v.optional(v.number()),
});

/**
Expand Down
11 changes: 11 additions & 0 deletions convex/userQuests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,14 @@ export const markIncomplete = userMutation({
await ctx.db.patch(userQuest._id, { completionTime: undefined });
},
});

export const removeQuest = userMutation({
args: { questId: v.id("quests") },
handler: async (ctx, args) => {
const userQuest = await getUserQuestByQuestId(ctx, {
questId: args.questId,
});
if (userQuest === null) throw new Error("Quest not found");
await ctx.db.delete(userQuest._id);
},
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,5 +96,5 @@
"vite": "^5.4.5",
"vitest": "^2.1.1"
},
"packageManager": "pnpm@9.9.0"
"packageManager": "pnpm@9.11.0"
}
2 changes: 1 addition & 1 deletion src/components/AppHeader/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const AppHeader = () => {
};

return (
<div className="flex gap-4 bg-gray-app items-center w-screen h-14 px-4 border-b border-gray-dim sticky top-0">
<div className="flex gap-4 bg-gray-app items-center w-screen h-14 px-4 border-b border-gray-dim sticky top-0 z-50">
<Link href={{ to: "/" }}>
<Logo className="h-[1.25rem]" />
</Link>
Expand Down
2 changes: 1 addition & 1 deletion src/components/Field/Field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function Label(props: LabelProps) {
<AriaLabel
{...props}
className={twMerge(
"text-sm text-gray-dim cursor-default w-fit",
"text-sm text-gray-normal cursor-default w-fit",
props.className,
)}
/>
Expand Down
21 changes: 21 additions & 0 deletions src/components/QuestStep/QuestStep.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from "@storybook/react";

import { QuestStep } from "./QuestStep";

const meta = {
component: QuestStep,
parameters: {
layout: "padded",
},
} satisfies Meta<typeof QuestStep>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {
title: "Get started",
description: "This is the first step in the quest.",
},
};
12 changes: 3 additions & 9 deletions src/components/QuestStep/QuestStep.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { api } from "@convex/_generated/api";
import type { Doc, Id } from "@convex/_generated/dataModel";
import { useQuery } from "convex/react";
import type { Doc } from "@convex/_generated/dataModel";
import Markdown from "react-markdown";
import { Card } from "../Card";
import { Checkbox } from "../Checkbox";
Expand All @@ -13,7 +11,7 @@ import { TextField } from "../TextField";
export interface QuestStepProps {
title: string;
description?: string;
fields?: Id<"questFields">[];
fields?: Doc<"questFields">[];
}

export function QuestFields(props: { questFields: Doc<"questFields">[] }) {
Expand Down Expand Up @@ -70,10 +68,6 @@ export function QuestFields(props: { questFields: Doc<"questFields">[] }) {
}

export function QuestStep({ title, description, fields }: QuestStepProps) {
const questFields = useQuery(api.questFields.getFields, {
fieldIds: fields ?? [],
});

return (
<Card className="flex flex-col gap-2">
<h2 className="text-xl font-semibold">{title}</h2>
Expand All @@ -82,7 +76,7 @@ export function QuestStep({ title, description, fields }: QuestStepProps) {
<Markdown className="prose dark:prose-invert">{description}</Markdown>
</div>
)}
{questFields && <QuestFields questFields={questFields} />}
{fields && <QuestFields questFields={fields} />}
</Card>
);
}
1 change: 1 addition & 0 deletions src/components/QuestStep/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./QuestStep";
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export * from "./NumberField";
export * from "./PageHeader";
export * from "./Popover";
export * from "./ProgressBar";
export * from "./QuestStep";
export * from "./RadioGroup";
export * from "./RangeCalendar";
export * from "./RichTextEditor";
Expand Down
30 changes: 27 additions & 3 deletions src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { Route as AuthenticatedSettingsRouteImport } from './routes/_authenticat
import { Route as AuthenticatedQuestsRouteImport } from './routes/_authenticated/quests/route'
import { Route as AuthenticatedAdminRouteImport } from './routes/_authenticated/admin/route'
import { Route as AuthenticatedSettingsIndexImport } from './routes/_authenticated/settings/index'
import { Route as AuthenticatedQuestsIndexImport } from './routes/_authenticated/quests/index'
import { Route as AuthenticatedAdminIndexImport } from './routes/_authenticated/admin/index'
import { Route as AuthenticatedSettingsOverviewImport } from './routes/_authenticated/settings/overview'
import { Route as AuthenticatedSettingsDataImport } from './routes/_authenticated/settings/data'
Expand Down Expand Up @@ -75,6 +76,11 @@ const AuthenticatedSettingsIndexRoute = AuthenticatedSettingsIndexImport.update(
} as any,
)

const AuthenticatedQuestsIndexRoute = AuthenticatedQuestsIndexImport.update({
path: '/',
getParentRoute: () => AuthenticatedQuestsRouteRoute,
} as any)

const AuthenticatedAdminIndexRoute = AuthenticatedAdminIndexImport.update({
path: '/',
getParentRoute: () => AuthenticatedAdminRouteRoute,
Expand Down Expand Up @@ -209,6 +215,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedAdminIndexImport
parentRoute: typeof AuthenticatedAdminRouteImport
}
'/_authenticated/quests/': {
id: '/_authenticated/quests/'
path: '/'
fullPath: '/quests/'
preLoaderRoute: typeof AuthenticatedQuestsIndexImport
parentRoute: typeof AuthenticatedQuestsRouteImport
}
'/_authenticated/settings/': {
id: '/_authenticated/settings/'
path: '/'
Expand Down Expand Up @@ -282,11 +295,13 @@ const AuthenticatedAdminRouteRouteWithChildren =

interface AuthenticatedQuestsRouteRouteChildren {
AuthenticatedQuestsQuestIdRoute: typeof AuthenticatedQuestsQuestIdRoute
AuthenticatedQuestsIndexRoute: typeof AuthenticatedQuestsIndexRoute
}

const AuthenticatedQuestsRouteRouteChildren: AuthenticatedQuestsRouteRouteChildren =
{
AuthenticatedQuestsQuestIdRoute: AuthenticatedQuestsQuestIdRoute,
AuthenticatedQuestsIndexRoute: AuthenticatedQuestsIndexRoute,
}

const AuthenticatedQuestsRouteRouteWithChildren =
Expand Down Expand Up @@ -353,6 +368,7 @@ export interface FileRoutesByFullPath {
'/settings/data': typeof AuthenticatedSettingsDataRoute
'/settings/overview': typeof AuthenticatedSettingsOverviewRoute
'/admin/': typeof AuthenticatedAdminIndexRoute
'/quests/': typeof AuthenticatedQuestsIndexRoute
'/settings/': typeof AuthenticatedSettingsIndexRoute
'/admin/forms/$formId': typeof AuthenticatedAdminFormsFormIdRoute
'/admin/quests/$questId': typeof AuthenticatedAdminQuestsQuestIdRoute
Expand All @@ -363,13 +379,13 @@ export interface FileRoutesByFullPath {

export interface FileRoutesByTo {
'': typeof UnauthenticatedRouteWithChildren
'/quests': typeof AuthenticatedQuestsRouteRouteWithChildren
'/login': typeof UnauthenticatedLoginRoute
'/': typeof AuthenticatedIndexRoute
'/quests/$questId': typeof AuthenticatedQuestsQuestIdRoute
'/settings/data': typeof AuthenticatedSettingsDataRoute
'/settings/overview': typeof AuthenticatedSettingsOverviewRoute
'/admin': typeof AuthenticatedAdminIndexRoute
'/quests': typeof AuthenticatedQuestsIndexRoute
'/settings': typeof AuthenticatedSettingsIndexRoute
'/admin/forms/$formId': typeof AuthenticatedAdminFormsFormIdRoute
'/admin/quests/$questId': typeof AuthenticatedAdminQuestsQuestIdRoute
Expand All @@ -391,6 +407,7 @@ export interface FileRoutesById {
'/_authenticated/settings/data': typeof AuthenticatedSettingsDataRoute
'/_authenticated/settings/overview': typeof AuthenticatedSettingsOverviewRoute
'/_authenticated/admin/': typeof AuthenticatedAdminIndexRoute
'/_authenticated/quests/': typeof AuthenticatedQuestsIndexRoute
'/_authenticated/settings/': typeof AuthenticatedSettingsIndexRoute
'/_authenticated/admin/forms/$formId': typeof AuthenticatedAdminFormsFormIdRoute
'/_authenticated/admin/quests/$questId': typeof AuthenticatedAdminQuestsQuestIdRoute
Expand All @@ -412,6 +429,7 @@ export interface FileRouteTypes {
| '/settings/data'
| '/settings/overview'
| '/admin/'
| '/quests/'
| '/settings/'
| '/admin/forms/$formId'
| '/admin/quests/$questId'
Expand All @@ -421,13 +439,13 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| ''
| '/quests'
| '/login'
| '/'
| '/quests/$questId'
| '/settings/data'
| '/settings/overview'
| '/admin'
| '/quests'
| '/settings'
| '/admin/forms/$formId'
| '/admin/quests/$questId'
Expand All @@ -447,6 +465,7 @@ export interface FileRouteTypes {
| '/_authenticated/settings/data'
| '/_authenticated/settings/overview'
| '/_authenticated/admin/'
| '/_authenticated/quests/'
| '/_authenticated/settings/'
| '/_authenticated/admin/forms/$formId'
| '/_authenticated/admin/quests/$questId'
Expand Down Expand Up @@ -513,7 +532,8 @@ export const routeTree = rootRoute
"filePath": "_authenticated/quests/route.tsx",
"parent": "/_authenticated",
"children": [
"/_authenticated/quests/$questId"
"/_authenticated/quests/$questId",
"/_authenticated/quests/"
]
},
"/_authenticated/settings": {
Expand Down Expand Up @@ -549,6 +569,10 @@ export const routeTree = rootRoute
"filePath": "_authenticated/admin/index.tsx",
"parent": "/_authenticated/admin"
},
"/_authenticated/quests/": {
"filePath": "_authenticated/quests/index.tsx",
"parent": "/_authenticated/quests"
},
"/_authenticated/settings/": {
"filePath": "_authenticated/settings/index.tsx",
"parent": "/_authenticated/settings"
Expand Down
Loading

0 comments on commit 20f5f39

Please sign in to comment.