From 826fe5adb12d8e5354da8ba82512760f6b6a2e58 Mon Sep 17 00:00:00 2001 From: case Date: Wed, 28 Aug 2024 22:49:42 -0700 Subject: [PATCH 1/4] Add Tags on Markets --- apps/api/app/api/markets/[id]/route.ts | 4 +- apps/api/app/api/markets/[id]/schema.ts | 2 +- apps/api/app/api/markets/route.ts | 4 +- apps/api/app/api/markets/schema.ts | 2 + .../app/(app)/questions/tagged/[tag]/page.tsx | 18 + packages/api-helpers/client/index.ts | 5 +- .../20240829035918_market_tags/migration.sql | 2 + packages/database/mocks.ts | 1 + packages/database/schema.prisma | 1 + packages/database/seed.ts | 1 + .../MarketScalarFieldEnumSchema.ts | 2 +- .../database/zod/modelSchema/MarketSchema.ts | 1 + .../markets/components/CreateMarketForm.tsx | 27 +- .../markets/components/EditMarketDialog.tsx | 23 +- .../markets/components/MarketOverviewPage.tsx | 11 + packages/markets/lib/createMarket.ts | 3 + packages/markets/lib/getMarkets.ts | 2 + packages/markets/lib/updateMarket.ts | 6 + packages/ui/package.json | 1 + .../ui/src/components/ui/multi-select.tsx | 621 ++++++++++++++++++ 20 files changed, 727 insertions(+), 10 deletions(-) create mode 100644 apps/web/app/(app)/questions/tagged/[tag]/page.tsx create mode 100644 packages/database/migrations/20240829035918_market_tags/migration.sql create mode 100644 packages/ui/src/components/ui/multi-select.tsx diff --git a/apps/api/app/api/markets/[id]/route.ts b/apps/api/app/api/markets/[id]/route.ts index a4bae086..bb36edba 100644 --- a/apps/api/app/api/markets/[id]/route.ts +++ b/apps/api/app/api/markets/[id]/route.ts @@ -46,14 +46,14 @@ export async function PATCH( const { id } = schema.patch.parameters.parse(params) const body = (await req.json()) as unknown - const { question, description, closeDate } = schema.patch.requestBody.transform(stripUndefined).parse(body) + const { question, description, closeDate, tags } = schema.patch.requestBody.transform(stripUndefined).parse(body) const market = await getMarket({ id }) if (market.createdBy !== session.user.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const updatedComment = await updateMarket({ id, question, description, closeDate }) + const updatedComment = await updateMarket({ id, question, description, closeDate, tags }) return NextResponse.json(updatedComment) } catch (error) { diff --git a/apps/api/app/api/markets/[id]/schema.ts b/apps/api/app/api/markets/[id]/schema.ts index 10199cfe..b5f49187 100644 --- a/apps/api/app/api/markets/[id]/schema.ts +++ b/apps/api/app/api/markets/[id]/schema.ts @@ -13,7 +13,7 @@ export default createSchema({ }, patch: { parameters: MarketSchema.pick({ id: true }), - requestBody: MarketSchema.pick({ question: true, description: true, closeDate: true }).partial(), + requestBody: MarketSchema.pick({ question: true, description: true, closeDate: true, tags: true }).partial(), responses: { 200: MarketSchema, 404: ServerErrorSchema, diff --git a/apps/api/app/api/markets/route.ts b/apps/api/app/api/markets/route.ts index 392f9ef1..0232b0f1 100644 --- a/apps/api/app/api/markets/route.ts +++ b/apps/api/app/api/markets/route.ts @@ -13,9 +13,9 @@ export async function GET(req: Request): Promise +
+
{params.tag}
+ +
+ +
+
+ ) +} diff --git a/packages/api-helpers/client/index.ts b/packages/api-helpers/client/index.ts index 49f75c84..5513715c 100644 --- a/packages/api-helpers/client/index.ts +++ b/packages/api-helpers/client/index.ts @@ -88,10 +88,11 @@ export async function getMyBalance() { return apiHandler<{ balance: number }>(`${process.env.NEXT_PUBLIC_API_URL}/v1/users/me/balance`) } -export async function getMarkets() { +export async function getMarkets(params: { tag?: string } | undefined) { + const { tag } = params || {} return apiHandler<{ markets: Array - }>(`${process.env.NEXT_PUBLIC_API_URL}/v1/markets`, { + }>(`${process.env.NEXT_PUBLIC_API_URL}/v1/markets${tag ? `?tag=${tag}` : ''}`, { next: { tags: ['markets'] }, }) } diff --git a/packages/database/migrations/20240829035918_market_tags/migration.sql b/packages/database/migrations/20240829035918_market_tags/migration.sql new file mode 100644 index 00000000..2b8fb688 --- /dev/null +++ b/packages/database/migrations/20240829035918_market_tags/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Market" ADD COLUMN "tags" TEXT[]; diff --git a/packages/database/mocks.ts b/packages/database/mocks.ts index 882b7fef..f3db9e4f 100644 --- a/packages/database/mocks.ts +++ b/packages/database/mocks.ts @@ -63,6 +63,7 @@ export function mockMarket(overrides?: Partial): Market { description, slug: faker.helpers.slugify(question), closeDate, + tags: [faker.word.noun(), faker.word.noun(), faker.word.noun(), faker.word.noun(), faker.word.noun()], ammAccountId: faker.string.uuid(), clearingAccountId: faker.string.uuid(), resolvedAt: faker.helpers.maybe(faker.date.past, { probability: 0.2 }) ?? null, diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma index 36278563..72512007 100644 --- a/packages/database/schema.prisma +++ b/packages/database/schema.prisma @@ -87,6 +87,7 @@ model Market { resolvedAt DateTime? transactions Transaction[] createdBy String + tags String[] /// @zod.string.array(.max(5)) user User @relation(fields: [createdBy], references: [id]) ammAccountId String @unique ammAccount Account @relation("MarketAMMAccount", fields: [ammAccountId], references: [id]) diff --git a/packages/database/seed.ts b/packages/database/seed.ts index 953fcc69..33cb51bf 100644 --- a/packages/database/seed.ts +++ b/packages/database/seed.ts @@ -51,6 +51,7 @@ async function main() { description: `

${faker.lorem.paragraph()}

`, closeDate: faker.date.future(), createdBy: faker.helpers.arrayElement(user_ids), + tags: [faker.word.noun(), faker.word.noun(), faker.word.noun(), faker.word.noun(), faker.word.noun()], }) for (let j = 0; j < 10; j++) { diff --git a/packages/database/zod/inputTypeSchemas/MarketScalarFieldEnumSchema.ts b/packages/database/zod/inputTypeSchemas/MarketScalarFieldEnumSchema.ts index b19361f9..a12bd2e3 100644 --- a/packages/database/zod/inputTypeSchemas/MarketScalarFieldEnumSchema.ts +++ b/packages/database/zod/inputTypeSchemas/MarketScalarFieldEnumSchema.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -export const MarketScalarFieldEnumSchema = z.enum(['id','question','description','slug','closeDate','resolvedAt','createdBy','ammAccountId','clearingAccountId','createdAt','updatedAt']); +export const MarketScalarFieldEnumSchema = z.enum(['id','question','description','slug','closeDate','resolvedAt','createdBy','tags','ammAccountId','clearingAccountId','createdAt','updatedAt']); export default MarketScalarFieldEnumSchema; diff --git a/packages/database/zod/modelSchema/MarketSchema.ts b/packages/database/zod/modelSchema/MarketSchema.ts index b18ff760..38f7ec7b 100644 --- a/packages/database/zod/modelSchema/MarketSchema.ts +++ b/packages/database/zod/modelSchema/MarketSchema.ts @@ -12,6 +12,7 @@ export const MarketSchema = z.object({ closeDate: z.coerce.date().nullable(), resolvedAt: z.coerce.date().nullable(), createdBy: z.string(), + tags: z.string().array().max(5), ammAccountId: z.string(), clearingAccountId: z.string(), createdAt: z.coerce.date(), diff --git a/packages/markets/components/CreateMarketForm.tsx b/packages/markets/components/CreateMarketForm.tsx index e89da00b..a1a5f861 100644 --- a/packages/markets/components/CreateMarketForm.tsx +++ b/packages/markets/components/CreateMarketForm.tsx @@ -21,6 +21,7 @@ import { Editor } from '@play-money/ui/editor' import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@play-money/ui/form' import { Input } from '@play-money/ui/input' import { Label } from '@play-money/ui/label' +import { MultiSelect } from '@play-money/ui/multi-select' import { Popover, PopoverContent, PopoverTrigger } from '@play-money/ui/popover' import { RadioGroup, RadioGroupItem } from '@play-money/ui/radio-group' import { toast } from '@play-money/ui/use-toast' @@ -28,7 +29,12 @@ import { cn } from '@play-money/ui/utils' const COLORS = ['#03a9f4', '#e91e63', '#ff9800', '#8bc34a', '#9c27b0', '#ffc107', '#607d8b', '#009688', '#795548'] -const marketCreateFormSchema = MarketSchema.pick({ question: true, description: true, closeDate: true }).and( +const marketCreateFormSchema = MarketSchema.pick({ + question: true, + description: true, + closeDate: true, + tags: true, +}).and( z.object({ options: z.array(MarketOptionSchema.pick({ name: true, color: true })), type: z.enum(['binary', 'multi']), @@ -293,6 +299,25 @@ export function CreateMarketForm({ onSuccess }: { onSuccess?: () => Promise )} /> + + ( + + Tags + + ({ value: v, label: v }))} + onChange={(values) => onChange(values?.map((v) => v.value))} + {...field} + /> + + + + )} + /> + @@ -55,6 +56,7 @@ export const EditMarketDialog = ({ question: market.question, description: market.description || '

', closeDate: market.closeDate, + tags: market.tags, }, }) @@ -122,6 +124,25 @@ export const EditMarketDialog = ({ )} /> + + ( + + Tags + + ({ value: v, label: v }))} + onChange={(values) => onChange(values?.map((v) => v.value))} + {...field} + /> + + + + )} + /> + + + {market.tags.length ? ( +
+ {market.tags.map((tag) => ( + + {tag} + + ))} +
+ ) : null} {!market.resolvedAt ? ( diff --git a/packages/markets/lib/createMarket.ts b/packages/markets/lib/createMarket.ts index 7804c040..d384b0b7 100644 --- a/packages/markets/lib/createMarket.ts +++ b/packages/markets/lib/createMarket.ts @@ -17,6 +17,7 @@ export async function createMarket({ closeDate, createdBy, options, + tags, subsidyAmount = new Decimal(INITIAL_MARKET_LIQUIDITY_PRIMARY), }: { question: string @@ -24,6 +25,7 @@ export async function createMarket({ closeDate: Date | null createdBy: string options?: Array + tags?: Array subsidyAmount?: Decimal }) { let slug = slugifyTitle(question) @@ -62,6 +64,7 @@ export async function createMarket({ description, closeDate, slug, + tags: tags?.map((tag) => slugifyTitle(tag)), options: { createMany: { data: parsedOptions.map((option, i) => ({ diff --git a/packages/markets/lib/getMarkets.ts b/packages/markets/lib/getMarkets.ts index 42d23ce1..9fe0517b 100644 --- a/packages/markets/lib/getMarkets.ts +++ b/packages/markets/lib/getMarkets.ts @@ -6,6 +6,7 @@ import { getMarketClearingAccount } from './getMarketClearingAccount' interface MarketFilterOptions { createdBy?: string + tag?: string } interface SortOptions { @@ -26,6 +27,7 @@ export async function getMarkets( const markets = await db.market.findMany({ where: { createdBy: filters.createdBy, + tags: filters.tag ? { has: filters.tag } : undefined, }, include: { user: true, diff --git a/packages/markets/lib/updateMarket.ts b/packages/markets/lib/updateMarket.ts index 8fc63fe4..43f0d8ed 100644 --- a/packages/markets/lib/updateMarket.ts +++ b/packages/markets/lib/updateMarket.ts @@ -6,11 +6,13 @@ export async function updateMarket({ question, description, closeDate, + tags, }: { id: string question?: string description?: string closeDate?: Date + tags?: Array }) { const updatedData: Partial = {} @@ -27,6 +29,10 @@ export async function updateMarket({ updatedData.closeDate = closeDate } + if (tags) { + updatedData.tags = tags.map((tag) => slugifyTitle(tag)) + } + const updatedMarket = await db.market.update({ where: { id }, data: updatedData, diff --git a/packages/ui/package.json b/packages/ui/package.json index 9381136d..3a906a93 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -24,6 +24,7 @@ "./form": "./src/components/ui/form.tsx", "./input": "./src/components/ui/input.tsx", "./label": "./src/components/ui/label.tsx", + "./multi-select": "./src/components/ui/multi-select.tsx", "./popover": "./src/components/ui/popover.tsx", "./progress": "./src/components/ui/progress.tsx", "./radio-group": "./src/components/ui/radio-group.tsx", diff --git a/packages/ui/src/components/ui/multi-select.tsx b/packages/ui/src/components/ui/multi-select.tsx new file mode 100644 index 00000000..577cad35 --- /dev/null +++ b/packages/ui/src/components/ui/multi-select.tsx @@ -0,0 +1,621 @@ +'use client' + +import { Command as CommandPrimitive, useCommandState } from 'cmdk' +import { X } from 'lucide-react' +import * as React from 'react' +import { forwardRef, useEffect } from 'react' +import { cn } from '../../lib/utils' +import { Badge } from './badge' +import { Command, CommandGroup, CommandItem, CommandList } from './command' + +/* eslint-disable -- https://shadcnui-expansions.typeart.cc/docs/multiple-selector */ + +// Based on django's slugify: +// https://github.com/django/django/blob/a21a63cc288ba51bcf8c227a49de6f5bb9a72cc3/django/utils/text.py#L362 +export function slugifyTitle(title: string, maxLen = 50) { + let slug = title + .normalize('NFKD') // Normalize to decomposed form (eg. é -> e) + .toLowerCase() + .replace(/[^\w\s-]/g, '') // Remove non-word characters + .trim() + .replace(/[\s]/g, '-') // Replace whitespace with a dash + .replace(/-+/, '-') // Replace multiple dashes with a single dash + + if (slug.length > maxLen) { + slug = slug.substring(0, maxLen).replace(/-+[^-]*?$/, '') // Remove the last word, since it might be cut off + } + + return slug +} + +export interface Option { + value: string + label: string + disable?: boolean + /** fixed option that can't be removed. */ + fixed?: boolean + /** Group the options by providing key. */ + [key: string]: string | boolean | undefined +} +type GroupOption = Record> + +interface MultiSelectProps { + value?: Array