From d81791991c87d3df7a16793e7d976b543e87164d Mon Sep 17 00:00:00 2001 From: Konstantin Rybakov Date: Wed, 12 Jun 2024 08:03:02 +0300 Subject: [PATCH] rka-21: add job card actions --- app/components/list/action-button.tsx | 32 +++ app/components/list/actions/create-action.ts | 31 +++ app/components/list/actions/mark-property.ts | 11 + app/components/list/hooks/use-job-action.ts | 34 +++ app/components/list/job-card-actions.tsx | 53 +++++ app/{ => components/list}/job-card.tsx | 8 +- app/{ => components/list}/job-list.tsx | 0 app/{ => components/nav}/navigation.tsx | 0 app/layout.tsx | 4 +- app/page.tsx | 2 +- drizzle/0007_ancient_lady_bullseye.sql | 4 + drizzle/meta/0007_snapshot.json | 232 +++++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/db/queries.ts | 5 +- lib/db/queries/job-action.ts | 33 +++ lib/db/schema.ts | 15 +- 16 files changed, 463 insertions(+), 8 deletions(-) create mode 100644 app/components/list/action-button.tsx create mode 100644 app/components/list/actions/create-action.ts create mode 100644 app/components/list/actions/mark-property.ts create mode 100644 app/components/list/hooks/use-job-action.ts create mode 100644 app/components/list/job-card-actions.tsx rename app/{ => components/list}/job-card.tsx (86%) rename app/{ => components/list}/job-list.tsx (100%) rename app/{ => components/nav}/navigation.tsx (100%) create mode 100644 drizzle/0007_ancient_lady_bullseye.sql create mode 100644 drizzle/meta/0007_snapshot.json create mode 100644 lib/db/queries/job-action.ts diff --git a/app/components/list/action-button.tsx b/app/components/list/action-button.tsx new file mode 100644 index 0000000..5927fda --- /dev/null +++ b/app/components/list/action-button.tsx @@ -0,0 +1,32 @@ +import { Button } from '@radix-ui/themes' +import type { BaseButtonProps } from '@radix-ui/themes/dist/esm/components/base-button.js' +import type { ReactNode } from 'react' + +type ActionButtonProps = { + loading: boolean + clickHandler: () => void + isActive: boolean + colorActive: BaseButtonProps['color'] + icon: ReactNode + label: string +} + +export const ActionButton = ({ + loading, + clickHandler, + isActive, + colorActive, + icon, + label, +}: ActionButtonProps) => ( + +) diff --git a/app/components/list/actions/create-action.ts b/app/components/list/actions/create-action.ts new file mode 100644 index 0000000..234da71 --- /dev/null +++ b/app/components/list/actions/create-action.ts @@ -0,0 +1,31 @@ +import type { SelectJob } from '@/lib/db/schema' +import { logger } from '@/lib/logger' +import type { ActionResponse } from '@/lib/types/api' + +export const createAction = + (query: (jobId: SelectJob['id'], property: boolean) => Promise) => + async ( + jobId: SelectJob['id'], + property: boolean, + ): Promise> => { + const l = logger.child({ jobId, property }) + + try { + const result = await query(jobId, property) + + return { + data: result, + error: false, + } + } catch (error) { + l.error(error) + + return { + error: true, + errorMessage: + error instanceof Error + ? error.message + : '[createAction] Unknown error', + } + } + } diff --git a/app/components/list/actions/mark-property.ts b/app/components/list/actions/mark-property.ts new file mode 100644 index 0000000..e640c4c --- /dev/null +++ b/app/components/list/actions/mark-property.ts @@ -0,0 +1,11 @@ +'use server' +import { + queryMarkHidden, + queryMarkSeen, + queryMarkTopChoice, +} from '@/lib/db/queries/job-action' +import { createAction } from './create-action' + +export const actionMarkTopChoice = createAction(queryMarkTopChoice) +export const actionMarkHidden = createAction(queryMarkHidden) +export const actionMarkSeen = createAction(queryMarkSeen) diff --git a/app/components/list/hooks/use-job-action.ts b/app/components/list/hooks/use-job-action.ts new file mode 100644 index 0000000..880024a --- /dev/null +++ b/app/components/list/hooks/use-job-action.ts @@ -0,0 +1,34 @@ +import type { SelectJob } from '@/lib/db/schema' +import { useState } from 'react' +import type { createAction } from '../actions/create-action' + +export const useJobAction = ( + initialState: boolean, + action: ReturnType, +) => { + const [isActive, setIsActive] = useState(initialState) + const [loading, setLoading] = useState(false) + + const clickHandler = async (jobId: SelectJob['id']) => { + const newState = !isActive + + setLoading(true) + + try { + const result = await action(jobId, newState) + + if (result.error) { + // TODO: Add toast notifications + console.error(result.errorMessage) + } else { + setIsActive(newState) + } + } catch (error) { + console.error(error) + } finally { + setLoading(false) + } + } + + return { isActive, loading, clickHandler } +} diff --git a/app/components/list/job-card-actions.tsx b/app/components/list/job-card-actions.tsx new file mode 100644 index 0000000..b0a9a97 --- /dev/null +++ b/app/components/list/job-card-actions.tsx @@ -0,0 +1,53 @@ +'use client' +import type { SelectJob } from '@/lib/db/schema' +import { + EyeNoneIcon, + EyeOpenIcon, + LightningBoltIcon, +} from '@radix-ui/react-icons' +import { Text } from '@radix-ui/themes' +import { ActionButton } from './action-button' +import { + actionMarkHidden, + actionMarkSeen, + actionMarkTopChoice, +} from './actions/mark-property' +import { useJobAction } from './hooks/use-job-action' + +export const JobCardActions = ({ job }: { job: SelectJob }) => { + const hidden = useJobAction(job.isHidden, actionMarkHidden) + const topChoice = useJobAction(job.isTopChoice, actionMarkTopChoice) + const seen = useJobAction(job.isSeen, actionMarkSeen) + + return ( + <> + + Mark as: + + hidden.clickHandler(job.id)} + colorActive="sky" + icon={} + label="Hidden" + /> + topChoice.clickHandler(job.id)} + colorActive="plum" + icon={} + label="Top Choice" + /> + seen.clickHandler(job.id)} + colorActive="grass" + icon={} + label="Seen" + /> + + ) +} diff --git a/app/job-card.tsx b/app/components/list/job-card.tsx similarity index 86% rename from app/job-card.tsx rename to app/components/list/job-card.tsx index 8a81f47..f6cca64 100644 --- a/app/job-card.tsx +++ b/app/components/list/job-card.tsx @@ -12,6 +12,7 @@ import { Text, } from '@radix-ui/themes' import NextLink from 'next/link' +import { JobCardActions } from './job-card-actions' type JobCardProps = { job: Awaited[number] @@ -21,8 +22,8 @@ type JobCardProps = { export const JobCard = async ({ job }: JobCardProps) => { return ( - - + + {job.title} @@ -53,6 +54,9 @@ export const JobCard = async ({ job }: JobCardProps) => { }).format(new Date(job.lastUpdatedAt))} + + + ) diff --git a/app/job-list.tsx b/app/components/list/job-list.tsx similarity index 100% rename from app/job-list.tsx rename to app/components/list/job-list.tsx diff --git a/app/navigation.tsx b/app/components/nav/navigation.tsx similarity index 100% rename from app/navigation.tsx rename to app/components/nav/navigation.tsx diff --git a/app/layout.tsx b/app/layout.tsx index 5e9a880..5de7acf 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -9,7 +9,7 @@ import { Box, Container, Flex, Section, Theme } from '@radix-ui/themes' import type { Metadata } from 'next' import '@radix-ui/themes/styles.css' -import { Navigation } from './navigation' +import { Navigation } from './components/nav/navigation' export const metadata: Metadata = { title: 'OWAT!', @@ -28,7 +28,7 @@ export default function RootLayout({
- + diff --git a/app/page.tsx b/app/page.tsx index c352135..45c8a7e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,7 +3,7 @@ import { type User, currentUser } from '@clerk/nextjs/server' import { PlusIcon } from '@radix-ui/react-icons' import { Box, Button, Heading, Section } from '@radix-ui/themes' import Link from 'next/link' -import { JobList } from './job-list' +import { JobList } from './components/list/job-list' export default async function Home() { // TODO: Fix user management diff --git a/drizzle/0007_ancient_lady_bullseye.sql b/drizzle/0007_ancient_lady_bullseye.sql new file mode 100644 index 0000000..4881345 --- /dev/null +++ b/drizzle/0007_ancient_lady_bullseye.sql @@ -0,0 +1,4 @@ +ALTER TABLE "jobs" ADD COLUMN "is_seen" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "jobs" ADD COLUMN "is_hidden" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "jobs" ADD COLUMN "is_top_choice" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "jobs" ADD COLUMN "is_applied" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..88e2776 --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,232 @@ +{ + "id": "3f6c5f5d-1c9c-46ed-8595-732cbc03d7fa", + "prevId": "1affdfae-cbeb-4867-b25b-9dacad9b76f2", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tracker_url": { + "name": "tracker_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tracker_type": { + "name": "tracker_type", + "type": "tracker_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "hiring_platform": { + "name": "hiring_platform", + "type": "hiring_platform", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "companies_tracker_url_unique": { + "name": "companies_tracker_url_unique", + "nullsNotDistinct": false, + "columns": [ + "tracker_url" + ] + } + } + }, + "public.jobs": { + "name": "jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_updated_at": { + "name": "last_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "departments": { + "name": "departments", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_seen": { + "name": "is_seen", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_hidden": { + "name": "is_hidden", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_top_choice": { + "name": "is_top_choice", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_applied": { + "name": "is_applied", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "jobs_company_id_companies_id_fk": { + "name": "jobs_company_id_companies_id_fk", + "tableFrom": "jobs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "jobs_url_unique": { + "name": "jobs_url_unique", + "nullsNotDistinct": false, + "columns": [ + "url" + ] + } + } + } + }, + "enums": { + "public.hiring_platform": { + "name": "hiring_platform", + "schema": "public", + "values": [ + "greenhouse" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "open", + "closed" + ] + }, + "public.tracker_type": { + "name": "tracker_type", + "schema": "public", + "values": [ + "hiring_platform" + ] + } + }, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index bcfd705..71b179d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1717929473056, "tag": "0006_marvelous_rumiko_fujikawa", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1718042650768, + "tag": "0007_ancient_lady_bullseye", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/queries.ts b/lib/db/queries.ts index bf3b2a7..807b569 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -1,6 +1,6 @@ // TODO: split this file -import { and, eq, notInArray, sql } from 'drizzle-orm' +import { and, asc, eq, notInArray, sql } from 'drizzle-orm' import { db } from './db' import { type InsertCompany, type InsertJob, companies, jobs } from './schema' @@ -62,6 +62,7 @@ export const queryInsertJobs = async (jobList: InsertJob[]) => { status: 'open', }, }) + .returning({ id: jobs.id }) return result } @@ -73,6 +74,7 @@ export const queryGetJobs = async () => { with: { company: true, }, + orderBy: [asc(jobs.companyId), asc(jobs.title)], }) return result @@ -96,6 +98,7 @@ export const queryMarkJobsAsClosed = async ( ), ), ) + .returning({ id: jobs.id }) return result } diff --git a/lib/db/queries/job-action.ts b/lib/db/queries/job-action.ts new file mode 100644 index 0000000..91862ae --- /dev/null +++ b/lib/db/queries/job-action.ts @@ -0,0 +1,33 @@ +import { logger } from '@/lib/logger' +import { eq } from 'drizzle-orm' +import { db } from '../db' +import { type SelectJob, jobs } from '../schema' + +type JobActionField = keyof Pick< + SelectJob, + 'isTopChoice' | 'isHidden' | 'isSeen' +> + +const createQuery = + (field: JobActionField) => async (jobId: SelectJob['id'], value: boolean) => { + const l = logger.child({ jobId, field, value }) + + l.debug('Updating job') + + const result = await db + .update(jobs) + .set({ [field]: value }) + .where(eq(jobs.id, jobId)) + .returning({ id: jobs.id }) + + return result + } + +export const queryMarkTopChoice = createQuery('isTopChoice') +export type QueryMarkTopChoiceResult = ReturnType + +export const queryMarkHidden = createQuery('isHidden') +export type QueryMarkHiddenResult = ReturnType + +export const queryMarkSeen = createQuery('isSeen') +export type QueryMarkSeenResult = ReturnType diff --git a/lib/db/schema.ts b/lib/db/schema.ts index ccd959f..99d09f9 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -1,5 +1,6 @@ import { relations } from 'drizzle-orm' import { + boolean, integer, json, pgEnum, @@ -20,7 +21,10 @@ export const hiringPlatform = pgEnum('hiring_platform', hiringPlatforms) export const companies = pgTable('companies', { id: serial('id').primaryKey(), createdAt: timestamp('created_at').notNull().defaultNow(), - updatedAt: timestamp('updated_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at') + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), name: text('name').notNull(), trackerURL: text('tracker_url').notNull().unique(), trackerType: trackerType('tracker_type').notNull(), @@ -41,7 +45,10 @@ export const jobStatus = pgEnum('job_status', jobStatuses) export const jobs = pgTable('jobs', { id: serial('id').primaryKey(), createdAt: timestamp('created_at').notNull().defaultNow(), - updatedAt: timestamp('updated_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at') + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), url: text('url').notNull().unique(), title: text('title').notNull(), location: text('location').notNull(), @@ -52,6 +59,10 @@ export const jobs = pgTable('jobs', { companyId: integer('company_id') .notNull() .references(() => companies.id, { onDelete: 'cascade' }), + isSeen: boolean('is_seen').notNull().default(false), + isHidden: boolean('is_hidden').notNull().default(false), + isTopChoice: boolean('is_top_choice').notNull().default(false), + isApplied: boolean('is_applied').notNull().default(false), }) export type SelectJob = typeof jobs.$inferSelect