diff --git a/.env.example b/.env.example index 7cadcc6f..7bd06dbf 100644 --- a/.env.example +++ b/.env.example @@ -1,39 +1,22 @@ -# Since the ".env" file is gitignored, you can use the ".env.example" file to -# build a new ".env" file when you clone the repo. Keep this file up-to-date -# when you add new variables to `.env`. - -# This file will be committed to version control, so make sure not to have any -# secrets in it. If you are cloning this repo, create a copy of this file named -# ".env" and populate it with your secrets. - -# When adding additional environment variables, the schema in "/src/env.mjs" -# should be updated accordingly. - -# Vercel Postgres -POSTGRES_URL="************" -POSTGRES_PRISMA_URL="************" -POSTGRES_URL_NO_SSL="************" -POSTGRES_URL_NON_POOLING="************" -POSTGRES_USER="************" -POSTGRES_HOST="************" -POSTGRES_PASSWORD="************" -POSTGRES_DATABASE="************" - -NEXT_PUBLIC_URL="http://localhost:3000" - - -# Uploadthing -UPLOADTHING_SECRET="" -UPLOADTHING_APP_ID="" - - -# optional global override for analytics -# can be used to diable analytics in development -#NEXT_PUBLIC_DISABLE_ANALYTICS=true - -# optional manual specification for installation ID. Useful in scenarios such as -# CI/CD where the installation ID cannot be automatically determined because -# there is no database. Also useful for ensuring consistent ID between DB -# resets. -#INSTALLATION_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - +# ------------------- +# Optional environment variables - uncomment to use +# ------------------- + +#SANDBOX_MODE=false # true or false - if true, the app will use the sandbox mode, which disables resetting the database and other features +#PUBLIC_URL="http://yourdomain.com" # When using advanced deployment, this is required. Set to the domain name of your app +#DISABLE_ANALYTICS=true # true or false - if true, the app will not send anonymous analytics data to the server +#INSTALLATION_ID="your-app-name" # A unique identifier for your app, used for analytics. Generated automatically if not set. + +# ------------------- +# Required environment variables +# ------------------- + +UPLOADTHING_SECRET=sk_live_xxxxxx # Your UploadThing secret key +UPLOADTHING_APP_ID=xxxxxxx # Your UploadThing app ID + +POSTGRES_USER="postgres" # Your PostgreSQL username +POSTGRES_PASSWORD="postgres" # Your PostgreSQL password +POSTGRES_DATABASE="postgres" # Your PostgreSQL database name +POSTGRES_HOST="postgres" # Your PostgreSQL host +POSTGRES_PRISMA_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DATABASE}?schema=public" # A pooled connection URL for Prisma. +POSTGRES_URL_NON_POOLING="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DATABASE}?schema=public" # A non-pooling connection URL for Prisma \ No newline at end of file diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 90d8197c..06a38878 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -1,4 +1,3 @@ -// eslint-disable-next-line @typescript-eslint/no-var-requires const path = require('path'); /** @type {import("eslint").Linter.Config} */ @@ -19,7 +18,7 @@ const config = { parserOptions: { project: path.join(__dirname, 'tsconfig.json'), }, - plugins: ['@typescript-eslint', 'eslint-plugin-local-rules'], + plugins: ['@typescript-eslint'], extends: [ 'eslint:recommended', 'plugin:@typescript-eslint/stylistic', @@ -27,7 +26,7 @@ const config = { 'next/core-web-vitals', 'prettier', ], - ignorePatterns: ['node_modules', '*.stories.*', '*.test.*'], + ignorePatterns: ['node_modules', '*.stories.*', '*.test.*', 'public', '.eslintrc.cjs',], rules: { "@next/next/no-img-element": "off", "import/no-anonymous-default-export": "off", @@ -42,7 +41,7 @@ const config = { }, ], '@typescript-eslint/no-unused-vars': [ - 'warn', + 'error', { argsIgnorePattern: '^_', }, @@ -54,7 +53,7 @@ const config = { } ], 'no-unreachable': 'error', - 'local-rules/require-data-mapper': 'error', }, }; + module.exports = config; diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 734d76ff..add5e8da 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Build +name: Lint on: pull_request: @@ -13,30 +13,26 @@ jobs: SKIP_ENV_VALIDATION: true steps: - - name: Checkout code - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v2 + - name: Enable Corepack + run: corepack enable + + - name: Use Node 20.x + uses: actions/setup-node@v4 with: - version: 8 + node-version: 20.x + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile - name: Get pnpm store directory shell: bash run: | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - - uses: actions/cache@v3 - name: Setup pnpm cache - with: - path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - uses: actions/cache@v3 + - uses: actions/cache@v4 name: Setup next cache with: path: | @@ -49,4 +45,10 @@ jobs: ${{ runner.os }}-nextjs-${{ hashFiles('**/pnpm-lock.yaml') }}- - name: Run build - run: pnpm next build + run: pnpm lint + + - name: Run tests + run: pnpm test + + - name: Run knip + run: pnpm knip diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..30dc564a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "plugins": ["prettier-plugin-tailwindcss"], + "printWidth": 80, + "quoteProps": "consistent", + "singleQuote": true +} diff --git a/.vscode/settings.json b/.vscode/settings.json index d393a680..1af7fbce 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,5 +6,7 @@ "typescript.enablePromptUseWorkspaceTsdk": true, "WillLuke.nextjs.addTypesOnSave": true, "WillLuke.nextjs.hasPrompted": true, - "jest.jestCommandLine": "pnpm run test" + "editor.codeActionsOnSave": { + "source.organizeImports": "always" + } } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 517f8523..b06839ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,22 @@ -FROM node:18-alpine AS base -RUN corepack enable -ENV SKIP_ENV_VALIDATION=true +FROM node:lts-alpine AS base # Install dependencies only when needed FROM base AS deps + # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. -RUN apk add --no-cache libc6-compat +#RUN apk add --no-cache libc6-compat WORKDIR /app # Prisma stuff COPY prisma ./prisma -# Install dependencies -COPY package.json pnpm-lock.yaml* ./ -COPY postinstall.mjs ./postinstall.mjs -RUN pnpm install -RUN pnpm prisma generate +# Copy package.json and lockfile, along with postinstall script +COPY package.json pnpm-lock.yaml* postinstall.js migrate-and-start.sh handle-migrations.js ./ + +# # Install pnpm and install dependencies +RUN corepack enable pnpm && pnpm i --frozen-lockfile + +# --------- # Rebuild the source code only when needed FROM base AS builder @@ -23,13 +24,11 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . +# Install git - this is needed to get the app version during build +RUN apk add --no-cache git -ENV NEXT_TELEMETRY_DISABLED 1 - -# Install git -RUN apk update && apk add --no-cache git - -RUN pnpm run build +ENV SKIP_ENV_VALIDATION=true +RUN corepack enable pnpm && pnpm run build # If using npm comment out above and use below instead # RUN npm run build @@ -39,7 +38,8 @@ FROM base AS runner WORKDIR /app ENV NODE_ENV production -# Uncomment the following line in case you want to disable telemetry during runtime. + +# disable telemetry during runtime. ENV NEXT_TELEMETRY_DISABLED 1 RUN addgroup --system --gid 1001 nodejs @@ -55,13 +55,17 @@ RUN chown nextjs:nodejs .next # https://nextjs.org/docs/advanced-features/output-file-tracing COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/handle-migrations.js ./ +COPY --from=builder --chown=nextjs:nodejs /app/migrate-and-start.sh ./ +COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma USER nextjs EXPOSE 3000 ENV PORT 3000 -# set hostname to localhost -ENV HOSTNAME "0.0.0.0" -CMD ["node", "server.js"] \ No newline at end of file +# server.js is created by next build from the standalone output +# https://nextjs.org/docs/pages/api-reference/next-config-js/output +# CMD HOSTNAME="0.0.0.0" npm run start:prod +CMD ["sh", "migrate-and-start.sh"] \ No newline at end of file diff --git a/actions/activityFeed.ts b/actions/activityFeed.ts new file mode 100644 index 00000000..8914064b --- /dev/null +++ b/actions/activityFeed.ts @@ -0,0 +1,25 @@ +'use sever'; + +import { revalidateTag } from 'next/cache'; +import type { Activity, ActivityType } from '~/lib/data-table/types'; +import { prisma } from '~/utils/db'; + +export async function addEvent( + type: ActivityType, + message: Activity['message'], +) { + try { + await prisma.events.create({ + data: { + type, + message, + }, + }); + + revalidateTag('activityFeed'); + + return { success: true, error: null }; + } catch (error) { + return { success: false, error: 'Failed to add event' }; + } +} diff --git a/actions/appSettings.ts b/actions/appSettings.ts new file mode 100644 index 00000000..c0075bf7 --- /dev/null +++ b/actions/appSettings.ts @@ -0,0 +1,51 @@ +'use server'; + +import { revalidateTag } from 'next/cache'; +import { redirect } from 'next/navigation'; +import { requireApiAuth } from '~/utils/auth'; +import { prisma } from '~/utils/db'; + +export async function setAnonymousRecruitment(input: boolean) { + await requireApiAuth(); + + await prisma.appSettings.updateMany({ + data: { + allowAnonymousRecruitment: input, + }, + }); + + revalidateTag('allowAnonymousRecruitment'); + + return input; +} + +export async function setLimitInterviews(input: boolean) { + await requireApiAuth(); + await prisma.appSettings.updateMany({ + data: { + limitInterviews: input, + }, + }); + + revalidateTag('limitInterviews'); + + return input; +} + +export const setAppConfigured = async () => { + await requireApiAuth(); + + try { + await prisma.appSettings.updateMany({ + data: { + configured: true, + }, + }); + + revalidateTag('appSettings'); + } catch (error) { + return { error: 'Failed to update appSettings', appSettings: null }; + } + + redirect('/dashboard'); +}; diff --git a/actions/auth.ts b/actions/auth.ts new file mode 100644 index 00000000..92e0fb5b --- /dev/null +++ b/actions/auth.ts @@ -0,0 +1,153 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { cookies } from 'next/headers'; +import { createUserFormDataSchema, loginSchema } from '~/schemas/auth'; +import { auth, getServerSession } from '~/utils/auth'; +import { prisma } from '~/utils/db'; + +export async function signup(formData: unknown) { + const parsedFormData = createUserFormDataSchema.safeParse(formData); + + if (!parsedFormData.success) { + return { + success: false, + error: 'Invalid form submission', + }; + } + + try { + const { username, password } = parsedFormData.data; + + const user = await auth.createUser({ + key: { + providerId: 'username', // auth method + providerUserId: username, // unique id when using "username" auth method + password, // hashed by Lucia + }, + attributes: { + username, + }, + }); + + const session = await auth.createSession({ + userId: user.userId, + attributes: {}, + }); + + if (!session) { + return { + success: false, + error: 'Failed to create session', + }; + } + + // set session cookie + + const sessionCookie = auth.createSessionCookie(session); + + cookies().set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes, + ); + + return { + success: true, + }; + } catch (error) { + // db error, email taken, etc + return { + success: false, + error: 'Username already taken', + }; + } +} + +export const login = async ( + data: unknown, +): Promise< + | { + success: true; + } + | { + success: false; + formErrors: string[]; + fieldErrors?: Record; + } +> => { + const parsedFormData = loginSchema.safeParse(data); + + if (!parsedFormData.success) { + return { + success: false, + ...parsedFormData.error.flatten(), + }; + } + + const { username, password } = parsedFormData.data; + + // get user by userId + const existingUser = await prisma.user.findFirst({ + where: { + username, + }, + }); + + if (!existingUser) { + // NOTE: + // Returning immediately allows malicious actors to figure out valid usernames from response times, + // allowing them to only focus on guessing passwords in brute-force attacks. + // As a preventive measure, you may want to hash passwords even for invalid usernames. + // However, valid usernames can be already be revealed with the signup page among other methods. + // It will also be much more resource intensive. + // Since protecting against this is non-trivial, + // it is crucial your implementation is protected against brute-force attacks with login throttling etc. + // If usernames are public, you may outright tell the user that the username is invalid. + // eslint-disable-next-line no-console + console.log('invalid username'); + return { + success: false, + formErrors: ['Incorrect username or password'], + }; + } + + let key; + try { + key = await auth.useKey('username', username, password); + } catch (e) { + return { + success: false, + formErrors: ['Incorrect username or password'], + }; + } + + const session = await auth.createSession({ + userId: key.userId, + attributes: {}, + }); + + const sessionCookie = auth.createSessionCookie(session); + cookies().set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes, + ); + + return { + success: true, + }; +}; + +export async function logout() { + const session = await getServerSession(); + if (!session) { + return { + error: 'Unauthorized', + }; + } + + await auth.invalidateSession(session.sessionId); + + revalidatePath('/'); +} diff --git a/actions/interviews.ts b/actions/interviews.ts new file mode 100644 index 00000000..011ad406 --- /dev/null +++ b/actions/interviews.ts @@ -0,0 +1,308 @@ +'use server'; + +import { createId } from '@paralleldrive/cuid2'; +import { Prisma, type Interview, type Protocol } from '@prisma/client'; +import { revalidateTag } from 'next/cache'; +import { cookies } from 'next/headers'; +import trackEvent from '~/lib/analytics'; +import type { InstalledProtocols } from '~/lib/interviewer/store'; +import { formatExportableSessions } from '~/lib/network-exporters/formatters/formatExportableSessions'; +import archive from '~/lib/network-exporters/formatters/session/archive'; +import { generateOutputFiles } from '~/lib/network-exporters/formatters/session/generateOutputFiles'; +import groupByProtocolProperty from '~/lib/network-exporters/formatters/session/groupByProtocolProperty'; +import { insertEgoIntoSessionNetworks } from '~/lib/network-exporters/formatters/session/insertEgoIntoSessionNetworks'; +import { resequenceIds } from '~/lib/network-exporters/formatters/session/resequenceIds'; +import type { + ExportOptions, + ExportReturn, + FormattedSession, +} from '~/lib/network-exporters/utils/types'; +import { getInterviewsForExport } from '~/queries/interviews'; +import type { + CreateInterview, + DeleteInterviews, + SyncInterview, +} from '~/schemas/interviews'; +import { type NcNetwork } from '~/schemas/network-canvas'; +import { requireApiAuth } from '~/utils/auth'; +import { prisma } from '~/utils/db'; +import { ensureError } from '~/utils/ensureError'; +import { addEvent } from './activityFeed'; +import { uploadZipToUploadThing } from './uploadThing'; + +export async function deleteInterviews(data: DeleteInterviews) { + await requireApiAuth(); + + const idsToDelete = data.map((p) => p.id); + + try { + const deletedInterviews = await prisma.interview.deleteMany({ + where: { + id: { + in: idsToDelete, + }, + }, + }); + + void addEvent( + 'Interview(s) Deleted', + `Deleted ${deletedInterviews.count} interview(s)`, + ); + + revalidateTag('getInterviews'); + revalidateTag('summaryStatistics'); + + return { error: null, interview: deletedInterviews }; + } catch (error) { + return { error: 'Failed to delete interviews', interview: null }; + } +} + +export const updateExportTime = async (interviewIds: Interview['id'][]) => { + await requireApiAuth(); + try { + const updatedInterviews = await prisma.interview.updateMany({ + where: { + id: { + in: interviewIds, + }, + }, + data: { + exportTime: new Date(), + }, + }); + + revalidateTag('getInterviews'); + + void addEvent( + 'Data Exported', + `Exported data for ${updatedInterviews.count} interview(s)`, + ); + + return { error: null, interview: updatedInterviews }; + } catch (error) { + return { error: 'Failed to update interviews', interview: null }; + } +}; + +export const prepareExportData = async (interviewIds: Interview['id'][]) => { + await requireApiAuth(); + + const interviewsSessions = await getInterviewsForExport(interviewIds); + + const protocolsMap = new Map(); + interviewsSessions.forEach((session) => { + protocolsMap.set(session.protocol.hash, session.protocol); + }); + + const formattedProtocols: InstalledProtocols = + Object.fromEntries(protocolsMap); + const formattedSessions = formatExportableSessions(interviewsSessions); + + return { formattedSessions, formattedProtocols }; +}; + +export const exportSessions = async ( + formattedSessions: FormattedSession[], + formattedProtocols: InstalledProtocols, + interviewIds: Interview['id'][], + exportOptions: ExportOptions, +): Promise => { + await requireApiAuth(); + + try { + const result = await Promise.resolve(formattedSessions) + .then(insertEgoIntoSessionNetworks) + .then(groupByProtocolProperty) + .then(resequenceIds) + .then(generateOutputFiles(formattedProtocols, exportOptions)) + .then(archive) + .then(uploadZipToUploadThing); + + void trackEvent({ + type: 'DataExported', + metadata: { + status: result.status, + sessions: interviewIds.length, + exportOptions, + result: result, + }, + }); + + revalidateTag('getInterviews'); + + return result; + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + const e = ensureError(error); + void trackEvent({ + type: 'Error', + name: e.name, + message: e.message, + stack: e.stack, + metadata: { + path: '~/actions/interviews.ts', + }, + }); + + return { + status: 'error', + error: `Error during data export: ${e.message}`, + }; + } +}; + +export async function createInterview(data: CreateInterview) { + const { participantIdentifier, protocolId } = data; + + /** + * If no participant identifier is provided, we check if anonymous recruitment is enabled. + * If it is, we create a new participant and use that identifier. + */ + const participantStatement = participantIdentifier + ? { + connect: { + identifier: participantIdentifier, + }, + } + : { + create: { + identifier: `p-${createId()}`, + label: 'Anonymous Participant', + }, + }; + + try { + if (!participantIdentifier) { + const appSettings = await prisma.appSettings.findFirst(); + if (!appSettings || !appSettings.allowAnonymousRecruitment) { + return { + errorType: 'no-anonymous-recruitment', + error: 'Anonymous recruitment is not enabled', + createdInterviewId: null, + }; + } + } + + const createdInterview = await prisma.interview.create({ + select: { + participant: true, + id: true, + }, + data: { + network: Prisma.JsonNull, + participant: participantStatement, + protocol: { + connect: { + id: protocolId, + }, + }, + }, + }); + + void addEvent( + 'Interview Started', + `Participant "${ + createdInterview.participant.label ?? + createdInterview.participant.identifier + }" started an interview`, + ); + + revalidateTag('getInterviews'); + revalidateTag('getParticipants'); + revalidateTag('summaryStatistics'); + + return { + error: null, + createdInterviewId: createdInterview.id, + errorType: null, + }; + } catch (error) { + const e = ensureError(error); + + void trackEvent({ + type: 'Error', + name: e.name, + message: e.message, + stack: e.stack, + metadata: { + path: '/routers/interview.ts', + }, + }); + + return { + errorType: e.message, + error: 'Failed to create interview', + createdInterviewId: null, + }; + } +} + +export async function syncInterview(data: SyncInterview) { + const { id, network, currentStep, stageMetadata } = data; + + try { + await prisma.interview.update({ + where: { + id, + }, + data: { + network, + currentStep, + stageMetadata, + lastUpdated: new Date(), + }, + }); + + revalidateTag(`getInterviewById-${id}`); + + // eslint-disable-next-line no-console + console.log(`🚀 Interview synced with server! (${id})`); + return { success: true }; + } catch (error) { + const message = ensureError(error).message; + return { success: false, error: message }; + } +} + +export type SyncInterviewType = typeof syncInterview; + +export async function finishInterview(interviewId: Interview['id']) { + try { + const updatedInterview = await prisma.interview.update({ + where: { + id: interviewId, + }, + data: { + finishTime: new Date(), + }, + }); + + void addEvent( + 'Interview Completed', + `Interview with ID ${interviewId} has been completed`, + ); + + const network = JSON.parse( + JSON.stringify(updatedInterview.network), + ) as NcNetwork; + + void trackEvent({ + type: 'InterviewCompleted', + metadata: { + nodeCount: network?.nodes?.length ?? 0, + edgeCount: network?.edges?.length ?? 0, + }, + }); + + cookies().set(updatedInterview.protocolId, 'completed'); + + revalidateTag('getInterviews'); + revalidateTag('summaryStatistics'); + + return { error: null }; + } catch (error) { + return { error: 'Failed to finish interview' }; + } +} diff --git a/actions/participants.ts b/actions/participants.ts new file mode 100644 index 00000000..aa08e87c --- /dev/null +++ b/actions/participants.ts @@ -0,0 +1,171 @@ +'use server'; + +import { createId } from '@paralleldrive/cuid2'; +import { revalidateTag } from 'next/cache'; +import { addEvent } from '~/actions/activityFeed'; +import { + participantListInputSchema, + updateSchema, +} from '~/schemas/participant'; +import { requireApiAuth } from '~/utils/auth'; +import { prisma } from '~/utils/db'; + +export async function deleteParticipants(participantIds: string[]) { + await requireApiAuth(); + + const result = await prisma.participant.deleteMany({ + where: { + id: { in: participantIds }, + }, + }); + + void addEvent( + 'Participant(s) Removed', + `Deleted ${result.count} participant(s)`, + ); + + revalidateTag('getParticipants'); + revalidateTag('getInterviews'); + revalidateTag('summaryStatistics'); +} + +export async function deleteAllParticipants() { + await requireApiAuth(); + + const result = await prisma.participant.deleteMany(); + + void addEvent( + 'Participant(s) Removed', + `Deleted ${result.count} participant(s)`, + ); + + revalidateTag('getParticipants'); + revalidateTag('getInterviews'); + revalidateTag('summaryStatistics'); +} + +export async function importParticipants(rawInput: unknown) { + await requireApiAuth(); + + const participantList = participantListInputSchema.parse(rawInput); + + + /* + Format participantList: + - Ensure all participants have an identifier by generating one for any that don't have one. + - If participant label is empty string, set it to undefined + */ + const participantsWithIdentifiers = participantList.map((participant) => { + return { + identifier: !participant.identifier ? createId() : participant.identifier, + label: participant.label === '' ? undefined : participant.label, + }; + }); + + try { + const [existingParticipants, createdParticipants] = + await prisma.$transaction([ + prisma.participant.findMany({ + where: { + identifier: { + in: participantsWithIdentifiers.map((p) => p.identifier), + }, + }, + }), + prisma.participant.createMany({ + data: participantsWithIdentifiers, + skipDuplicates: true, + }), + ]); + + void addEvent( + 'Participant(s) Added', + `Added ${createdParticipants.count} participant(s)`, + ); + + revalidateTag('getParticipants'); + revalidateTag('summaryStatistics'); + + return { + error: null, + createdParticipants: createdParticipants.count, + existingParticipants: existingParticipants, + }; + } catch (error) { + return { + error: 'Failed to create participant', + createdParticipants: null, + existingParticipants: null, + }; + } +} + +export async function updateParticipant(rawInput: unknown) { + await requireApiAuth(); + + const { identifier, data } = updateSchema.parse(rawInput); + + try { + const updatedParticipant = await prisma.participant.update({ + where: { identifier }, + data, + }); + + revalidateTag('getParticipants'); + revalidateTag('summaryStatistics'); + + return { error: null, participant: updatedParticipant }; + } catch (error) { + return { error: 'Failed to update participant', participant: null }; + } +} + +export async function createParticipant(rawInput: unknown) { + await requireApiAuth(); + + const participants = participantListInputSchema.parse(rawInput); + + const participantsWithIdentifiers = participants.map((participant) => { + return { + identifier: participant.identifier ?? createId(), + ...participant, + }; + }); + + try { + const [existingParticipants, createdParticipants] = + await prisma.$transaction([ + prisma.participant.findMany({ + where: { + identifier: { + in: participantsWithIdentifiers.map((p) => p.identifier), + }, + }, + }), + prisma.participant.createMany({ + data: participantsWithIdentifiers, + skipDuplicates: true, + }), + ]); + + void addEvent( + 'Participant(s) Added', + `Added ${createdParticipants.count} participant(s)`, + ); + + revalidateTag('getParticipants'); + revalidateTag('summaryStatistics'); + + return { + error: null, + createdParticipants: createdParticipants.count, + existingParticipants: existingParticipants, + }; + } catch (error) { + return { + error: 'Failed to create participant', + createdParticipants: null, + existingParticipants: null, + }; + } +} diff --git a/actions/protocols.ts b/actions/protocols.ts new file mode 100644 index 00000000..fb28ba15 --- /dev/null +++ b/actions/protocols.ts @@ -0,0 +1,188 @@ +'use server'; + +import { type Protocol } from '@codaco/shared-consts'; +import { Prisma } from '@prisma/client'; +import { revalidateTag } from 'next/cache'; +import { hash } from 'ohash'; +import { UTApi } from 'uploadthing/server'; +import { type z } from 'zod'; +import { protocolInsertSchema } from '~/schemas/protocol'; +import { requireApiAuth } from '~/utils/auth'; +import { prisma } from '~/utils/db'; +import { addEvent } from './activityFeed'; + +// When deleting protocols we must first delete the assets associated with them +// from the cloud storage. +export async function deleteProtocols(hashes: string[]) { + await requireApiAuth(); + + const protocolsToBeDeleted = await prisma.protocol.findMany({ + where: { hash: { in: hashes } }, + select: { id: true, name: true }, + }); + + // Select assets that are ONLY associated with the protocols to be deleted + const assetKeysToDelete = await prisma.asset.findMany({ + where: { + protocols: { + every: { + id: { + in: protocolsToBeDeleted.map((p) => p.id), + }, + }, + }, + }, + select: { key: true }, + }); + + // We put asset deletion in a separate try/catch because if it fails, we still + // want to delete the protocol. + try { + // eslint-disable-next-line no-console + console.log('deleting protocol assets...'); + + await deleteFilesFromUploadThing(assetKeysToDelete.map((a) => a.key)); + } catch (error) { + // eslint-disable-next-line no-console + console.log('Error deleting protocol assets!', error); + } + + // Delete assets in assetKeysToDelete from the database + + try { + // eslint-disable-next-line no-console + console.log('deleting assets from database...'); + await prisma.asset.deleteMany({ + where: { + key: { + in: assetKeysToDelete.map((a) => a.key), + }, + }, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.log('Error deleting assets from database!', error); + } + + try { + const deletedProtocols = await prisma.protocol.deleteMany({ + where: { hash: { in: hashes } }, + }); + + // insert an event for each protocol deleted + // eslint-disable-next-line no-console + console.log('inserting events for deleted protocols...'); + const events = protocolsToBeDeleted.map((p) => { + return { + type: 'Protocol Uninstalled', + message: `Protocol "${p.name}" uninstalled`, + }; + }); + + await prisma.events.createMany({ + data: events, + }); + + revalidateTag('activityFeed'); + revalidateTag('summaryStatistics'); + revalidateTag('getProtocols'); + revalidateTag('getInterviews'); + revalidateTag('getParticipants'); + + return { error: null, deletedProtocols: deletedProtocols }; + } catch (error) { + // eslint-disable-next-line no-console + console.log('delete protocols error: ', error); + return { + error: 'Failed to delete protocols', + deletedProtocols: null, + }; + } +} + +async function deleteFilesFromUploadThing(fileKey: string | string[]) { + await requireApiAuth(); + + if (fileKey.length === 0) { + // eslint-disable-next-line no-console + console.log('No assets to delete'); + return; + } + + const utapi = new UTApi(); + + const response = await utapi.deleteFiles(fileKey); + + if (!response.success) { + throw new Error('Failed to delete files from uploadthing'); + } + + return; +} + +export async function insertProtocol( + input: z.infer, +) { + await requireApiAuth(); + + const { + protocol: inputProtocol, + protocolName, + newAssets, + existingAssetIds, + } = protocolInsertSchema.parse(input); + + const protocol = inputProtocol as Protocol; + + try { + const protocolHash = hash(protocol); + + await prisma.protocol.create({ + data: { + hash: protocolHash, + lastModified: protocol.lastModified, + name: protocolName, + schemaVersion: protocol.schemaVersion, + stages: protocol.stages as unknown as Prisma.JsonArray, // The Stage interface needs to be changed to be a type: https://www.totaltypescript.com/type-vs-interface-which-should-you-use#index-signatures-in-types-vs-interfaces + codebook: protocol.codebook, + description: protocol.description, + assets: { + create: newAssets, + connect: existingAssetIds.map((assetId) => ({ assetId })), + }, + }, + }); + + void addEvent('Protocol Installed', `Protocol "${protocolName}" installed`); + + revalidateTag('getProtocols'); + revalidateTag('summaryStatistics'); + + return { error: null, success: true }; + } catch (e) { + // Attempt to delete any assets we uploaded to storage + if (newAssets.length > 0) { + void deleteFilesFromUploadThing(newAssets.map((a) => a.key)); + } + // Check for protocol already existing + if (e instanceof Prisma.PrismaClientKnownRequestError) { + if (e.code === 'P2002') { + return { + error: + 'The protocol you attempted to add already exists in the database. Please remove it and try again.', + success: false, + errorDetails: e, + }; + } + + return { + error: + 'There was an error adding your protocol to the database. See the error details for more information.', + success: false, + errorDetails: e, + }; + } + + throw e; + } +} diff --git a/actions/reset.ts b/actions/reset.ts new file mode 100644 index 00000000..9035ab7f --- /dev/null +++ b/actions/reset.ts @@ -0,0 +1,42 @@ +'use server'; + +import { revalidatePath, revalidateTag } from 'next/cache'; +import { UTApi } from 'uploadthing/server'; +import { requireApiAuth } from '~/utils/auth'; +import { prisma } from '~/utils/db'; + +export const resetAppSettings = async () => { + await requireApiAuth(); + + try { + // Delete all data: + await Promise.all([ + prisma.user.deleteMany(), // Deleting a user will cascade to Session and Key + prisma.participant.deleteMany(), + prisma.protocol.deleteMany(), // Deleting protocol will cascade to Interviews + prisma.appSettings.deleteMany(), + prisma.events.deleteMany(), + prisma.asset.deleteMany(), + ]); + + revalidatePath('/'); + revalidateTag('appSettings'); + revalidateTag('activityFeed'); + revalidateTag('summaryStatistics'); + revalidateTag('getProtocols'); + revalidateTag('getParticipants'); + revalidateTag('getInterviews'); + + const utapi = new UTApi(); + + // Remove all files from UploadThing: + await utapi.listFiles({}).then(({ files }) => { + const keys = files.map((file) => file.key); + return utapi.deleteFiles(keys); + }); + + return { error: null, appSettings: null }; + } catch (error) { + return { error: 'Failed to reset appSettings', appSettings: null }; + } +}; diff --git a/actions/uploadThing.ts b/actions/uploadThing.ts new file mode 100644 index 00000000..6e31032e --- /dev/null +++ b/actions/uploadThing.ts @@ -0,0 +1,64 @@ +'use server'; + +import { File } from 'node:buffer'; +import { readFile, unlink } from 'node:fs/promises'; +import { UTApi } from 'uploadthing/server'; +import type { + ArchiveResult, + ExportReturn, +} from '~/lib/network-exporters/utils/types'; +import { requireApiAuth } from '~/utils/auth'; +import { ensureError } from '~/utils/ensureError'; + +export const deleteZipFromUploadThing = async (key: string) => { + await requireApiAuth(); + + const utapi = new UTApi(); + + const deleteResponse = await utapi.deleteFiles(key); + + if (!deleteResponse.success) { + throw new Error('Failed to delete the zip file from UploadThing'); + } +}; + +export const uploadZipToUploadThing = async ( + results: ArchiveResult, +): Promise => { + const { path: zipLocation, completed, rejected } = results; + + try { + const fileName = zipLocation.split('/').pop()?.split('.').shift() ?? 'file'; + const zipBuffer = await readFile(zipLocation); + const zipFile = new File([zipBuffer], `${fileName}.zip`, { + type: 'application/zip', + }); + + const utapi = new UTApi(); + + const { data, error } = await utapi.uploadFiles(zipFile); + + if (data) { + void unlink(zipLocation); // Delete the zip file after successful upload + return { + zipUrl: data.url, + zipKey: data.key, + status: rejected.length ? 'partial' : 'success', + error: rejected.length ? 'Some exports failed' : null, + failedExports: rejected, + successfulExports: completed, + }; + } + + return { + status: 'error', + error: error.message, + }; + } catch (error) { + const e = ensureError(error); + return { + status: 'error', + error: e.message, + }; + } +}; diff --git a/analytics/utils.ts b/analytics/utils.ts deleted file mode 100644 index 675310d7..00000000 --- a/analytics/utils.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { makeEventTracker } from '@codaco/analytics'; -import { cache } from 'react'; -import { env } from '~/env.mjs'; -import { prisma } from '~/utils/db'; - -export const getInstallationId = cache(async () => { - if (env.INSTALLATION_ID) { - return env.INSTALLATION_ID; - } - - // eslint-disable-next-line local-rules/require-data-mapper - const appSettings = await prisma.appSettings.findFirst(); - - return appSettings?.installationId ?? 'Unknown'; -}); - -export const trackEvent = makeEventTracker({ - enabled: !env.NEXT_PUBLIC_DISABLE_ANALYTICS, -}); diff --git a/app/(blobs)/(setup)/_components/OnboardContinue.tsx b/app/(blobs)/(setup)/_components/OnboardContinue.tsx new file mode 100644 index 00000000..5264712d --- /dev/null +++ b/app/(blobs)/(setup)/_components/OnboardContinue.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { parseAsInteger, useQueryState } from 'nuqs'; +import { Button } from '~/components/ui/Button'; + +export default function OnboardContinue() { + const [currentStep, setCurrentStep] = useQueryState( + 'step', + parseAsInteger.withDefault(1), + ); + + return ( + + ); +} diff --git a/app/(setup)/_components/OnboardSteps/CreateAccount.tsx b/app/(blobs)/(setup)/_components/OnboardSteps/CreateAccount.tsx similarity index 88% rename from app/(setup)/_components/OnboardSteps/CreateAccount.tsx rename to app/(blobs)/(setup)/_components/OnboardSteps/CreateAccount.tsx index 906bbc30..d946eec5 100644 --- a/app/(setup)/_components/OnboardSteps/CreateAccount.tsx +++ b/app/(blobs)/(setup)/_components/OnboardSteps/CreateAccount.tsx @@ -1,4 +1,4 @@ -import { SignUpForm } from '~/app/(setup)/_components/SignUpForm'; +import { SignUpForm } from '~/app/(blobs)/(setup)/_components/SignUpForm'; import Heading from '~/components/ui/typography/Heading'; import Paragraph from '~/components/ui/typography/Paragraph'; diff --git a/app/(setup)/_components/OnboardSteps/Documentation.tsx b/app/(blobs)/(setup)/_components/OnboardSteps/Documentation.tsx similarity index 96% rename from app/(setup)/_components/OnboardSteps/Documentation.tsx rename to app/(blobs)/(setup)/_components/OnboardSteps/Documentation.tsx index 94c635e6..df3fe07d 100644 --- a/app/(setup)/_components/OnboardSteps/Documentation.tsx +++ b/app/(blobs)/(setup)/_components/OnboardSteps/Documentation.tsx @@ -1,11 +1,11 @@ import { FileText } from 'lucide-react'; -import { setAppConfigured } from '~/app/_actions'; +import { setAppConfigured } from '~/actions/appSettings'; +import Section from '~/components/layout/Section'; +import { Button } from '~/components/ui/Button'; import SubmitButton from '~/components/ui/SubmitButton'; -import { trackEvent } from '~/analytics/utils'; import Heading from '~/components/ui/typography/Heading'; import Paragraph from '~/components/ui/typography/Paragraph'; -import Section from '~/components/layout/Section'; -import { Button } from '~/components/ui/Button'; +import trackEvent from '~/lib/analytics'; function Documentation() { const handleAppConfigured = async () => { diff --git a/app/(blobs)/(setup)/_components/OnboardSteps/ManageParticipants.tsx b/app/(blobs)/(setup)/_components/OnboardSteps/ManageParticipants.tsx new file mode 100644 index 00000000..fd4308d3 --- /dev/null +++ b/app/(blobs)/(setup)/_components/OnboardSteps/ManageParticipants.tsx @@ -0,0 +1,65 @@ +import ImportCSVModal from '~/app/dashboard/participants/_components/ImportCSVModal'; +import Heading from '~/components/ui/typography/Heading'; +import Paragraph from '~/components/ui/typography/Paragraph'; +import SettingsSection from '~/components/layout/SettingsSection'; +import LimitInterviewsSwitchClient from '~/components/LimitInterviewsSwitchClient'; +import OnboardContinue from '../OnboardContinue'; +import AnonymousRecruitmentSwitchClient from '~/components/AnonymousRecruitmentSwitchClient'; + +function ManageParticipants({ + allowAnonymousRecruitment, + limitInterviews, +}: { + allowAnonymousRecruitment: boolean; + limitInterviews: boolean; +}) { + return ( +
+
+ Configure Participation + + You can now optionally upload a CSV file containing the details of + participants you wish to recruit for your study. You can also choose + to allow anonymous recruitment of participants. Both options can be + configured later from the dashboard. + +
+
+ } + > + Upload a CSV file of participants. + + + } + > + + Allow participants to join your study by visiting a URL. + + + + } + > + + Limit each participant to being allowed to complete one interview + per protocol. + + +
+
+ +
+
+ ); +} + +export default ManageParticipants; diff --git a/app/(setup)/_components/OnboardSteps/UploadProtocol.tsx b/app/(blobs)/(setup)/_components/OnboardSteps/UploadProtocol.tsx similarity index 80% rename from app/(setup)/_components/OnboardSteps/UploadProtocol.tsx rename to app/(blobs)/(setup)/_components/OnboardSteps/UploadProtocol.tsx index c2d4bebe..6a2250ff 100644 --- a/app/(setup)/_components/OnboardSteps/UploadProtocol.tsx +++ b/app/(blobs)/(setup)/_components/OnboardSteps/UploadProtocol.tsx @@ -1,12 +1,15 @@ 'use client'; -import ProtocolUploader from '~/app/(dashboard)/dashboard/_components/ProtocolUploader'; +import { parseAsInteger, useQueryState } from 'nuqs'; +import ProtocolUploader from '~/app/dashboard/_components/ProtocolUploader'; import { Button } from '~/components/ui/Button'; -import { useOnboardingContext } from '../OnboardingProvider'; import Heading from '~/components/ui/typography/Heading'; import Paragraph from '~/components/ui/typography/Paragraph'; function ConfigureStudy() { - const { currentStep, setCurrentStep } = useOnboardingContext(); + const [currentStep, setCurrentStep] = useQueryState( + 'step', + parseAsInteger.withDefault(1), + ); const handleNextStep = () => { void setCurrentStep(currentStep + 1); diff --git a/app/(setup)/_components/Sidebar.tsx b/app/(blobs)/(setup)/_components/Sidebar.tsx similarity index 90% rename from app/(setup)/_components/Sidebar.tsx rename to app/(blobs)/(setup)/_components/Sidebar.tsx index f62044c2..0db5dea8 100644 --- a/app/(setup)/_components/Sidebar.tsx +++ b/app/(blobs)/(setup)/_components/Sidebar.tsx @@ -2,8 +2,8 @@ import { Check } from 'lucide-react'; import { cn } from '~/utils/shadcn'; -import { useOnboardingContext } from './OnboardingProvider'; import Paragraph from '~/components/ui/typography/Paragraph'; +import { parseAsInteger, useQueryState } from 'nuqs'; const stepLabels = [ 'Create Account', @@ -13,7 +13,10 @@ const stepLabels = [ ]; function OnboardSteps() { - const { currentStep, setCurrentStep } = useOnboardingContext(); + const [currentStep, setCurrentStep] = useQueryState( + 'step', + parseAsInteger.withDefault(1), + ); return (
diff --git a/app/(blobs)/(setup)/_components/SignInForm.tsx b/app/(blobs)/(setup)/_components/SignInForm.tsx new file mode 100644 index 00000000..225a4e2c --- /dev/null +++ b/app/(blobs)/(setup)/_components/SignInForm.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { Loader2 } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { login } from '~/actions/auth'; +import { Button } from '~/components/ui/Button'; +import { Input } from '~/components/ui/Input'; +import UnorderedList from '~/components/ui/typography/UnorderedList'; +import { useToast } from '~/components/ui/use-toast'; +import useZodForm from '~/hooks/useZodForm'; +import { loginSchema } from '~/schemas/auth'; + +export const SignInForm = () => { + const { + register, + handleSubmit, + setError, + formState: { errors, isSubmitting }, + } = useZodForm({ + schema: loginSchema, + }); + + const { toast } = useToast(); + const router = useRouter(); + + const onSubmit = async (data: unknown) => { + const result = await login(data); + + if (result.success === true) { + router.push('/dashboard'); + return; + } + + // Handle formErrors + if (result.formErrors.length > 0) { + toast({ + variant: 'destructive', + title: 'Login failed', + description: ( + <> + + {result.formErrors.map((error) => ( +
  • {error}
  • + ))} +
    + + ), + }); + } + + // Handle field errors + if (result.fieldErrors) { + for (const [field, message] of Object.entries(result.fieldErrors)) { + setError(`root.${field}`, { types: { type: 'manual', message } }); + } + } + }; + + return ( +
    void handleSubmit(onSubmit)(event)} + className="flex w-full flex-col" + > +
    + +
    +
    + +
    +
    + +
    +
    + ); +}; diff --git a/app/(blobs)/(setup)/_components/SignUpForm.tsx b/app/(blobs)/(setup)/_components/SignUpForm.tsx new file mode 100644 index 00000000..29d4fab3 --- /dev/null +++ b/app/(blobs)/(setup)/_components/SignUpForm.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { signup } from '~/actions/auth'; +import { Button } from '~/components/ui/Button'; +import { Input } from '~/components/ui/Input'; +import useZodForm from '~/hooks/useZodForm'; +import { createUserSchema } from '~/schemas/auth'; + +export const SignUpForm = () => { + const { + register, + formState: { errors, isValid }, + } = useZodForm({ + schema: createUserSchema, + }); + + return ( +
    +
    + +
    +
    + +
    +
    + {/* {isLoading ? ( + + ) : ( */} + + {/* )} */} +
    +
    + ); +}; diff --git a/app/(blobs)/(setup)/layout.tsx b/app/(blobs)/(setup)/layout.tsx new file mode 100644 index 00000000..1faf603c --- /dev/null +++ b/app/(blobs)/(setup)/layout.tsx @@ -0,0 +1,7 @@ +import type { ReactNode } from 'react'; +import { requireAppNotExpired } from '~/queries/appSettings'; + +export default async function Layout({ children }: { children: ReactNode }) { + await requireAppNotExpired(true); + return children; +} diff --git a/app/(blobs)/(setup)/setup/Setup.tsx b/app/(blobs)/(setup)/setup/Setup.tsx new file mode 100644 index 00000000..3a9e7de8 --- /dev/null +++ b/app/(blobs)/(setup)/setup/Setup.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { motion } from 'framer-motion'; +import { parseAsInteger, useQueryState } from 'nuqs'; +import { use, useEffect } from 'react'; +import { containerClasses } from '~/components/ContainerClasses'; +import { cn } from '~/utils/shadcn'; +import CreateAccount from '../_components/OnboardSteps/CreateAccount'; +import Documentation from '../_components/OnboardSteps/Documentation'; +import ManageParticipants from '../_components/OnboardSteps/ManageParticipants'; +import UploadProtocol from '../_components/OnboardSteps/UploadProtocol'; +import OnboardSteps from '../_components/Sidebar'; +import type { SetupData } from './page'; + +export default function Setup({ + setupDataPromise, +}: { + setupDataPromise: SetupData; +}) { + const [step, setStep] = useQueryState('step', parseAsInteger.withDefault(1)); + + const { hasAuth, allowAnonymousRecruitment, limitInterviews } = + use(setupDataPromise); + + const cardClasses = cn(containerClasses, 'flex-row bg-transparent p-0 gap-6'); + const mainClasses = cn('bg-white flex w-full p-12 rounded-xl'); + + useEffect(() => { + if (!hasAuth && step > 1) { + void setStep(1); + return; + } + + if (hasAuth && step === 1) { + void setStep(2); + return; + } + }, [hasAuth, step, setStep]); + + return ( + + +
    + {step === 1 && } + {step === 2 && } + {step === 3 && ( + + )} + {step === 4 && } +
    +
    + ); +} diff --git a/app/(blobs)/(setup)/setup/page.tsx b/app/(blobs)/(setup)/setup/page.tsx new file mode 100644 index 00000000..bf55c0df --- /dev/null +++ b/app/(blobs)/(setup)/setup/page.tsx @@ -0,0 +1,46 @@ +import { Loader2 } from 'lucide-react'; +import { Suspense } from 'react'; +import { + getAnonymousRecruitmentStatus, + getLimitInterviewsStatus, + requireAppNotExpired, +} from '~/queries/appSettings'; +import { getServerSession } from '~/utils/auth'; +import { prisma } from '~/utils/db'; +import Setup from './Setup'; + +async function getSetupData() { + const session = await getServerSession(); + const allowAnonymousRecruitment = await getAnonymousRecruitmentStatus(); + const limitInterviews = await getLimitInterviewsStatus(); + const otherData = await prisma.$transaction([ + prisma.protocol.count(), + prisma.participant.count(), + ]); + + return { + hasAuth: !!session, + allowAnonymousRecruitment, + limitInterviews, + hasProtocol: otherData[0] > 0, + hasParticipants: otherData[1] > 0, + }; +} + +export type SetupData = ReturnType; + +export const dynamic = 'force-dynamic'; + +export default async function Page() { + await requireAppNotExpired(true); + + const setupDataPromise = getSetupData(); + + return ( + } + > + + + ); +} diff --git a/app/(blobs)/(setup)/signin/page.tsx b/app/(blobs)/(setup)/signin/page.tsx new file mode 100644 index 00000000..19210251 --- /dev/null +++ b/app/(blobs)/(setup)/signin/page.tsx @@ -0,0 +1,28 @@ +import { redirect } from 'next/navigation'; +import { containerClasses } from '~/components/ContainerClasses'; +import { getServerSession } from '~/utils/auth'; +import { cn } from '~/utils/shadcn'; +import { SignInForm } from '../_components/SignInForm'; + +export const metadata = { + title: 'Fresco - Sign In', + description: 'Sign in to Fresco.', +}; + +export const dynamic = 'force-dynamic'; + +export default async function Page() { + const session = await getServerSession(); + + if (session) { + // If the user is already signed in, redirect to the dashboard + redirect('/dashboard'); + } + + return ( +
    +

    Sign In To Fresco

    + +
    + ); +} diff --git a/app/(setup)/expired/page.tsx b/app/(blobs)/expired/page.tsx similarity index 63% rename from app/(setup)/expired/page.tsx rename to app/(blobs)/expired/page.tsx index 066c2b87..fcba44a5 100644 --- a/app/(setup)/expired/page.tsx +++ b/app/(blobs)/expired/page.tsx @@ -1,9 +1,19 @@ -import { env } from '~/env.mjs'; -import { containerClasses } from '../_components/schemas'; -import { resetAppSettings } from '~/app/_actions'; +import { redirect } from 'next/navigation'; +import { resetAppSettings } from '~/actions/reset'; +import { containerClasses } from '~/components/ContainerClasses'; import SubmitButton from '~/components/ui/SubmitButton'; +import { env } from '~/env'; +import { isAppExpired } from '~/queries/appSettings'; + +export const dynamic = 'force-dynamic'; + +export default async function Page() { + const isExpired = await isAppExpired(); + + if (!isExpired) { + redirect('/'); + } -export default function Page() { return (

    Installation expired

    diff --git a/app/(setup)/layout.tsx b/app/(blobs)/layout.tsx similarity index 100% rename from app/(setup)/layout.tsx rename to app/(blobs)/layout.tsx diff --git a/app/(dashboard)/dashboard/_components/ActivityFeed/ActivityFeed.tsx b/app/(dashboard)/dashboard/_components/ActivityFeed/ActivityFeed.tsx deleted file mode 100644 index b904d5dc..00000000 --- a/app/(dashboard)/dashboard/_components/ActivityFeed/ActivityFeed.tsx +++ /dev/null @@ -1,18 +0,0 @@ -'use client'; - -import { DataTableSkeleton } from '~/components/data-table/data-table-skeleton'; -import ActivityFeedTable from './ActivityFeedTable'; -import { api } from '~/trpc/client'; -import { useTableStateFromSearchParams } from './useTableStateFromSearchParams'; - -export const ActivityFeed = () => { - const { searchParams } = useTableStateFromSearchParams(); - const { data, isLoading } = - api.dashboard.getActivities.useQuery(searchParams); - - if (isLoading) { - return ; - } - - return ; -}; diff --git a/app/(dashboard)/dashboard/_components/ActivityFeed/TasksTableFloatingBarContent.tsx b/app/(dashboard)/dashboard/_components/ActivityFeed/TasksTableFloatingBarContent.tsx deleted file mode 100644 index a6a7591c..00000000 --- a/app/(dashboard)/dashboard/_components/ActivityFeed/TasksTableFloatingBarContent.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { type Table } from '@tanstack/react-table'; -import usePortal from 'react-useportal'; - -export function TasksTableFloatingBarContent(_table: Table) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const { Portal } = usePortal(); - - return ( - -
    - Nothing to see here! - {/* - - */} -
    -
    - ); -} diff --git a/app/(dashboard)/dashboard/_components/ActivityFeed/useTableStateFromSearchParams.ts b/app/(dashboard)/dashboard/_components/ActivityFeed/useTableStateFromSearchParams.ts deleted file mode 100644 index 6a2b50a5..00000000 --- a/app/(dashboard)/dashboard/_components/ActivityFeed/useTableStateFromSearchParams.ts +++ /dev/null @@ -1,62 +0,0 @@ -'use client'; -import { - FilterParam, - pageSizes, - sortOrder, - sortableFields, -} from '~/lib/data-table/types'; -import { - parseAsArrayOf, - parseAsInteger, - parseAsJson, - parseAsNumberLiteral, - parseAsStringLiteral, - useQueryState, -} from 'nuqs'; - -/** - * This hook implements the table state items required by the DataTable. - * - * Ultimately, we could abstract this further, and implement a generic - * useSearchParamsTableState hook so that the way the state is stored is an - * implementation detail. This would allow us to store table state in novel - * ways, such as in localStorage, in the URL, or even in a database. - * - */ -export const useTableStateFromSearchParams = () => { - const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1)); - const [perPage, setPerPage] = useQueryState( - 'per_page', - parseAsNumberLiteral(pageSizes).withDefault(10), - ); - const [sort, setSort] = useQueryState( - 'sort', - parseAsStringLiteral(sortOrder).withDefault('desc'), - ); - const [sortField, setSortField] = useQueryState( - 'sort_field', - parseAsStringLiteral(sortableFields).withDefault('timestamp'), - ); - - const [filterParams, setFilterParams] = useQueryState( - 'filter_params', - parseAsArrayOf(parseAsJson((value) => FilterParam.parse(value))), - ); - - return { - searchParams: { - page, - perPage, - sort, - sortField, - filterParams, - }, - setSearchParams: { - setPage, - setPerPage, - setSort, - setSortField, - setFilterParams, - }, - }; -}; diff --git a/app/(dashboard)/dashboard/_components/ParticipantsTable/ParticipantsTable.tsx b/app/(dashboard)/dashboard/_components/ParticipantsTable/ParticipantsTable.tsx deleted file mode 100644 index 9b9010a8..00000000 --- a/app/(dashboard)/dashboard/_components/ParticipantsTable/ParticipantsTable.tsx +++ /dev/null @@ -1,189 +0,0 @@ -'use client'; - -import { DataTable } from '~/components/DataTable/DataTable'; -import { getParticipantColumns } from '~/app/(dashboard)/dashboard/_components/ParticipantsTable/Columns'; -import type { ParticipantWithInterviews } from '~/shared/types'; -import { ActionsDropdown } from '~/app/(dashboard)/dashboard/_components/ParticipantsTable/ActionsDropdown'; -import AddParticipantButton from '~/app/(dashboard)/dashboard/participants/_components/AddParticipantButton'; -import { useCallback, useMemo, useState } from 'react'; -import { DeleteParticipantsDialog } from '~/app/(dashboard)/dashboard/participants/_components/DeleteParticipantsDialog'; -import { api } from '~/trpc/client'; -import { type RouterOutputs } from '~/trpc/shared'; -import { DataTableSkeleton } from '~/components/data-table/data-table-skeleton'; -import { type ColumnDef } from '@tanstack/react-table'; -import { useRouter } from 'next/navigation'; -import { Button } from '~/components/ui/Button'; -import { Trash } from 'lucide-react'; -import { GenerateParticipantURLs } from '../../participants/_components/ExportParticipants/ExportParticipantUrlSection'; - -export const ParticipantsTable = ({ - initialData, -}: { - initialData: RouterOutputs['participant']['get']['all']; -}) => { - const { data: participants, isLoading } = api.participant.get.all.useQuery( - undefined, - { - initialData, - refetchOnMount: false, - onError(error) { - throw new Error(error.message); - }, - }, - ); - - const utils = api.useUtils(); - const router = useRouter(); - - const { mutateAsync: apiDeleteParticipants } = - api.participant.delete.byId.useMutation({ - async onMutate(participantIds) { - await utils.participant.get.all.cancel(); - - // snapshot current participants - const previousValue = utils.participant.get.all.getData(); - - // Optimistically update to the new value - const newValue = previousValue?.filter( - (p) => !participantIds.includes(p.identifier), - ); - - utils.participant.get.all.setData(undefined, newValue); - - resetDelete(); // Will hide the modal - - return { previousValue }; - }, - onSuccess() { - router.refresh(); - }, - onError(error, identifiers, context) { - utils.participant.get.all.setData(undefined, context?.previousValue); - throw new Error(error.message); - }, - async onSettled() { - await utils.participant.get.all.invalidate(); - }, - }); - - const { mutateAsync: apiDeleteAllParticipants } = - api.participant.delete.all.useMutation({ - async onMutate() { - await utils.participant.get.all.cancel(); - const previousValue = utils.participant.get.all.getData(); - utils.participant.get.all.setData(undefined, []); - resetDelete(); - return { previousValue }; - }, - onSuccess() { - router.refresh(); - }, - onError(error, _, context) { - utils.participant.get.all.setData(undefined, context?.previousValue); - throw new Error(error.message); - }, - async onSettled() { - await utils.participant.get.all.invalidate(); - }, - }); - - // Memoize the columns so they don't re-render on every render - const columns = useMemo[]>( - () => getParticipantColumns(), - [], - ); - - const [participantsToDelete, setParticipantsToDelete] = useState< - ParticipantWithInterviews[] | null - >(null); - const [showDeleteModal, setShowDeleteModal] = useState(false); - - // Actual delete handler, which handles optimistic updates, etc. - const doDelete = async () => { - if (!participantsToDelete) { - return; - } - - // Check if we are deleting all and call the appropriate function - if (participantsToDelete.length === participants.length) { - await apiDeleteAllParticipants(); - return; - } - - await apiDeleteParticipants(participantsToDelete.map((p) => p.identifier)); - }; - - // Resets the state when the dialog is closed. - const resetDelete = () => { - setShowDeleteModal(false); - setParticipantsToDelete(null); - }; - - const handleDeleteItems = useCallback( - (items: ParticipantWithInterviews[]) => { - // Set state to the items to be deleted - setParticipantsToDelete(items); - - // Show the dialog - setShowDeleteModal(true); - }, - [], - ); - - const handleDeleteAll = useCallback(() => { - // Set state to all items - setParticipantsToDelete(participants); - - // Show the dialog - setShowDeleteModal(true); - }, [participants]); - - if (isLoading) { - return ( - - ); - } - - return ( - <> - participant._count.interviews > 0, - ) - } - haveUnexportedInterviews={ - !!participantsToDelete?.some((participant) => - participant.interviews.some((interview) => !interview.exportTime), - ) - } - onConfirm={doDelete} - onCancel={resetDelete} - /> - -
    - - -
    - - - } - /> - - ); -}; diff --git a/app/(dashboard)/dashboard/_components/ProtocolsTable/CopyButton.tsx b/app/(dashboard)/dashboard/_components/ProtocolsTable/CopyButton.tsx deleted file mode 100644 index 10a1c856..00000000 --- a/app/(dashboard)/dashboard/_components/ProtocolsTable/CopyButton.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Copy } from 'lucide-react'; -import type { FC } from 'react'; -import { useToast } from '~/components/ui/use-toast'; - -type CopyButtonProps = { - text: string; -}; - -const CopyButton: FC = ({ text }) => { - const { toast } = useToast(); - - const handleCopyClick = () => { - if (text) { - navigator.clipboard - .writeText(text) - .then(() => { - toast({ - description: 'Copied to clipboard', - variant: 'success', - duration: 3000, - }); - }) - .catch((error) => { - // eslint-disable-next-line no-console - console.error('Could not copy text: ', error); - toast({ - title: 'Error', - description: 'Could not copy text', - variant: 'destructive', - }); - }); - } - }; - - return ; -}; - -export default CopyButton; diff --git a/app/(dashboard)/dashboard/_components/SummaryStatistics/SummaryStatistics.tsx b/app/(dashboard)/dashboard/_components/SummaryStatistics/SummaryStatistics.tsx deleted file mode 100644 index a6de4a20..00000000 --- a/app/(dashboard)/dashboard/_components/SummaryStatistics/SummaryStatistics.tsx +++ /dev/null @@ -1,79 +0,0 @@ -'use client'; - -import Image from 'next/image'; -import Link from 'next/link'; -import ResponsiveContainer from '~/components/ResponsiveContainer'; -import { api } from '~/trpc/client'; -import { InterviewIcon, ProtocolIcon } from './Icons'; -import StatCard, { StatCardSkeleton } from './StatCard'; - -export default function SummaryStatistics() { - const { data: protocolCount, isLoading: isProtocolStatsLoading } = - api.dashboard.getSummaryStatistics.protocolCount.useQuery(); - - const { data: participantCount, isLoading: isParticipantStatsLoading } = - api.dashboard.getSummaryStatistics.participantCount.useQuery(); - - const { data: interviewCount, isLoading: isInterviewStatsLoading } = - api.dashboard.getSummaryStatistics.interviewCount.useQuery(); - - return ( - - - {isProtocolStatsLoading ? ( - } /> - ) : ( - } - /> - )} - - - {isParticipantStatsLoading ? ( - - } - /> - ) : ( - - } - /> - )} - - - {isInterviewStatsLoading ? ( - } /> - ) : ( - } - /> - )} - - - ); -} diff --git a/app/(dashboard)/dashboard/_components/UserMenu.tsx b/app/(dashboard)/dashboard/_components/UserMenu.tsx deleted file mode 100644 index 395716c0..00000000 --- a/app/(dashboard)/dashboard/_components/UserMenu.tsx +++ /dev/null @@ -1,26 +0,0 @@ -'use client'; - -import { Button } from '~/components/ui/Button'; -import { api } from '~/trpc/client'; - -const UserMenu = () => { - const utils = api.useUtils(); - const { mutate: signOut, isLoading } = api.session.signOut.useMutation({ - onSuccess: async () => { - await utils.session.get.invalidate(); - }, - }); - - return ( - - ); -}; - -export default UserMenu; diff --git a/app/(dashboard)/dashboard/interviews/_actions/deleteZipFromUploadThing.ts b/app/(dashboard)/dashboard/interviews/_actions/deleteZipFromUploadThing.ts deleted file mode 100644 index 4b796580..00000000 --- a/app/(dashboard)/dashboard/interviews/_actions/deleteZipFromUploadThing.ts +++ /dev/null @@ -1,23 +0,0 @@ -'use server'; - -// We need to delete the exported data from UploadThing after the user has downloaded it. -// This is to ensure that we are not storing any sensitive data on UploadThing for longer than necessary. - -import { utapi } from '~/app/api/uploadthing/core'; -import { getServerSession } from '~/utils/auth'; - -export const deleteZipFromUploadThing = async (key: string) => { - const session = await getServerSession(); - - if (!session) { - throw new Error( - 'You must be logged in to delete interview data from UploadThing!', - ); - } - - const deleteResponse = await utapi.deleteFiles(key); - - if (!deleteResponse.success) { - throw new Error('Failed to delete the zip file from UploadThing'); - } -}; diff --git a/app/(dashboard)/dashboard/interviews/_actions/export.ts b/app/(dashboard)/dashboard/interviews/_actions/export.ts deleted file mode 100644 index 05e47780..00000000 --- a/app/(dashboard)/dashboard/interviews/_actions/export.ts +++ /dev/null @@ -1,161 +0,0 @@ -'use server'; - -import { type Interview, type Protocol } from '@prisma/client'; -import { trackEvent } from '~/analytics/utils'; -import FileExportManager from '~/lib/network-exporters/FileExportManager'; -import { - formatExportableSessions, - type FormattedSessions, -} from '~/lib/network-exporters/formatters/formatExportableSessions'; -import { type ExportOptions } from '~/lib/network-exporters/utils/exportOptionsSchema'; -import { api } from '~/trpc/server'; -import { getServerSession } from '~/utils/auth'; -import { ensureError } from '~/utils/ensureError'; - -type UploadData = { - key: string; - url: string; - name: string; - size: number; -}; - -type UpdateItems = { - statusText: string; - progress: number; -}; - -type FailResult = { - data: null; - error: string; - message: string; -}; - -type SuccessResult = { - data: UploadData; - error: null; - message: string; -}; - -export const prepareExportData = async (interviewIds: Interview['id'][]) => { - const session = await getServerSession(); - - if (!session) { - throw new Error('You must be logged in to export interview sessions!.'); - } - - const interviewsSessions = - await api.interview.get.forExport.query(interviewIds); - - const protocolsMap = new Map(); - interviewsSessions.forEach((session) => { - protocolsMap.set(session.protocol.hash, session.protocol); - }); - - const formattedProtocols = Object.fromEntries(protocolsMap); - const formattedSessions = formatExportableSessions(interviewsSessions); - - return { formattedSessions, formattedProtocols }; -}; - -export const exportSessions = async ( - formattedSessions: FormattedSessions, - formattedProtocols: Record, - interviewIds: Interview['id'][], - exportOptions: ExportOptions, -) => { - try { - const fileExportManager = new FileExportManager(exportOptions); - - fileExportManager.on('begin', () => { - // eslint-disable-next-line no-console - console.log({ - statusText: 'Starting export...', - percentProgress: 0, - }); - }); - - fileExportManager.on('update', ({ statusText, progress }: UpdateItems) => { - // eslint-disable-next-line no-console - console.log({ - statusText, - percentProgress: progress, - }); - }); - - fileExportManager.on('session-exported', (sessionId: unknown) => { - if (!sessionId || typeof sessionId !== 'string') { - // eslint-disable-next-line no-console - console.warn('session-exported event did not contain a sessionID'); - return; - } - // eslint-disable-next-line no-console - console.log('session-exported success sessionId:', sessionId); - }); - - fileExportManager.on('error', (errResult: FailResult) => { - // eslint-disable-next-line no-console - console.log('Session export failed, Error:', errResult.message); - void trackEvent({ - type: 'Error', - name: 'SessionExportFailed', - message: errResult.message, - metadata: { - errResult, - path: '/(dashboard)/dashboard/interviews/_actions/export.ts', - }, - }); - }); - - fileExportManager.on( - 'finished', - ({ statusText, progress }: UpdateItems) => { - // eslint-disable-next-line no-console - console.log({ - statusText, - percentProgress: progress, - }); - }, - ); - - const exportJob = fileExportManager.exportSessions( - formattedSessions, - formattedProtocols, - ); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const { run } = await exportJob; - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call - const result: SuccessResult | FailResult = await run(); // main export method - - void trackEvent({ - type: 'DataExported', - metadata: { - sessions: interviewIds.length, - exportOptions, - resultError: result.error, - resultMessage: result.message, - }, - }); - - return result; - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - const e = ensureError(error); - void trackEvent({ - type: 'Error', - name: e.name, - message: e.message, - stack: e.stack, - metadata: { - path: '/(dashboard)/dashboard/interviews/_actions/export.ts', - }, - }); - - return { - data: null, - message: 'Error during data export!', - error: e.message, - }; - } -}; diff --git a/app/(dashboard)/dashboard/participants/_components/ExportParticipants/ExportParticipantUrlSection.tsx b/app/(dashboard)/dashboard/participants/_components/ExportParticipants/ExportParticipantUrlSection.tsx deleted file mode 100644 index bdef476d..00000000 --- a/app/(dashboard)/dashboard/participants/_components/ExportParticipants/ExportParticipantUrlSection.tsx +++ /dev/null @@ -1,138 +0,0 @@ -'use client'; -import { useState, useEffect } from 'react'; - -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '~/components/ui/select'; - -import { api } from '~/trpc/client'; -import ExportCSVParticipantURLs from '~/app/(dashboard)/dashboard/participants/_components/ExportParticipants/ExportCSVParticipantURLs'; -import { Skeleton } from '~/components/ui/skeleton'; -import FancyBox from '~/components/ui/FancyBox'; -import { type RouterOutputs } from '~/trpc/shared'; -import { Button } from '~/components/ui/Button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '~/components/ui/dialog'; -import { FileUp } from 'lucide-react'; - -export const GenerateParticipantURLs = () => { - const { data: protocols, isLoading: isLoadingProtocols } = - api.protocol.get.all.useQuery(); - - const { data: participants, isLoading: isLoadingParticipants } = - api.participant.get.all.useQuery(); - - const [selectedParticipants, setSelectedParticipants] = useState< - RouterOutputs['participant']['get']['all'][0]['id'][] - >([]); - - const [selectedProtocol, setSelectedProtocol] = - useState(); - - // Default to all participants selected - useEffect(() => { - if (participants) { - setSelectedParticipants(participants.map((p) => p.id)); - } - }, [participants]); - - const [open, setOpen] = useState(false); - - const handleOpenChange = () => { - setOpen(!open); - }; - - return ( - <> - - - - - Generate Participation URLs - - Generate a CSV that contains{' '} - unique participation URLs for all participants by - protocol. These URLs can be shared with participants to allow them - to take your interview. - - -
    - {isLoadingProtocols || !protocols ? ( - - ) : ( - - )} - {isLoadingParticipants || !participants ? ( - - ) : ( - ({ - id: participant.id, - label: participant.identifier, - value: participant.id, - }))} - placeholder="Select Participants..." - singular="Participant" - plural="Participants" - value={selectedParticipants} - onValueChange={setSelectedParticipants} - /> - )} -
    - - - participants?.find((p) => p.id === id), - ) as RouterOutputs['participant']['get']['all'][0][] - } - disabled={!selectedParticipants || !selectedProtocol} - /> - -
    -
    - - ); -}; diff --git a/app/(dashboard)/dashboard/protocols/_components/AnonymousRecruitmentModal.tsx b/app/(dashboard)/dashboard/protocols/_components/AnonymousRecruitmentModal.tsx deleted file mode 100644 index 728ba287..00000000 --- a/app/(dashboard)/dashboard/protocols/_components/AnonymousRecruitmentModal.tsx +++ /dev/null @@ -1,95 +0,0 @@ -'use client'; -import type { Protocol } from '@prisma/client'; -import { useState, useEffect } from 'react'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '~/components/ui/dialog'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '~/components/ui/select'; - -import { Button } from '~/components/ui/Button'; -import { api } from '~/trpc/client'; -import { getBaseUrl } from '~/trpc/shared'; -import CopyButton from '~/app/(dashboard)/dashboard/_components/ProtocolsTable/CopyButton'; - -export const AnonymousRecruitmentModal = () => { - const { data: protocolData, isLoading: isLoadingProtocols } = - api.protocol.get.all.useQuery(); - const [protocols, setProtocols] = useState([]); - - const [selectedProtocol, setSelectedProtocol] = useState(); - - const { data: appSettings } = api.appSettings.get.useQuery(); - - const allowAnonymousRecruitment = !!appSettings?.allowAnonymousRecruitment; - - useEffect(() => { - if (protocolData) { - setProtocols(protocolData); - } - }, [protocolData]); - - const url = `${getBaseUrl()}/onboard/${selectedProtocol?.id}`; - - return ( - setSelectedProtocol(undefined)}> - - - - - - Generate Anonymous Participation URL - - Generate an anonymous participation URL for a protocol. This URL can - be shared with participants to allow them to self-enroll in your - study. - - -
    - - {selectedProtocol && ( -
    -
    {url}
    - -
    - )} -
    -
    -
    - ); -}; diff --git a/app/(dashboard)/dashboard/protocols/page.tsx b/app/(dashboard)/dashboard/protocols/page.tsx deleted file mode 100644 index 84b0455d..00000000 --- a/app/(dashboard)/dashboard/protocols/page.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import ResponsiveContainer from '~/components/ResponsiveContainer'; -import { ProtocolsTable } from '../_components/ProtocolsTable/ProtocolsTable'; -import PageHeader from '~/components/ui/typography/PageHeader'; -import Section from '~/components/layout/Section'; -import { api } from '~/trpc/server'; - -export const dynamic = 'force-dynamic'; - -const ProtocolsPage = async () => { - const protocols = await api.protocol.get.all.query(); - const allowAnonymousRecruitment = - await api.appSettings.getAnonymousRecruitmentStatus.query(); - - return ( - <> - - - - -
    - -
    -
    - - ); -}; - -export default ProtocolsPage; diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx deleted file mode 100644 index ade0446a..00000000 --- a/app/(dashboard)/layout.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { NavigationBar } from './dashboard/_components/NavigationBar'; -import FeedbackBanner from '~/components/Feedback/FeedbackBanner'; - -export const metadata = { - title: 'Network Canvas Fresco - Dashboard', - description: 'Fresco.', -}; - -const Layout = ({ children }: { children: React.ReactNode }) => { - return ( - <> - - - {children} - - ); -}; - -export default Layout; diff --git a/app/(dashboard)/loading.tsx b/app/(dashboard)/loading.tsx deleted file mode 100644 index 4e020fb1..00000000 --- a/app/(dashboard)/loading.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Loader2 } from 'lucide-react'; - -export default function Loading() { - // Or a custom loading skeleton component - return ( -
    - -
    - ); -} diff --git a/app/(interview)/interview/[interviewId]/page.tsx b/app/(interview)/interview/[interviewId]/page.tsx index 5346f422..b4b805d2 100644 --- a/app/(interview)/interview/[interviewId]/page.tsx +++ b/app/(interview)/interview/[interviewId]/page.tsx @@ -1,57 +1,37 @@ -/* eslint-disable local-rules/require-data-mapper */ import InterviewShell from '../_components/InterviewShell'; -import { api } from '~/trpc/server'; import { cookies } from 'next/headers'; -import { redirect } from 'next/navigation'; -import { prisma } from '~/utils/db'; -import { unstable_noStore } from 'next/cache'; - -export const dynamic = 'force-dynamic'; +import { notFound, redirect } from 'next/navigation'; +import { getLimitInterviewsStatus } from '~/queries/appSettings'; +import { syncInterview } from '~/actions/interviews'; +import { getInterviewById } from '~/queries/interviews'; +import FeedbackBanner from '~/components/Feedback/FeedbackBanner'; +import { getServerSession } from '~/utils/auth'; export default async function Page({ params, }: { params: { interviewId: string }; }) { - unstable_noStore(); - const { interviewId } = params; if (!interviewId) { return 'No interview id found'; } - const appSettings = await api.appSettings.get.query(); - - /** - * Fetch the interview using prisma directly here, because using tRPC is - * heavily catched, and we always want to fetch the latest data. - */ - const interview = await prisma.interview.findUnique({ - where: { - id: interviewId, - }, - include: { - protocol: { - include: { - assets: true, - }, - }, - }, - }); + const interview = await getInterviewById(interviewId); + const session = await getServerSession(); // If the interview is not found, redirect to the 404 page if (!interview) { - redirect('/404'); + notFound(); } // if limitInterviews is enabled // Check cookies for interview already completed for this user for this protocol // and redirect to finished page - if ( - appSettings?.limitInterviews && - cookies().get(interview?.protocol?.id ?? '') - ) { + const limitInterviews = await getLimitInterviewsStatus(); + + if (limitInterviews && cookies().get(interview?.protocol?.id ?? '')) { redirect('/interview/finished'); } @@ -60,5 +40,10 @@ export default async function Page({ redirect('/interview/finished'); } - return ; + return ( + <> + {session && } + + + ); } diff --git a/app/(interview)/interview/_components/InterviewShell.tsx b/app/(interview)/interview/_components/InterviewShell.tsx index 704a7ef8..a1685c2c 100644 --- a/app/(interview)/interview/_components/InterviewShell.tsx +++ b/app/(interview)/interview/_components/InterviewShell.tsx @@ -10,24 +10,19 @@ import { import { store } from '~/lib/interviewer/store'; import ServerSync from './ServerSync'; import { useEffect, useState } from 'react'; -import type { Prisma } from '@prisma/client'; import { parseAsInteger, useQueryState } from 'nuqs'; +import type { SyncInterviewType } from '~/actions/interviews'; +import type { getInterviewById } from '~/queries/interviews'; // The job of interview shell is to receive the server-side session and protocol // and create a redux store with that data. // Eventually it will handle syncing this data back. const InterviewShell = ({ interview, + syncInterview, }: { - interview: Prisma.InterviewGetPayload<{ - include: { - protocol: { - include: { - assets: true; - }; - }; - }; - }>; + interview: Awaited>; + syncInterview: SyncInterviewType; }) => { const [initialized, setInitialized] = useState(false); const [currentStage, setCurrentStage] = useQueryState('step', parseAsInteger); @@ -60,15 +55,15 @@ const InterviewShell = ({ }); setInitialized(true); - }, [interview, initialized, setInitialized, currentStage, setCurrentStage]); + }, [initialized, setInitialized, currentStage, setCurrentStage, interview]); - if (!initialized) { + if (!initialized || !interview) { return null; } return ( - + diff --git a/app/(interview)/interview/_components/ServerSync.tsx b/app/(interview)/interview/_components/ServerSync.tsx index b5921ab5..bd83b32e 100644 --- a/app/(interview)/interview/_components/ServerSync.tsx +++ b/app/(interview)/interview/_components/ServerSync.tsx @@ -3,38 +3,34 @@ import { debounce, isEqual } from 'lodash'; import { type ReactNode, useEffect, useState, useCallback } from 'react'; import { useSelector } from 'react-redux'; +import type { SyncInterviewType } from '~/actions/interviews'; import usePrevious from '~/hooks/usePrevious'; import { getActiveSession } from '~/lib/interviewer/selectors/session'; -import { api } from '~/trpc/client'; // The job of ServerSync is to listen to actions in the redux store, and to sync // data with the server. const ServerSync = ({ interviewId, children, + serverSync, }: { interviewId: string; children: ReactNode; + serverSync: SyncInterviewType; }) => { const [init, setInit] = useState(false); // Current stage const currentSession = useSelector(getActiveSession); const prevCurrentSession = usePrevious(currentSession); - const { mutate: syncSessionWithServer } = api.interview.sync.useMutation({ - onMutate: () => { - // eslint-disable-next-line no-console - console.log(`⬆️ Syncing session with server...`); - }, - }); // eslint-disable-next-line react-hooks/exhaustive-deps const debouncedSessionSync = useCallback( - debounce(syncSessionWithServer, 2000, { + debounce(serverSync, 2000, { leading: true, trailing: true, maxWait: 10000, }), - [syncSessionWithServer], + [serverSync], ); useEffect(() => { @@ -51,7 +47,7 @@ const ServerSync = ({ return; } - debouncedSessionSync({ + void debouncedSessionSync({ id: interviewId, network: currentSession.network, currentStep: currentSession.currentStep ?? 0, @@ -65,10 +61,6 @@ const ServerSync = ({ debouncedSessionSync, ]); - if (!init) { - return
    Sync Loading (no init)...
    ; - } - return children; }; diff --git a/app/(interview)/interview/_components/SmallScreenOverlay.tsx b/app/(interview)/interview/_components/SmallScreenOverlay.tsx index b33ebb48..e2b73018 100644 --- a/app/(interview)/interview/_components/SmallScreenOverlay.tsx +++ b/app/(interview)/interview/_components/SmallScreenOverlay.tsx @@ -1,8 +1,13 @@ +import Image from 'next/image'; +import { env } from 'node:process'; import Heading from '~/components/ui/typography/Heading'; import Paragraph from '~/components/ui/typography/Paragraph'; -import Image from 'next/image'; -export const SmallScreenOverlay = () => { +const SmallScreenOverlay = () => { + if (env.NODE_ENV === 'development') { + return null; + } + return (
    @@ -26,4 +31,5 @@ export const SmallScreenOverlay = () => {
    ); }; + export default SmallScreenOverlay; diff --git a/app/(interview)/onboard/[protocolId]/route.ts b/app/(interview)/onboard/[protocolId]/route.ts index 873156bd..c36c5c4c 100644 --- a/app/(interview)/onboard/[protocolId]/route.ts +++ b/app/(interview)/onboard/[protocolId]/route.ts @@ -1,8 +1,8 @@ -import { NextResponse, type NextRequest } from 'next/server'; -import { trackEvent } from '~/analytics/utils'; -import { api } from '~/trpc/server'; import { cookies } from 'next/headers'; -import { redirect } from 'next/navigation'; +import { NextResponse, type NextRequest } from 'next/server'; +import { createInterview } from '~/actions/interviews'; +import trackEvent from '~/lib/analytics'; +import { getLimitInterviewsStatus } from '~/queries/appSettings'; export const dynamic = 'force-dynamic'; @@ -11,19 +11,22 @@ const handler = async ( { params }: { params: { protocolId: string } }, ) => { const protocolId = params.protocolId; // From route segment + const url = req.nextUrl.clone(); // If no protocol ID is provided, redirect to the error page. if (!protocolId || protocolId === 'undefined') { - return NextResponse.redirect(new URL('/onboard/error', req.nextUrl)); + url.pathname = '/onboard/error'; + return NextResponse.redirect(url); } - const appSettings = await api.appSettings.get.query(); + const limitInterviews = await getLimitInterviewsStatus(); // if limitInterviews is enabled // Check cookies for interview already completed for this user for this protocol // and redirect to finished page - if (appSettings?.limitInterviews && cookies().get(protocolId)) { - redirect('/interview/finished'); + if (limitInterviews && cookies().get(protocolId)) { + url.pathname = '/interview/finished'; + return NextResponse.redirect(url); } let participantIdentifier: string | undefined; @@ -42,7 +45,7 @@ const handler = async ( } // Create a new interview given the protocolId and participantId - const { createdInterviewId, error } = await api.interview.create.mutate({ + const { createdInterviewId, error } = await createInterview({ participantIdentifier, protocolId, }); @@ -57,7 +60,8 @@ const handler = async ( }, }); - return NextResponse.redirect(new URL('/onboard/error', req.nextUrl)); + url.pathname = '/onboard/error'; + return NextResponse.redirect(url); } // eslint-disable-next-line no-console @@ -75,9 +79,8 @@ const handler = async ( }); // Redirect to the interview - return NextResponse.redirect( - new URL(`/interview/${createdInterviewId}`, req.nextUrl), - ); + url.pathname = `/interview/${createdInterviewId}`; + return NextResponse.redirect(url); }; export { handler as GET, handler as POST }; diff --git a/app/(setup)/_components/Helpers.tsx b/app/(setup)/_components/Helpers.tsx deleted file mode 100644 index 619fbf82..00000000 --- a/app/(setup)/_components/Helpers.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { motion } from 'framer-motion'; -import { Loader2 } from 'lucide-react'; - -export const StepLoadingState = () => ( - - - -); diff --git a/app/(setup)/_components/OnboardSteps/ManageParticipants.tsx b/app/(setup)/_components/OnboardSteps/ManageParticipants.tsx deleted file mode 100644 index 8ede0b06..00000000 --- a/app/(setup)/_components/OnboardSteps/ManageParticipants.tsx +++ /dev/null @@ -1,93 +0,0 @@ -'use client'; - -import { Check } from 'lucide-react'; -import { Button } from '~/components/ui/Button'; -import ImportCSVModal from '~/app/(dashboard)/dashboard/participants/_components/ImportCSVModal'; -import { useOnboardingContext } from '../OnboardingProvider'; -import { useState } from 'react'; -import RecruitmentSwitch from '~/components/RecruitmentSwitch'; -import Heading from '~/components/ui/typography/Heading'; -import Paragraph from '~/components/ui/typography/Paragraph'; -import SettingsSection from '~/components/layout/SettingsSection'; -import LimitInterviewsSwitch from '~/components/LimitInterviewsSwitch'; - -// const SettingsSection = ({ -// title, -// description, -// children, -// }: { -// title: string; -// description: string; -// children: React.ReactNode; -// }) => ( -//
    -//
    -// {title} -// {description} -//
    -// {children} -//
    -// ); - -function ManageParticipants() { - const [participantsUploaded, setParticipantsUploaded] = useState(false); - const { currentStep, setCurrentStep } = useOnboardingContext(); - - const handleParticipantsUploaded = () => { - setParticipantsUploaded(true); - }; - - const handleNextStep = () => { - void setCurrentStep(currentStep + 1); - }; - - return ( -
    -
    - Configure Participation - - You can now optionally upload a CSV file containing the details of - participants you wish to recruit for your study. You can also choose - to allow anonymous recruitment of participants. Both options can be - configured later from the dashboard. - -
    -
    - - {participantsUploaded && } - {!participantsUploaded && ( - - )} - - } - > - Upload a CSV file of participants. - - } - > - - Allow participants to join your study by visiting a URL. - - - } - > - - Limit each participant to being allowed to complete one interview per protocol. - - -
    -
    - -
    -
    - ); -} - -export default ManageParticipants; diff --git a/app/(setup)/_components/OnboardingProvider.tsx b/app/(setup)/_components/OnboardingProvider.tsx deleted file mode 100644 index 7967338f..00000000 --- a/app/(setup)/_components/OnboardingProvider.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useQueryState, parseAsInteger } from 'nuqs'; -import { createContext, useContext, type ReactNode } from 'react'; -import type { URLSearchParams } from 'url'; - -type OnboardingContext = { - currentStep: number; - setCurrentStep: (step: number) => Promise; -}; - -const onboardingContext = createContext(null); - -export const useOnboardingContext = () => { - const context = useContext(onboardingContext); - - if (!context) { - throw new Error( - 'useOnboardingContext must be used within a OnboardingProvider', - ); - } - - return context; -}; - -export const OnboardingProvider = ({ children }: { children: ReactNode }) => { - const [currentStep, setCurrentStep] = useQueryState( - 'step', - parseAsInteger.withDefault(1), - ); - - return ( - - {children} - - ); -}; diff --git a/app/(setup)/_components/SignInForm.tsx b/app/(setup)/_components/SignInForm.tsx deleted file mode 100644 index b8a75287..00000000 --- a/app/(setup)/_components/SignInForm.tsx +++ /dev/null @@ -1,102 +0,0 @@ -'use client'; - -import { Button } from '~/components/ui/Button'; -import { Input } from '~/components/ui/Input'; -import { userSignInFormSchema } from './schemas'; -import { Loader2 } from 'lucide-react'; -import { useState } from 'react'; -import { api } from '~/trpc/client'; -import ActionError from '../../../components/ActionError'; -import useZodForm from '~/hooks/useZodForm'; -import { useRouter } from 'next/navigation'; - -type ResponseError = { - title: string; - description: string; -}; - -export default function SignInForm() { - const router = useRouter(); - const [loading, setLoading] = useState(false); - - const [responseError, setResponseError] = useState( - null, - ); - - const { - register, - handleSubmit, - formState: { errors }, - } = useZodForm({ - schema: userSignInFormSchema, - }); - - const { mutateAsync: signIn } = api.session.signIn.useMutation({ - onMutate: () => setLoading(true), - onSuccess: (result) => { - if (result.error ?? !result.session) { - setLoading(false); // Only reset loading state on error, otherwise we are signing in... - setResponseError({ - title: 'Sign in failed', - description: result.error, - }); - } - - router.refresh(); - }, - onError: (error) => { - setLoading(false); - throw new Error(error.message); - }, - }); - - const onSubmit = async (data: unknown) => { - const payload = userSignInFormSchema.parse(data); - await signIn(payload); - }; - - return ( -
    void handleSubmit(onSubmit)(event)} - className="flex w-full flex-col" - > - {responseError && ( -
    - -
    - )} -
    - -
    -
    - -
    -
    - {loading ? ( - - ) : ( - - )} -
    -
    - ); -} diff --git a/app/(setup)/_components/SignUpForm.tsx b/app/(setup)/_components/SignUpForm.tsx deleted file mode 100644 index a93972e4..00000000 --- a/app/(setup)/_components/SignUpForm.tsx +++ /dev/null @@ -1,99 +0,0 @@ -'use client'; - -import { Button } from '~/components/ui/Button'; -import useZodForm from '~/hooks/useZodForm'; -import { Input } from '~/components/ui/Input'; -import { userCreateFormSchema } from './schemas'; -import { Loader2, XCircle } from 'lucide-react'; -import { api } from '~/trpc/client'; -import { type z } from 'zod'; -import { useToast } from '~/components/ui/use-toast'; -import { useOnboardingContext } from './OnboardingProvider'; -import { useEffect } from 'react'; -import { useAtomValue } from 'jotai'; -import { sessionAtom } from '~/providers/SessionProvider'; - -export const SignUpForm = () => { - const { - register, - handleSubmit, - formState: { errors, isValid }, - } = useZodForm({ - schema: userCreateFormSchema, - mode: 'all', - }); - - const { toast } = useToast(); - const session = useAtomValue(sessionAtom); - - const { mutateAsync: signUp, isLoading } = api.session.signUp.useMutation({ - onSuccess: (result) => { - if (result.error) { - const error = result.error; - toast({ - title: 'Error', - description: error, - icon: , - variant: 'destructive', - }); - return; - } - - void setCurrentStep(2); - }, - }); - const { currentStep, setCurrentStep } = useOnboardingContext(); - // If we are logged in, skip this step. - useEffect(() => { - if (session) { - void setCurrentStep(2); - } - }, [session, setCurrentStep, currentStep]); - - const onSubmit = async (data: z.infer) => { - await signUp(data); - }; - - return ( -
    void handleSubmit(onSubmit)(event)} - autoComplete="do-not-autofill" - > -
    - -
    -
    - -
    -
    - {isLoading ? ( - - ) : ( - - )} -
    -
    - ); -}; diff --git a/app/(setup)/setup/page.tsx b/app/(setup)/setup/page.tsx deleted file mode 100644 index d96145d8..00000000 --- a/app/(setup)/setup/page.tsx +++ /dev/null @@ -1,81 +0,0 @@ -'use client'; - -import { cn } from '~/utils/shadcn'; -import { useRouter } from 'next/navigation'; -import OnboardSteps from '../_components/Sidebar'; -import { parseAsInteger, useQueryState } from 'nuqs'; -import { containerClasses } from '../_components/schemas'; -import React, { useEffect } from 'react'; -import { api } from '~/trpc/client'; -import dynamic from 'next/dynamic'; -import { AnimatePresence, motion } from 'framer-motion'; -import { OnboardingProvider } from '../_components/OnboardingProvider'; -import { StepLoadingState } from '../_components/Helpers'; -import { clientRevalidateTag } from '~/utils/clientRevalidate'; - -// Stages are dynamically imported, and then conditionally rendered, so that -// we don't load all the code for all the stages at once. -const CreateAccount = dynamic( - () => import('../_components/OnboardSteps/CreateAccount'), - { - loading: () => , - }, -); -const UploadProtocol = dynamic( - () => import('../_components/OnboardSteps/UploadProtocol'), - { - loading: () => , - }, -); -const ManageParticipants = dynamic( - () => import('../_components/OnboardSteps/ManageParticipants'), - { - loading: () => , - }, -); -const Documentation = dynamic( - () => import('../_components/OnboardSteps/Documentation'), - { - loading: () => , - }, -); - -function Page() { - const router = useRouter(); - - const [currentStep] = useQueryState('step', parseAsInteger.withDefault(1)); - - const { data } = api.appSettings.get.useQuery(undefined, { - refetchInterval: 1000 * 10, - }); - - useEffect(() => { - if (data?.expired) { - clientRevalidateTag('appSettings.get') - .then(() => router.refresh()) - // eslint-disable-next-line no-console - .catch((e) => console.error(e)); - } - }, [data, router]); - - const cardClasses = cn(containerClasses, 'flex-row bg-transparent p-0 gap-6'); - const mainClasses = cn('bg-white flex w-full p-12 rounded-xl'); - - return ( - - - -
    - - {currentStep === 1 && } - {currentStep === 2 && } - {currentStep === 3 && } - {currentStep === 4 && } - -
    -
    -
    - ); -} - -export default Page; diff --git a/app/(setup)/signin/page.tsx b/app/(setup)/signin/page.tsx deleted file mode 100644 index 2f7b9be4..00000000 --- a/app/(setup)/signin/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { containerClasses } from '../_components/schemas'; -import SignInForm from '../_components/SignInForm'; -import { cn } from '~/utils/shadcn'; - -export const metadata = { - title: 'Fresco - Sign In', - description: 'Sign in to Fresco.', -}; - -export default function Page() { - return ( -
    -

    Sign In To Fresco

    - -
    - ); -} diff --git a/app/_actions.ts b/app/_actions.ts deleted file mode 100644 index 1d0f0e6c..00000000 --- a/app/_actions.ts +++ /dev/null @@ -1,19 +0,0 @@ -'use server'; - -import { redirect } from 'next/navigation'; -import { api } from '~/trpc/server'; -import { ensureError } from '~/utils/ensureError'; - -export const resetAppSettings = async () => { - try { - await api.appSettings.reset.mutate(); - } catch (error) { - const e = ensureError(error); - throw new Error(e.message); - } -}; - -export const setAppConfigured = async () => { - await api.appSettings.setConfigured.mutate(); - redirect('/dashboard'); -}; diff --git a/app/api/analytics/route.ts b/app/api/analytics/route.ts index 8b041605..731f7820 100644 --- a/app/api/analytics/route.ts +++ b/app/api/analytics/route.ts @@ -1,10 +1,13 @@ -import { getInstallationId } from '~/analytics/utils'; import { createRouteHandler } from '@codaco/analytics'; +import { type NextRequest } from 'next/server'; +import { getInstallationId } from '~/queries/appSettings'; -const installationId = await getInstallationId(); +const routeHandler = async (request: NextRequest) => { + const installationId = await getInstallationId(); -const routeHandler = createRouteHandler({ - installationId, -}); + return createRouteHandler({ + installationId, + })(request); +}; export { routeHandler as POST }; diff --git a/app/api/revalidate/route.ts b/app/api/revalidate/route.ts deleted file mode 100644 index df676279..00000000 --- a/app/api/revalidate/route.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { revalidatePath, revalidateTag } from 'next/cache'; - -type RequestData = { tag?: string; path?: string }; - -// Route handler for triggering revalidation based on a tag or a path from client components -export async function POST(request: Request) { - const { tag, path } = (await request.json()) as RequestData; - - if (tag) { - revalidateTag(tag); - return Response.json({ revalidated: true, now: Date.now() }); - } - - if (path) { - revalidatePath(path); - return Response.json({ revalidated: true, now: Date.now() }); - } - - return Response.json({ revalidated: false, now: Date.now() }); -} diff --git a/app/api/trpc/[trpc]/route.ts b/app/api/trpc/[trpc]/route.ts deleted file mode 100644 index f5f80bef..00000000 --- a/app/api/trpc/[trpc]/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; -import { env } from '~/env.mjs'; -import { appRouter } from '~/server/router'; -import { createTRPCContext } from '~/server/context'; -import { trackEvent } from '~/analytics/utils'; - -const handler = (req: Request) => - fetchRequestHandler({ - endpoint: '/api/trpc', - req, - router: appRouter, - createContext: () => createTRPCContext({ req }), - onError(opts) { - const { error, type, path } = opts; - - void trackEvent({ - type: 'Error', - name: error.name, - message: error.message, - stack: error.stack, - metadata: { - code: error.code, - path, - type, - }, - }); - - if (env.NODE_ENV === 'development') { - // eslint-disable-next-line no-console - console.error(error); - } - }, - }); - -export { handler as GET, handler as POST }; diff --git a/app/api/uploadthing/core.ts b/app/api/uploadthing/core.ts index fe3e26cd..bd9a91a7 100644 --- a/app/api/uploadthing/core.ts +++ b/app/api/uploadthing/core.ts @@ -1,5 +1,4 @@ import { createUploadthing } from 'uploadthing/next'; -import { UTApi } from 'uploadthing/server'; import { getServerSession } from '~/utils/auth'; const f = createUploadthing(); @@ -18,6 +17,4 @@ export const ourFileRouter = { .onUploadComplete(() => undefined), }; -export const utapi = new UTApi(); - export type OurFileRouter = typeof ourFileRouter; diff --git a/app/dashboard/_components/ActivityFeed/ActivityFeed.tsx b/app/dashboard/_components/ActivityFeed/ActivityFeed.tsx new file mode 100644 index 00000000..4737a017 --- /dev/null +++ b/app/dashboard/_components/ActivityFeed/ActivityFeed.tsx @@ -0,0 +1,20 @@ +import { hash } from 'ohash'; +import { Suspense } from 'react'; +import { DataTableSkeleton } from '~/components/data-table/data-table-skeleton'; +import { getActivities } from '~/queries/activityFeed'; +import ActivityFeedTable from './ActivityFeedTable'; +import { searchParamsCache } from './SearchParams'; + +export default function ActivityFeed() { + const searchParams = searchParamsCache.all(); + const activitiesPromise = getActivities(searchParams); + + return ( + } + > + + + ); +} diff --git a/app/(dashboard)/dashboard/_components/ActivityFeed/ActivityFeedTable.tsx b/app/dashboard/_components/ActivityFeed/ActivityFeedTable.tsx similarity index 77% rename from app/(dashboard)/dashboard/_components/ActivityFeed/ActivityFeedTable.tsx rename to app/dashboard/_components/ActivityFeed/ActivityFeedTable.tsx index ceb9bdd8..1223ba19 100644 --- a/app/(dashboard)/dashboard/_components/ActivityFeed/ActivityFeedTable.tsx +++ b/app/dashboard/_components/ActivityFeed/ActivityFeedTable.tsx @@ -1,22 +1,24 @@ 'use client'; -import { useMemo } from 'react'; +import type { Events } from '@prisma/client'; import type { ColumnDef } from '@tanstack/react-table'; -import { useDataTable } from '~/hooks/use-data-table'; +import { use, useMemo } from 'react'; import { DataTable } from '~/components/data-table/data-table'; +import { useDataTable } from '~/hooks/use-data-table'; +import type { ActivitiesFeed } from '~/queries/activityFeed'; import { fetchActivityFeedTableColumnDefs, - searchableColumns, filterableColumns, + searchableColumns, } from './ColumnDefinition'; -import type { Events } from '@prisma/client'; -import { type RouterOutputs } from '~/trpc/shared'; export default function ActivityFeedTable({ - tableData, + activitiesPromise, }: { - tableData: RouterOutputs['dashboard']['getActivities']; + activitiesPromise: ActivitiesFeed; }) { + const tableData = use(activitiesPromise); + // Memoize the columns so they don't re-render on every render const columns = useMemo[]>( () => fetchActivityFeedTableColumnDefs(), @@ -37,8 +39,6 @@ export default function ActivityFeedTable({ columns={columns} searchableColumns={searchableColumns} filterableColumns={filterableColumns} - // floatingBarContent={TasksTableFloatingBarContent(dataTable)} - // deleteRowsAction={(_event) => {}} /> ); } diff --git a/app/(dashboard)/dashboard/_components/ActivityFeed/ColumnDefinition.tsx b/app/dashboard/_components/ActivityFeed/ColumnDefinition.tsx similarity index 100% rename from app/(dashboard)/dashboard/_components/ActivityFeed/ColumnDefinition.tsx rename to app/dashboard/_components/ActivityFeed/ColumnDefinition.tsx diff --git a/app/dashboard/_components/ActivityFeed/SearchParams.ts b/app/dashboard/_components/ActivityFeed/SearchParams.ts new file mode 100644 index 00000000..92441377 --- /dev/null +++ b/app/dashboard/_components/ActivityFeed/SearchParams.ts @@ -0,0 +1,20 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsJson, + parseAsStringLiteral, +} from 'nuqs/server'; +import { FilterParam, sortOrder, sortableFields } from '~/lib/data-table/types'; + +export const searchParamsParsers = { + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: parseAsStringLiteral(sortOrder).withDefault('desc'), + sortField: parseAsStringLiteral(sortableFields).withDefault('timestamp'), + filterParams: parseAsArrayOf( + parseAsJson((value) => FilterParam.parse(value)), + ), +}; + +export const searchParamsCache = createSearchParamsCache(searchParamsParsers); \ No newline at end of file diff --git a/app/dashboard/_components/ActivityFeed/useTableStateFromSearchParams.ts b/app/dashboard/_components/ActivityFeed/useTableStateFromSearchParams.ts new file mode 100644 index 00000000..30a29a40 --- /dev/null +++ b/app/dashboard/_components/ActivityFeed/useTableStateFromSearchParams.ts @@ -0,0 +1,31 @@ +'use client'; +import { useQueryStates } from 'nuqs'; +import { searchParamsParsers } from './SearchParams'; + +/** + * This hook implements the table state items required by the DataTable. + * + * Ultimately, we could abstract this further, and implement a generic + * useSearchParamsTableState hook so that the way the state is stored is an + * implementation detail. This would allow us to store table state in novel + * ways, such as in localStorage, in the URL, or even in a database. + * + */ +export const useTableStateFromSearchParams = () => { + const [{ page, perPage, sort, sortField, filterParams }, setSearchParams] = + useQueryStates(searchParamsParsers, { + clearOnDefault: true, + shallow: false, + }); + + return { + searchParams: { + page, + perPage, + sort, + sortField, + filterParams, + }, + setSearchParams, + }; +}; \ No newline at end of file diff --git a/app/(dashboard)/dashboard/_components/ActivityFeed/utils.ts b/app/dashboard/_components/ActivityFeed/utils.ts similarity index 100% rename from app/(dashboard)/dashboard/_components/ActivityFeed/utils.ts rename to app/dashboard/_components/ActivityFeed/utils.ts diff --git a/app/(dashboard)/dashboard/_components/AnalyticsButton.tsx b/app/dashboard/_components/AnalyticsButton.tsx similarity index 95% rename from app/(dashboard)/dashboard/_components/AnalyticsButton.tsx rename to app/dashboard/_components/AnalyticsButton.tsx index f634328a..83898923 100644 --- a/app/(dashboard)/dashboard/_components/AnalyticsButton.tsx +++ b/app/dashboard/_components/AnalyticsButton.tsx @@ -1,7 +1,7 @@ 'use client'; -import { trackEvent } from '~/analytics/utils'; import { Button } from '~/components/ui/Button'; import { useToast } from '~/components/ui/use-toast'; +import trackEvent from '~/lib/analytics'; import { ensureError } from '~/utils/ensureError'; const AnalyticsButton = () => { diff --git a/app/(dashboard)/dashboard/_components/InterviewsTable/ActionsDropdown.tsx b/app/dashboard/_components/InterviewsTable/ActionsDropdown.tsx similarity index 93% rename from app/(dashboard)/dashboard/_components/InterviewsTable/ActionsDropdown.tsx rename to app/dashboard/_components/InterviewsTable/ActionsDropdown.tsx index 4b5640bc..f1d5fd35 100644 --- a/app/(dashboard)/dashboard/_components/InterviewsTable/ActionsDropdown.tsx +++ b/app/dashboard/_components/InterviewsTable/ActionsDropdown.tsx @@ -11,7 +11,7 @@ import { } from '~/components/ui/dropdown-menu'; import type { Row } from '@tanstack/react-table'; import { useState } from 'react'; -import { DeleteInterviewsDialog } from '~/app/(dashboard)/dashboard/interviews/_components/DeleteInterviewsDialog'; +import { DeleteInterviewsDialog } from '~/app/dashboard/interviews/_components/DeleteInterviewsDialog'; import type { Interview } from '@prisma/client'; import Link from 'next/link'; diff --git a/app/(dashboard)/dashboard/_components/InterviewsTable/Columns.tsx b/app/dashboard/_components/InterviewsTable/Columns.tsx similarity index 95% rename from app/(dashboard)/dashboard/_components/InterviewsTable/Columns.tsx rename to app/dashboard/_components/InterviewsTable/Columns.tsx index 6fd98792..a15c0e2e 100644 --- a/app/(dashboard)/dashboard/_components/InterviewsTable/Columns.tsx +++ b/app/dashboard/_components/InterviewsTable/Columns.tsx @@ -6,13 +6,13 @@ import { DataTableColumnHeader } from '~/components/DataTable/ColumnHeader'; import { Progress } from '~/components/ui/progress'; import type { Stage } from '@codaco/shared-consts'; import { Badge } from '~/components/ui/badge'; -import type { RouterOutputs } from '~/trpc/shared'; import TimeAgo from '~/components/ui/TimeAgo'; import Image from 'next/image'; +import type { GetInterviewsReturnType } from '~/queries/interviews'; -type Interviews = RouterOutputs['interview']['get']['all'][0]; - -export const InterviewColumns = (): ColumnDef[] => [ +export const InterviewColumns = (): ColumnDef< + Awaited[0] +>[] => [ { id: 'select', header: ({ table }) => ( diff --git a/app/(dashboard)/dashboard/_components/InterviewsTable/InterviewsTable.tsx b/app/dashboard/_components/InterviewsTable/InterviewsTable.tsx similarity index 69% rename from app/(dashboard)/dashboard/_components/InterviewsTable/InterviewsTable.tsx rename to app/dashboard/_components/InterviewsTable/InterviewsTable.tsx index d75a5627..3101e0fc 100644 --- a/app/(dashboard)/dashboard/_components/InterviewsTable/InterviewsTable.tsx +++ b/app/dashboard/_components/InterviewsTable/InterviewsTable.tsx @@ -1,39 +1,34 @@ 'use client'; -import { useMemo, useState } from 'react'; -import { ActionsDropdown } from '~/app/(dashboard)/dashboard/_components/InterviewsTable/ActionsDropdown'; -import { InterviewColumns } from '~/app/(dashboard)/dashboard/_components/InterviewsTable/Columns'; +import { use, useMemo, useState } from 'react'; +import { ActionsDropdown } from '~/app/dashboard/_components/InterviewsTable/ActionsDropdown'; +import { InterviewColumns } from '~/app/dashboard/_components/InterviewsTable/Columns'; import { DataTable } from '~/components/DataTable/DataTable'; import { Button } from '~/components/ui/Button'; -import { api } from '~/trpc/client'; -import { DeleteInterviewsDialog } from '~/app/(dashboard)/dashboard/interviews/_components/DeleteInterviewsDialog'; -import { ExportInterviewsDialog } from '~/app/(dashboard)/dashboard/interviews/_components/ExportInterviewsDialog'; -import type { RouterOutputs } from '~/trpc/shared'; +import { DeleteInterviewsDialog } from '~/app/dashboard/interviews/_components/DeleteInterviewsDialog'; +import { ExportInterviewsDialog } from '~/app/dashboard/interviews/_components/ExportInterviewsDialog'; import { HardDriveUpload } from 'lucide-react'; -import { GenerateInterviewURLs } from '~/app/(dashboard)/dashboard/interviews/_components/ExportInterviewUrlSection'; +import { GenerateInterviewURLs } from '~/app/dashboard/interviews/_components/GenerateInterviewURLs'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '~/components/ui/dropdown-menu'; - -type Interviews = RouterOutputs['interview']['get']['all']; +import type { GetInterviewsReturnType } from '~/queries/interviews'; +import type { GetProtocolsReturnType } from '~/queries/protocols'; export const InterviewsTable = ({ - initialInterviews, + interviewsPromise, + protocolsPromise, }: { - initialInterviews: Interviews; + interviewsPromise: GetInterviewsReturnType; + protocolsPromise: GetProtocolsReturnType; }) => { - const { data: interviews } = api.interview.get.all.useQuery(undefined, { - initialData: initialInterviews, - refetchOnMount: false, - onError(error) { - throw new Error(error.message); - }, - }); + const interviews = use(interviewsPromise); - const [selectedInterviews, setSelectedInterviews] = useState(); + const [selectedInterviews, setSelectedInterviews] = + useState(); const [showDeleteModal, setShowDeleteModal] = useState(false); const [showExportModal, setShowExportModal] = useState(false); @@ -42,7 +37,7 @@ export const InterviewsTable = ({ [interviews], ); - const handleDelete = (data: Interviews) => { + const handleDelete = (data: typeof interviews) => { setSelectedInterviews(data); setShowDeleteModal(true); }; @@ -106,7 +101,10 @@ export const InterviewsTable = ({ - + } /> diff --git a/app/dashboard/_components/InterviewsTable/InterviewsTableServer.tsx b/app/dashboard/_components/InterviewsTable/InterviewsTableServer.tsx new file mode 100644 index 00000000..2817bbde --- /dev/null +++ b/app/dashboard/_components/InterviewsTable/InterviewsTableServer.tsx @@ -0,0 +1,21 @@ +import { Suspense } from 'react'; +import { DataTableSkeleton } from '~/components/data-table/data-table-skeleton'; +import { getInterviews } from '~/queries/interviews'; +import { getProtocols } from '~/queries/protocols'; +import { InterviewsTable } from './InterviewsTable'; + +export default function InterviewsTableServer() { + const interviewsPromise = getInterviews(); + const protocolsPromise = getProtocols(); + + return ( + } + > + + + ); +} diff --git a/app/(dashboard)/dashboard/_components/NavigationBar.tsx b/app/dashboard/_components/NavigationBar.tsx similarity index 93% rename from app/(dashboard)/dashboard/_components/NavigationBar.tsx rename to app/dashboard/_components/NavigationBar.tsx index 6dd79997..1f14c465 100644 --- a/app/(dashboard)/dashboard/_components/NavigationBar.tsx +++ b/app/dashboard/_components/NavigationBar.tsx @@ -1,18 +1,17 @@ 'use client'; -import * as React from 'react'; +import { motion } from 'framer-motion'; +import type { Route } from 'next'; import Image from 'next/image'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { cn } from '~/utils/shadcn'; -import UserMenu from './UserMenu'; import type { UrlObject } from 'url'; -import type { Route } from 'next'; -import { motion } from 'framer-motion'; import Heading from '~/components/ui/typography/Heading'; -import { env } from '~/env.mjs'; +import { env } from '~/env'; +import { cn } from '~/utils/shadcn'; +import UserMenu from './UserMenu'; -export const NavButton = ({ +const NavButton = ({ label, href, isActive = false, @@ -22,7 +21,7 @@ export const NavButton = ({ isActive?: boolean; }) => { return ( - + [] { +export function getParticipantColumns( + protocols: Awaited, +): ColumnDef[] { return [ { id: 'select', @@ -83,7 +83,8 @@ export function getParticipantColumns(): ColumnDef< ).length; return ( - {row.original._count.interviews} ({completedInterviews} completed) + {row.original._count.interviews ?? ''} ({completedInterviews}{' '} + completed) ); }, @@ -120,7 +121,12 @@ export function getParticipantColumns(): ColumnDef< ); }, cell: ({ row }) => { - return ; + return ( + + ); }, }, ]; diff --git a/app/(dashboard)/dashboard/_components/ParticipantsTable/GenerateParticipantURLButton.tsx b/app/dashboard/_components/ParticipantsTable/GenerateParticipantURLButton.tsx similarity index 80% rename from app/(dashboard)/dashboard/_components/ParticipantsTable/GenerateParticipantURLButton.tsx rename to app/dashboard/_components/ParticipantsTable/GenerateParticipantURLButton.tsx index 4c6f0600..40dd60db 100644 --- a/app/(dashboard)/dashboard/_components/ParticipantsTable/GenerateParticipantURLButton.tsx +++ b/app/dashboard/_components/ParticipantsTable/GenerateParticipantURLButton.tsx @@ -1,7 +1,7 @@ 'use client'; import type { Participant, Protocol } from '@prisma/client'; -import { useState, useEffect, useRef } from 'react'; +import { useRef, useState } from 'react'; import { Select, SelectContent, @@ -10,31 +10,22 @@ import { SelectValue, } from '~/components/ui/select'; +import { PopoverTrigger } from '@radix-ui/react-popover'; +import { Check, Copy } from 'lucide-react'; import { Button } from '~/components/ui/Button'; -import { api } from '~/trpc/client'; -import { getBaseUrl } from '~/trpc/shared'; -import { useToast } from '~/components/ui/use-toast'; import { Popover, PopoverContent } from '~/components/ui/popover'; -import { PopoverTrigger } from '@radix-ui/react-popover'; import Paragraph from '~/components/ui/typography/Paragraph'; -import { Check, Copy } from 'lucide-react'; +import { useToast } from '~/components/ui/use-toast'; +import type { GetProtocolsReturnType } from '~/queries/protocols'; export const GenerateParticipationURLButton = ({ participant, + protocols, }: { participant: Participant; + protocols: Awaited; }) => { - const { data: protocolData, isLoading: isLoadingProtocols } = - api.protocol.get.all.useQuery(); - const [protocols, setProtocols] = useState([]); - - const [selectedProtocol, setSelectedProtocol] = useState(); - - useEffect(() => { - if (protocolData) { - setProtocols(protocolData); - } - }, [protocolData]); + const [selectedProtocol, setSelectedProtocol] = useState(); const { toast } = useToast(); @@ -82,15 +73,14 @@ export const GenerateParticipationURLButton = ({ setSelectedProtocol(protocol); handleCopy( - `${getBaseUrl()}/onboard/${protocol?.id}/?participantIdentifier=${ - participant.identifier - }`, + `${window.location.origin}/onboard/${protocol?.id}/?participantIdentifier=${participant.identifier}`, ); ref.current?.click(); + + setSelectedProtocol(null); }} value={selectedProtocol?.id} - disabled={isLoadingProtocols} > diff --git a/app/dashboard/_components/ParticipantsTable/ParticipantsTable.tsx b/app/dashboard/_components/ParticipantsTable/ParticipantsTable.tsx new file mode 100644 index 00000000..a1e4023b --- /dev/null +++ b/app/dashboard/_components/ParticipantsTable/ParticipantsTable.tsx @@ -0,0 +1,21 @@ +import { Suspense } from 'react'; +import { DataTableSkeleton } from '~/components/data-table/data-table-skeleton'; +import { getParticipants } from '~/queries/participants'; +import { getProtocols } from '~/queries/protocols'; +import { ParticipantsTableClient } from './ParticipantsTableClient'; + +export default function ParticipantsTable() { + const participantsPromise = getParticipants(); + const protocolsPromise = getProtocols(); + + return ( + } + > + + + ); +} diff --git a/app/dashboard/_components/ParticipantsTable/ParticipantsTableClient.tsx b/app/dashboard/_components/ParticipantsTable/ParticipantsTableClient.tsx new file mode 100644 index 00000000..18463d06 --- /dev/null +++ b/app/dashboard/_components/ParticipantsTable/ParticipantsTableClient.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { type ColumnDef } from '@tanstack/react-table'; +import { Trash } from 'lucide-react'; +import { use, useCallback, useMemo, useState } from 'react'; +import { + deleteAllParticipants, + deleteParticipants, +} from '~/actions/participants'; +import { ActionsDropdown } from '~/app/dashboard/_components/ParticipantsTable/ActionsDropdown'; +import { getParticipantColumns } from '~/app/dashboard/_components/ParticipantsTable/Columns'; +import { DeleteParticipantsDialog } from '~/app/dashboard/participants/_components/DeleteParticipantsDialog'; +import { DataTable } from '~/components/DataTable/DataTable'; +import { Button } from '~/components/ui/Button'; +import type { GetParticipantsReturnType } from '~/queries/participants'; +import type { GetProtocolsReturnType } from '~/queries/protocols'; +import type { ParticipantWithInterviews } from '~/types/types'; +import AddParticipantButton from '../../participants/_components/AddParticipantButton'; +import { GenerateParticipantURLs } from '../../participants/_components/ExportParticipants/GenerateParticipantURLsButton'; + +export const ParticipantsTableClient = ({ + participantsPromise, + protocolsPromise, +}: { + participantsPromise: GetParticipantsReturnType; + protocolsPromise: GetProtocolsReturnType; +}) => { + const participants = use(participantsPromise); + const protocols = use(protocolsPromise); + + // Memoize the columns so they don't re-render on every render + const columns = useMemo[]>( + () => getParticipantColumns(protocols), + [protocols], + ); + + const [participantsToDelete, setParticipantsToDelete] = useState< + ParticipantWithInterviews[] | null + >(null); + const [showDeleteModal, setShowDeleteModal] = useState(false); + + // Actual delete handler, which handles optimistic updates, etc. + const doDelete = async () => { + if (!participantsToDelete) { + return; + } + + // Check if we are deleting all and call the appropriate function + if (participantsToDelete.length === participants.length) { + await deleteAllParticipants(); + resetDelete(); + return; + } + + await deleteParticipants(participantsToDelete.map((p) => p.id)); + + resetDelete(); + }; + + // Resets the state when the dialog is closed. + const resetDelete = () => { + setShowDeleteModal(false); + setParticipantsToDelete(null); + }; + + const handleDeleteItems = useCallback( + (items: ParticipantWithInterviews[]) => { + // Set state to the items to be deleted + setParticipantsToDelete(items); + + // Show the dialog + setShowDeleteModal(true); + }, + [], + ); + + const handleDeleteAll = useCallback(() => { + // Set state to all items + setParticipantsToDelete(participants); + + // Show the dialog + setShowDeleteModal(true); + }, [participants]); + + return ( + <> + participant._count.interviews > 0, + ) + } + haveUnexportedInterviews={ + !!participantsToDelete?.some((participant) => + participant.interviews.some((interview) => !interview.exportTime), + ) + } + onConfirm={doDelete} + onCancel={resetDelete} + /> + +
    + + +
    + + + } + /> + + ); +}; diff --git a/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx b/app/dashboard/_components/ProtocolUploader.tsx similarity index 92% rename from app/(dashboard)/dashboard/_components/ProtocolUploader.tsx rename to app/dashboard/_components/ProtocolUploader.tsx index 8ebd620b..e93e9c88 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolUploader.tsx +++ b/app/dashboard/_components/ProtocolUploader.tsx @@ -1,19 +1,19 @@ 'use client'; -import { useDropzone } from 'react-dropzone'; -import { Button } from '~/components/ui/Button'; -import { useProtocolImport } from '~/hooks/useProtocolImport'; -import { FileDown, Loader2 } from 'lucide-react'; import { AnimatePresence, motion } from 'framer-motion'; +import { FileDown, Loader2 } from 'lucide-react'; import { useCallback } from 'react'; -import usePortal from 'react-useportal'; -import { cn } from '~/utils/shadcn'; +import { useDropzone } from 'react-dropzone'; import JobCard from '~/components/ProtocolImport/JobCard'; +import { Button } from '~/components/ui/Button'; +import { PROTOCOL_EXTENSION } from '~/fresco.config'; +import usePortal from '~/hooks/usePortal'; +import { useProtocolImport } from '~/hooks/useProtocolImport'; import { withNoSSRWrapper } from '~/utils/NoSSRWrapper'; +import { cn } from '~/utils/shadcn'; function ProtocolUploader() { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const { Portal } = usePortal(); + const Portal = usePortal(); const { importProtocols, jobs, cancelJob, cancelAllJobs } = useProtocolImport(); @@ -24,8 +24,8 @@ function ProtocolUploader() { noClick: true, onDropAccepted: importProtocols, accept: { - 'application/octect-stream': ['.netcanvas'], - 'application/zip': ['.netcanvas'], + 'application/octect-stream': [PROTOCOL_EXTENSION], + 'application/zip': [PROTOCOL_EXTENSION], }, }); diff --git a/app/(dashboard)/dashboard/_components/ProtocolsTable/ActionsDropdown.tsx b/app/dashboard/_components/ProtocolsTable/ActionsDropdown.tsx similarity index 89% rename from app/(dashboard)/dashboard/_components/ProtocolsTable/ActionsDropdown.tsx rename to app/dashboard/_components/ProtocolsTable/ActionsDropdown.tsx index 78dc418f..c2dabc44 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolsTable/ActionsDropdown.tsx +++ b/app/dashboard/_components/ProtocolsTable/ActionsDropdown.tsx @@ -11,8 +11,8 @@ import { } from '~/components/ui/dropdown-menu'; import type { Row } from '@tanstack/react-table'; import { useState } from 'react'; -import type { ProtocolWithInterviews } from '~/shared/types'; -import { DeleteProtocolsDialog } from '~/app/(dashboard)/dashboard/protocols/_components/DeleteProtocolsDialog'; +import type { ProtocolWithInterviews } from '~/types/types'; +import { DeleteProtocolsDialog } from '~/app/dashboard/protocols/_components/DeleteProtocolsDialog'; export const ActionsDropdown = ({ row, diff --git a/app/(dashboard)/dashboard/_components/ProtocolsTable/AnonymousRecruitmentURLButton.tsx b/app/dashboard/_components/ProtocolsTable/AnonymousRecruitmentURLButton.tsx similarity index 78% rename from app/(dashboard)/dashboard/_components/ProtocolsTable/AnonymousRecruitmentURLButton.tsx rename to app/dashboard/_components/ProtocolsTable/AnonymousRecruitmentURLButton.tsx index 40d23ea3..0712f284 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolsTable/AnonymousRecruitmentURLButton.tsx +++ b/app/dashboard/_components/ProtocolsTable/AnonymousRecruitmentURLButton.tsx @@ -1,9 +1,9 @@ 'use client'; -import { getBaseUrl } from '~/trpc/shared'; -import { useToast } from '~/components/ui/use-toast'; import { Check, Copy } from 'lucide-react'; +import { useEffect, useState } from 'react'; import { Button } from '~/components/ui/Button'; +import { useToast } from '~/components/ui/use-toast'; export const AnonymousRecruitmentURLButton = ({ protocolId, @@ -11,8 +11,19 @@ export const AnonymousRecruitmentURLButton = ({ protocolId: string; }) => { const { toast } = useToast(); - const url = `${getBaseUrl()}/onboard/${protocolId}`; + const [url, setUrl] = useState(null); + + useEffect(() => { + if (typeof window !== 'undefined') { + setUrl(`${window.location.origin}/onboard/${protocolId}`); + } + }, [protocolId]); + const handleCopyClick = () => { + if (!url) { + return; + } + navigator.clipboard .writeText(url) .then(() => { diff --git a/app/(dashboard)/dashboard/_components/ProtocolsTable/Columns.tsx b/app/dashboard/_components/ProtocolsTable/Columns.tsx similarity index 98% rename from app/(dashboard)/dashboard/_components/ProtocolsTable/Columns.tsx rename to app/dashboard/_components/ProtocolsTable/Columns.tsx index b62decc4..2074deb1 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolsTable/Columns.tsx +++ b/app/dashboard/_components/ProtocolsTable/Columns.tsx @@ -3,7 +3,7 @@ import { type ColumnDef } from '@tanstack/react-table'; import { Checkbox } from '~/components/ui/checkbox'; import { DataTableColumnHeader } from '~/components/DataTable/ColumnHeader'; -import type { ProtocolWithInterviews } from '~/shared/types'; +import type { ProtocolWithInterviews } from '~/types/types'; import { AnonymousRecruitmentURLButton } from './AnonymousRecruitmentURLButton'; import TimeAgo from '~/components/ui/TimeAgo'; import Image from 'next/image'; diff --git a/app/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx b/app/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx new file mode 100644 index 00000000..606dda85 --- /dev/null +++ b/app/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx @@ -0,0 +1,37 @@ +import { unstable_noStore } from 'next/cache'; +import { Suspense } from 'react'; +import { DataTableSkeleton } from '~/components/data-table/data-table-skeleton'; +import { prisma } from '~/utils/db'; +import ProtocolsTableClient from './ProtocolsTableClient'; + +async function getData() { + unstable_noStore(); + + const data = await prisma.$transaction([ + prisma.protocol.findMany({ + include: { + interviews: true, + }, + }), + prisma.appSettings.findFirst(), + ]); + + return { + protocols: data[0], + appSettings: data[1], + }; +} + +export type GetData = ReturnType; + +export default function ProtocolsTable() { + const dataPromise = getData(); + + return ( + } + > + + + ); +} diff --git a/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx b/app/dashboard/_components/ProtocolsTable/ProtocolsTableClient.tsx similarity index 61% rename from app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx rename to app/dashboard/_components/ProtocolsTable/ProtocolsTableClient.tsx index e0629bc7..4f7b358a 100644 --- a/app/(dashboard)/dashboard/_components/ProtocolsTable/ProtocolsTable.tsx +++ b/app/dashboard/_components/ProtocolsTable/ProtocolsTableClient.tsx @@ -1,28 +1,20 @@ 'use client'; +import { use, useState } from 'react'; +import { DeleteProtocolsDialog } from '~/app/dashboard/protocols/_components/DeleteProtocolsDialog'; import { DataTable } from '~/components/DataTable/DataTable'; +import type { ProtocolWithInterviews } from '~/types/types'; +import ProtocolUploader from '../ProtocolUploader'; import { ActionsDropdown } from './ActionsDropdown'; import { getProtocolColumns } from './Columns'; -import { api } from '~/trpc/client'; -import { DeleteProtocolsDialog } from '~/app/(dashboard)/dashboard/protocols/_components/DeleteProtocolsDialog'; -import { useState } from 'react'; -import type { ProtocolWithInterviews } from '~/shared/types'; -import ProtocolUploader from '../ProtocolUploader'; +import { type GetData } from './ProtocolsTable'; + +const ProtocolsTableClient = ({ dataPromise }: { dataPromise: GetData }) => { + const data = use(dataPromise); -export const ProtocolsTable = ({ - initialData, - allowAnonymousRecruitment = false, -}: { - initialData: ProtocolWithInterviews[]; - allowAnonymousRecruitment: boolean; -}) => { - const { data: protocols } = api.protocol.get.all.useQuery(undefined, { - initialData, - refetchOnMount: false, - onError(error) { - throw new Error(error.message); - }, - }); + const { protocols, appSettings } = data; + + const allowAnonymousRecruitment = !!appSettings?.allowAnonymousRecruitment; const [showAlertDialog, setShowAlertDialog] = useState(false); const [protocolsToDelete, setProtocolsToDelete] = @@ -51,3 +43,5 @@ export const ProtocolsTable = ({ ); }; + +export default ProtocolsTableClient; diff --git a/app/(dashboard)/dashboard/_components/RecruitmentTestSection.tsx b/app/dashboard/_components/RecruitmentTestSection.tsx similarity index 72% rename from app/(dashboard)/dashboard/_components/RecruitmentTestSection.tsx rename to app/dashboard/_components/RecruitmentTestSection.tsx index 999721bb..4dd31224 100644 --- a/app/(dashboard)/dashboard/_components/RecruitmentTestSection.tsx +++ b/app/dashboard/_components/RecruitmentTestSection.tsx @@ -1,10 +1,8 @@ 'use client'; - import type { Participant, Protocol } from '@prisma/client'; -import type { Route } from 'next'; +import { type Route } from 'next'; import { useRouter } from 'next/navigation'; -import { useEffect, useState } from 'react'; -import Section from '~/components/layout/Section'; +import { use, useEffect, useState } from 'react'; import { Button } from '~/components/ui/Button'; import { Select, @@ -13,32 +11,26 @@ import { SelectTrigger, SelectValue, } from '~/components/ui/select'; -import Heading from '~/components/ui/typography/Heading'; -import Paragraph from '~/components/ui/typography/Paragraph'; -import { api } from '~/trpc/client'; -import { getBaseUrl } from '~/trpc/shared'; +import { type GetParticipantsReturnType } from '~/queries/participants'; +import { type GetProtocolsReturnType } from '~/queries/protocols'; -const RecruitmentTestSection = ({ - allowAnonymousRecruitment, +export default function RecruitmentTestSection({ + protocolsPromise, + participantsPromise, + allowAnonymousRecruitmentPromise, }: { - allowAnonymousRecruitment: boolean; -}) => { - const router = useRouter(); + protocolsPromise: GetProtocolsReturnType; + participantsPromise: GetParticipantsReturnType; + allowAnonymousRecruitmentPromise: Promise; +}) { + const protocols = use(protocolsPromise); + const participants = use(participantsPromise); + const allowAnonymousRecruitment = use(allowAnonymousRecruitmentPromise); - const { data: protocolData, isLoading: isLoadingProtocols } = - api.protocol.get.all.useQuery(); - const [protocols, setProtocols] = useState([]); const [selectedProtocol, setSelectedProtocol] = useState(); const [selectedParticipant, setSelectedParticipant] = useState(); - const { data: participants, isLoading: isLoadingParticipants } = - api.participant.get.all.useQuery(); - - useEffect(() => { - if (protocolData) { - setProtocols(protocolData); - } - }, [protocolData]); + const router = useRouter(); useEffect(() => { if (allowAnonymousRecruitment) { @@ -58,9 +50,7 @@ const RecruitmentTestSection = ({ }; return ( -
    - Recruitment Test Section - This section allows you to test recruitment. + <>
    { setSelectedProtocol(protocol); }} value={selectedProtocol?.id} - disabled={isLoadingProtocols} > @@ -110,9 +111,8 @@ export const GenerateInterviewURLs = () => { Cancel diff --git a/app/dashboard/interviews/loading.tsx b/app/dashboard/interviews/loading.tsx new file mode 100644 index 00000000..b5aeefa0 --- /dev/null +++ b/app/dashboard/interviews/loading.tsx @@ -0,0 +1,22 @@ +import ResponsiveContainer from '~/components/ResponsiveContainer'; +import { DataTableSkeleton } from '~/components/data-table/data-table-skeleton'; +import Section from '~/components/layout/Section'; +import PageHeader from '~/components/ui/typography/PageHeader'; + +export default function Loading() { + return ( + <> + + + + +
    + +
    +
    + + ); +} diff --git a/app/(dashboard)/dashboard/interviews/page.tsx b/app/dashboard/interviews/page.tsx similarity index 56% rename from app/(dashboard)/dashboard/interviews/page.tsx rename to app/dashboard/interviews/page.tsx index 8a532d06..7f644a28 100644 --- a/app/(dashboard)/dashboard/interviews/page.tsx +++ b/app/dashboard/interviews/page.tsx @@ -1,13 +1,14 @@ -import { InterviewsTable } from '~/app/(dashboard)/dashboard/_components/InterviewsTable/InterviewsTable'; import ResponsiveContainer from '~/components/ResponsiveContainer'; import Section from '~/components/layout/Section'; import PageHeader from '~/components/ui/typography/PageHeader'; -import { api } from '~/trpc/server'; +import { requireAppNotExpired } from '~/queries/appSettings'; +import { requirePageAuth } from '~/utils/auth'; +import InterviewsTableServer from '../_components/InterviewsTable/InterviewsTableServer'; -export const dynamic = 'force-dynamic'; +export default async function InterviewPage() { + await requireAppNotExpired(); + await requirePageAuth(); -const InterviewPage = async () => { - const initialInterviews = await api.interview.get.all.query(); return ( <> @@ -18,11 +19,9 @@ const InterviewPage = async () => {
    - +
    ); -}; - -export default InterviewPage; +} diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx new file mode 100644 index 00000000..3b2860f1 --- /dev/null +++ b/app/dashboard/layout.tsx @@ -0,0 +1,26 @@ +import FeedbackBanner from '~/components/Feedback/FeedbackBanner'; +import { requireAppNotExpired } from '~/queries/appSettings'; +import { requirePageAuth } from '~/utils/auth'; +import { NavigationBar } from './_components/NavigationBar'; + +export const metadata = { + title: 'Network Canvas Fresco - Dashboard', + description: 'Fresco.', +}; + +export const dynamic = 'force-dynamic'; + +const Layout = async ({ children }: { children: React.ReactNode }) => { + await requireAppNotExpired(); + await requirePageAuth(); + + return ( + <> + + + {children} + + ); +}; + +export default Layout; diff --git a/app/dashboard/loading.tsx b/app/dashboard/loading.tsx new file mode 100644 index 00000000..80702414 --- /dev/null +++ b/app/dashboard/loading.tsx @@ -0,0 +1,33 @@ +import ResponsiveContainer from '~/components/ResponsiveContainer'; +import { DataTableSkeleton } from '~/components/data-table/data-table-skeleton'; +import Section from '~/components/layout/Section'; +import Heading from '~/components/ui/typography/Heading'; +import PageHeader from '~/components/ui/typography/PageHeader'; +import Paragraph from '~/components/ui/typography/Paragraph'; +import { SummaryStatisticsSkeleton } from './_components/SummaryStatistics/SummaryStatistics'; + +export default function Loading() { + return ( + <> + + + + + + Recent Activity + + This table summarizes the most recent activity within Fresco. Use it + to keep track of new protocols, interviews, and participants. + + + +
    + +
    +
    + + ); +} diff --git a/app/(dashboard)/dashboard/page.tsx b/app/dashboard/page.tsx similarity index 72% rename from app/(dashboard)/dashboard/page.tsx rename to app/dashboard/page.tsx index 67844cf6..007f976d 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,15 +1,25 @@ import ResponsiveContainer from '~/components/ResponsiveContainer'; -import Heading from '~/components/ui/typography/Heading'; import Section from '~/components/layout/Section'; +import Heading from '~/components/ui/typography/Heading'; import PageHeader from '~/components/ui/typography/PageHeader'; -import { ActivityFeed } from './_components/ActivityFeed/ActivityFeed'; import Paragraph from '~/components/ui/typography/Paragraph'; +import { requireAppNotExpired } from '~/queries/appSettings'; +import { requirePageAuth } from '~/utils/auth'; +import ActivityFeed from './_components/ActivityFeed/ActivityFeed'; +import { searchParamsCache } from './_components/ActivityFeed/SearchParams'; import SummaryStatistics from './_components/SummaryStatistics/SummaryStatistics'; import AnonymousRecruitmentWarning from './protocols/_components/AnonymousRecruitmentWarning'; -export const dynamic = 'force-dynamic'; +export default async function Home({ + searchParams, +}: { + searchParams: Record; +}) { + await requireAppNotExpired(); + await requirePageAuth(); + + searchParamsCache.parse(searchParams); -function Home() { return ( <> @@ -35,5 +45,3 @@ function Home() { ); } - -export default Home; diff --git a/app/(dashboard)/dashboard/participants/_components/AddParticipantButton.tsx b/app/dashboard/participants/_components/AddParticipantButton.tsx similarity index 87% rename from app/(dashboard)/dashboard/participants/_components/AddParticipantButton.tsx rename to app/dashboard/participants/_components/AddParticipantButton.tsx index 7d7c1399..db433386 100644 --- a/app/(dashboard)/dashboard/participants/_components/AddParticipantButton.tsx +++ b/app/dashboard/participants/_components/AddParticipantButton.tsx @@ -2,7 +2,7 @@ import { Button } from '~/components/ui/Button'; import { type Participant } from '@prisma/client'; import { useState } from 'react'; -import ParticipantModal from '~/app/(dashboard)/dashboard/participants/_components/ParticipantModal'; +import ParticipantModal from '~/app/dashboard/participants/_components/ParticipantModal'; import { Plus } from 'lucide-react'; type AddParticipantButtonProps = { diff --git a/app/(dashboard)/dashboard/participants/_components/DeleteParticipantsDialog.tsx b/app/dashboard/participants/_components/DeleteParticipantsDialog.tsx similarity index 100% rename from app/(dashboard)/dashboard/participants/_components/DeleteParticipantsDialog.tsx rename to app/dashboard/participants/_components/DeleteParticipantsDialog.tsx diff --git a/app/(dashboard)/dashboard/participants/_components/DropzoneField.tsx b/app/dashboard/participants/_components/DropzoneField.tsx similarity index 93% rename from app/(dashboard)/dashboard/participants/_components/DropzoneField.tsx rename to app/dashboard/participants/_components/DropzoneField.tsx index 9784c689..468fb3ad 100644 --- a/app/(dashboard)/dashboard/participants/_components/DropzoneField.tsx +++ b/app/dashboard/participants/_components/DropzoneField.tsx @@ -1,13 +1,13 @@ -import { useController, type Control } from 'react-hook-form'; -import parseCSV from '~/utils/parseCSV'; -import { useDropzone } from 'react-dropzone'; -import { cn } from '~/utils/shadcn'; +import { isArray } from 'lodash'; +import { FileCheck, FileText } from 'lucide-react'; import { useId } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { useController, type Control } from 'react-hook-form'; import { Label } from '~/components/ui/Label'; -import { isArray } from 'lodash'; import Paragraph from '~/components/ui/typography/Paragraph'; -import { FileCheck, FileText } from 'lucide-react'; -import { type FormSchema } from '~/shared/schemas/schemas'; +import { type FormSchema } from '~/schemas/participant'; +import parseCSV from '~/utils/parseCSV'; +import { cn } from '~/utils/shadcn'; const accept = { 'text/csv': [], @@ -46,7 +46,10 @@ export default function DropzoneField({ } // Check that every row has either a label or an identifier - const valid = value.every((row) => row.label ?? row.identifier); + const valid = value.every( + (row) => + (row.label !== undefined && row.label !== '') || row.identifier, + ); if (!valid) { return 'Invalid CSV. Every row must have either a label or an identifier'; diff --git a/app/(dashboard)/dashboard/participants/_components/ExportParticipants/ExportCSVParticipantURLs.tsx b/app/dashboard/participants/_components/ExportParticipants/ExportCSVParticipantURLs.tsx similarity index 82% rename from app/(dashboard)/dashboard/participants/_components/ExportParticipants/ExportCSVParticipantURLs.tsx rename to app/dashboard/participants/_components/ExportParticipants/ExportCSVParticipantURLs.tsx index c015e540..37d49c14 100644 --- a/app/(dashboard)/dashboard/participants/_components/ExportParticipants/ExportCSVParticipantURLs.tsx +++ b/app/dashboard/participants/_components/ExportParticipants/ExportCSVParticipantURLs.tsx @@ -6,16 +6,15 @@ import { useState } from 'react'; import { Button } from '~/components/ui/Button'; import { useToast } from '~/components/ui/use-toast'; import { useDownload } from '~/hooks/useDownload'; -import { type RouterOutputs, getBaseUrl } from '~/trpc/shared'; +import type { GetParticipantsReturnType } from '~/queries/participants'; +import type { GetProtocolsReturnType } from '~/queries/protocols'; function ExportCSVParticipantURLs({ - participants, protocol, - disabled, + participants, }: { - participants: RouterOutputs['participant']['get']['all']; - protocol: RouterOutputs['protocol']['get']['all'][0]; - disabled: boolean; + protocol?: Awaited[0]; + participants: Awaited; }) { const download = useDownload(); const [isExporting, setIsExporting] = useState(false); @@ -31,9 +30,7 @@ function ExportCSVParticipantURLs({ const csvData = participants.map((participant) => ({ id: participant.id, identifier: participant.identifier, - interview_url: `${getBaseUrl()}/onboard/${protocol.id}/?participantId=${ - participant.id - }`, + interview_url: `${window.location.origin}/onboard/${protocol.id}/?participantId=${participant.id}`, })); const csv = unparse(csvData, { header: true }); @@ -66,7 +63,7 @@ function ExportCSVParticipantURLs({ return ( + + + + Generate Participation URLs + + Generate a CSV that contains{' '} + unique participation URLs for all participants by + protocol. These URLs can be shared with participants to allow them + to take your interview. + + +
    + + ({ + id: participant.id, + label: participant.identifier, + value: participant.id, + }))} + placeholder="Select Participants..." + singular="Participant" + plural="Participants" + value={selectedParticipants} + onValueChange={setSelectedParticipants} + /> +
    + + + participants.find((p) => p.id === id)!, + )} + /> + +
    +
    + + ); +}; diff --git a/app/(dashboard)/dashboard/participants/_components/ExportParticipants/ImportExportSection.tsx b/app/dashboard/participants/_components/ExportParticipants/ImportExportSection.tsx similarity index 70% rename from app/(dashboard)/dashboard/participants/_components/ExportParticipants/ImportExportSection.tsx rename to app/dashboard/participants/_components/ExportParticipants/ImportExportSection.tsx index a4bc5301..1b323af4 100644 --- a/app/(dashboard)/dashboard/participants/_components/ExportParticipants/ImportExportSection.tsx +++ b/app/dashboard/participants/_components/ExportParticipants/ImportExportSection.tsx @@ -1,10 +1,14 @@ -import Paragraph from '~/components/ui/typography/Paragraph'; +import { Suspense } from 'react'; +import ResponsiveContainer from '~/components/ResponsiveContainer'; import SettingsSection from '~/components/layout/SettingsSection'; +import { ButtonSkeleton } from '~/components/ui/Button'; +import Paragraph from '~/components/ui/typography/Paragraph'; +import { getParticipants } from '~/queries/participants'; import ImportCSVModal from '../ImportCSVModal'; import ExportParticipants from './ExportParticipants'; -import ResponsiveContainer from '~/components/ResponsiveContainer'; export default function ImportExportSection() { + const participantsPromise = getParticipants(); return ( - + }> + +
    } > diff --git a/app/(dashboard)/dashboard/participants/_components/ImportCSVModal.tsx b/app/dashboard/participants/_components/ImportCSVModal.tsx similarity index 90% rename from app/(dashboard)/dashboard/participants/_components/ImportCSVModal.tsx rename to app/dashboard/participants/_components/ImportCSVModal.tsx index f7844474..3fd688d8 100644 --- a/app/(dashboard)/dashboard/participants/_components/ImportCSVModal.tsx +++ b/app/dashboard/participants/_components/ImportCSVModal.tsx @@ -1,7 +1,11 @@ 'use client'; +import { AlertCircle, FileDown, Loader2 } from 'lucide-react'; import { useState } from 'react'; -import { api } from '~/trpc/client'; +import { useForm } from 'react-hook-form'; +import { ZodError } from 'zod'; +import { importParticipants } from '~/actions/participants'; +import { Alert, AlertDescription, AlertTitle } from '~/components/ui/Alert'; import { Button } from '~/components/ui/Button'; import { Dialog, @@ -12,14 +16,11 @@ import { DialogTitle, DialogTrigger, } from '~/components/ui/dialog'; -import { useToast } from '~/components/ui/use-toast'; -import { AlertCircle, FileDown, Loader2 } from 'lucide-react'; import Paragraph from '~/components/ui/typography/Paragraph'; import UnorderedList from '~/components/ui/typography/UnorderedList'; -import { Alert, AlertDescription, AlertTitle } from '~/components/ui/Alert'; -import { useForm } from 'react-hook-form'; +import { useToast } from '~/components/ui/use-toast'; +import { FormSchema } from '~/schemas/participant'; import DropzoneField from './DropzoneField'; -import { FormSchema } from '~/shared/schemas/schemas'; const ImportCSVModal = ({ onImportComplete, @@ -33,19 +34,6 @@ const ImportCSVModal = ({ }); const { isSubmitting, isValid } = formState; - const utils = api.useUtils(); - - const { - mutateAsync: importParticipants, - } = // TODO: think about optimistic updates - api.participant.create.useMutation({ - onError(error) { - throw new Error(error.message); - }, - async onSuccess() { - await utils.participant.get.all.invalidate(); - }, - }); const [showImportDialog, setShowImportDialog] = useState(false); @@ -91,6 +79,17 @@ const ImportCSVModal = ({ reset(); setShowImportDialog(false); } catch (e) { + // if it's a validation error, show the error message + if (e instanceof ZodError) { + toast({ + title: 'Error', + description: e.errors[0] + ? `Invalid CSV File: ${e.errors[0].message}` + : 'Invalid CSV file. Please check the file requirements and try again.', + variant: 'destructive', + }); + return; + } // eslint-disable-next-line no-console console.log(e); toast({ diff --git a/app/(dashboard)/dashboard/participants/_components/ParticipantModal.tsx b/app/dashboard/participants/_components/ParticipantModal.tsx similarity index 69% rename from app/(dashboard)/dashboard/participants/_components/ParticipantModal.tsx rename to app/dashboard/participants/_components/ParticipantModal.tsx index 5095c6d4..60db71ca 100644 --- a/app/(dashboard)/dashboard/participants/_components/ParticipantModal.tsx +++ b/app/dashboard/participants/_components/ParticipantModal.tsx @@ -1,7 +1,14 @@ 'use client'; -import { type Dispatch, type SetStateAction, useState, useEffect } from 'react'; +import { createId } from '@paralleldrive/cuid2'; +import type { Participant } from '@prisma/client'; +import { HelpCircle, Loader2 } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useEffect, useState, type Dispatch, type SetStateAction } from 'react'; import { z } from 'zod'; +import { createParticipant, updateParticipant } from '~/actions/participants'; +import ActionError from '~/components/ActionError'; +import InfoTooltip from '~/components/InfoTooltip'; import { Button } from '~/components/ui/Button'; import { Input } from '~/components/ui/Input'; import { @@ -11,20 +18,13 @@ import { DialogHeader, DialogTitle, } from '~/components/ui/dialog'; +import Heading from '~/components/ui/typography/Heading'; +import Paragraph from '~/components/ui/typography/Paragraph'; import useZodForm from '~/hooks/useZodForm'; -import ActionError from '~/components/ActionError'; -import { api } from '~/trpc/client'; import { participantIdentifierSchema, participantLabelSchema, -} from '~/shared/schemas/schemas'; -import type { Participant } from '@prisma/client'; -import { useRouter } from 'next/navigation'; -import InfoTooltip from '~/components/InfoTooltip'; -import { HelpCircle } from 'lucide-react'; -import Heading from '~/components/ui/typography/Heading'; -import Paragraph from '~/components/ui/typography/Paragraph'; -import { createId } from '@paralleldrive/cuid2'; +} from '~/schemas/participant'; type ParticipantModalProps = { open: boolean; @@ -42,103 +42,33 @@ function ParticipantModal({ existingParticipants, }: ParticipantModalProps) { const [error, setError] = useState(null); - const utils = api.useUtils(); + const [working, setWorking] = useState(false); + const router = useRouter(); const formSchema = z .object({ identifier: participantIdentifierSchema, label: participantLabelSchema, }) .refine( - (data) => - !existingParticipants?.find((p) => p.identifier === data.identifier), + (data) => { + const existingParticipant = existingParticipants.find( + (p) => p.identifier === data.identifier + ); + // Allow the current identifier if editing + return ( + !existingParticipant || + (editingParticipant && existingParticipant.id === editingParticipant.id) + ); + }, { path: ['identifier'], message: 'This identifier is already in use.', }, ); - type ValidationSchema = z.infer; - - const { mutateAsync: updateParticipant } = api.participant.update.useMutation( - { - async onMutate({ identifier, data }) { - await utils.participant.get.all.cancel(); - - // snapshot current participants - const previousValue = utils.participant.get.all.getData(); - - // Optimistically update to the new value - const newValue = previousValue?.map((p) => - p.identifier === identifier ? { ...p, ...data } : p, - ); - - utils.participant.get.all.setData(undefined, newValue); - - setOpen(false); - - return { previousValue }; - }, - onSuccess() { - router.refresh(); - }, - onError(error, _, context) { - utils.participant.get.all.setData(undefined, context?.previousValue); - setError(error.message); - }, - async onSettled() { - await utils.participant.get.all.invalidate(); - }, - }, - ); - - const router = useRouter(); - - const { mutateAsync: createParticipant } = api.participant.create.useMutation( - { - async onMutate(participantsData) { - const participants = participantsData.map((p) => ({ - ...p, - label: p.label ?? null, - })); - await utils.participant.get.all.cancel(); - - // snapshot current participants - const previousValue = utils.participant.get.all.getData(); - - const newParticipants = participants.map((p, index) => ({ - id: `optimistic-${index}`, - identifier: p.identifier ?? createId(), - label: p.label, - interviews: [], - _count: { - interviews: 0, - }, - })); - const newValue = previousValue - ? [...newParticipants, ...previousValue] - : newParticipants; - - // Optimistically update to the new value - utils.participant.get.all.setData(undefined, newValue); - - setOpen(false); - reset(); - return { previousValue }; - }, - onError(error, _, context) { - utils.participant.get.all.setData(undefined, context?.previousValue); - setError(error.message); - }, - async onSettled() { - await utils.participant.get.all.invalidate(); - }, - onSuccess() { - router.refresh(); - }, - }, - ); + type ValidationSchema = z.infer; const { register, @@ -153,6 +83,11 @@ function ParticipantModal({ const onSubmit = async (data: ValidationSchema) => { setError(null); + setWorking(true); + + if (data.label === '') { + data.label = undefined; + } if (editingParticipant) { await updateParticipant({ @@ -162,8 +97,17 @@ function ParticipantModal({ } if (!editingParticipant) { - await createParticipant([data]); + const result = await createParticipant([data]); + + if (result.error) { + setError(result.error); + } else { + router.refresh(); + setOpen(false); + } } + + setWorking(false); }; useEffect(() => { @@ -262,7 +206,8 @@ function ParticipantModal({ - diff --git a/app/dashboard/participants/loading.tsx b/app/dashboard/participants/loading.tsx new file mode 100644 index 00000000..d1548c68 --- /dev/null +++ b/app/dashboard/participants/loading.tsx @@ -0,0 +1,35 @@ +import ResponsiveContainer from '~/components/ResponsiveContainer'; +import { DataTableSkeleton } from '~/components/data-table/data-table-skeleton'; +import Section from '~/components/layout/Section'; +import { SettingsSectionSkeleton } from '~/components/layout/SettingsSection'; +import { ButtonSkeleton } from '~/components/ui/Button'; +import PageHeader from '~/components/ui/typography/PageHeader'; + +export default function Loading() { + return ( + <> + + + + + + + +
    + } + /> + + + +
    + +
    +
    + + ); +} diff --git a/app/(dashboard)/dashboard/participants/page.tsx b/app/dashboard/participants/page.tsx similarity index 61% rename from app/(dashboard)/dashboard/participants/page.tsx rename to app/dashboard/participants/page.tsx index 442c7a74..8ee130ff 100644 --- a/app/(dashboard)/dashboard/participants/page.tsx +++ b/app/dashboard/participants/page.tsx @@ -1,14 +1,14 @@ -import { ParticipantsTable } from '~/app/(dashboard)/dashboard/_components/ParticipantsTable/ParticipantsTable'; +import ParticipantsTable from '~/app/dashboard/_components/ParticipantsTable/ParticipantsTable'; import ResponsiveContainer from '~/components/ResponsiveContainer'; import Section from '~/components/layout/Section'; import PageHeader from '~/components/ui/typography/PageHeader'; -import { api } from '~/trpc/server'; +import { requireAppNotExpired } from '~/queries/appSettings'; +import { requirePageAuth } from '~/utils/auth'; import ImportExportSection from './_components/ExportParticipants/ImportExportSection'; -export const dynamic = 'force-dynamic'; - -const ParticipantPage = async () => { - const participants = await api.participant.get.all.query(); +export default async function ParticipantPage() { + await requireAppNotExpired(); + await requirePageAuth(); return ( <> @@ -21,11 +21,9 @@ const ParticipantPage = async () => {
    - +
    ); -}; - -export default ParticipantPage; +} diff --git a/app/(dashboard)/dashboard/protocols/_components/AnonymousRecruitmentWarning.tsx b/app/dashboard/protocols/_components/AnonymousRecruitmentWarning.tsx similarity index 73% rename from app/(dashboard)/dashboard/protocols/_components/AnonymousRecruitmentWarning.tsx rename to app/dashboard/protocols/_components/AnonymousRecruitmentWarning.tsx index 97f654b0..6f511826 100644 --- a/app/(dashboard)/dashboard/protocols/_components/AnonymousRecruitmentWarning.tsx +++ b/app/dashboard/protocols/_components/AnonymousRecruitmentWarning.tsx @@ -1,18 +1,13 @@ import { AlertCircle } from 'lucide-react'; import Link from '~/components/Link'; import ResponsiveContainer from '~/components/ResponsiveContainer'; -import { Alert, AlertTitle, AlertDescription } from '~/components/ui/Alert'; -import { api } from '~/trpc/server'; - -export const dynamic = 'force-dynamic'; +import { Alert, AlertDescription, AlertTitle } from '~/components/ui/Alert'; +import { getAnonymousRecruitmentStatus } from '~/queries/appSettings'; export default async function AnonymousRecruitmentWarning() { - const allowAnonymousRecruitment = - await api.appSettings.getAnonymousRecruitmentStatus.query(); + const allowAnonymousRecruitment = await getAnonymousRecruitmentStatus(); - if (!allowAnonymousRecruitment) { - return null; - } + if (!allowAnonymousRecruitment) return null; return ( diff --git a/app/(dashboard)/dashboard/protocols/_components/DeleteProtocolsDialog.tsx b/app/dashboard/protocols/_components/DeleteProtocolsDialog.tsx similarity index 80% rename from app/(dashboard)/dashboard/protocols/_components/DeleteProtocolsDialog.tsx rename to app/dashboard/protocols/_components/DeleteProtocolsDialog.tsx index 0ab9d7f9..8f4ca0c6 100644 --- a/app/(dashboard)/dashboard/protocols/_components/DeleteProtocolsDialog.tsx +++ b/app/dashboard/protocols/_components/DeleteProtocolsDialog.tsx @@ -1,5 +1,8 @@ -import { Loader2, AlertCircle, Trash2 } from 'lucide-react'; -import { Button } from '~/components/ui/Button'; +import { AlertCircle, Loader2, Trash2 } from 'lucide-react'; +import type { Dispatch, SetStateAction } from 'react'; +import { useEffect, useState } from 'react'; +import { deleteProtocols } from '~/actions/protocols'; +import { Alert, AlertDescription, AlertTitle } from '~/components/ui/Alert'; import { AlertDialog, AlertDialogCancel, @@ -9,12 +12,8 @@ import { AlertDialogHeader, AlertDialogTitle, } from '~/components/ui/AlertDialog'; -import { Alert, AlertDescription, AlertTitle } from '~/components/ui/Alert'; -import type { ProtocolWithInterviews } from '~/shared/types'; -import { useEffect, useState } from 'react'; -import type { Dispatch, SetStateAction } from 'react'; -import { api } from '~/trpc/client'; -import { useRouter } from 'next/navigation'; +import { Button } from '~/components/ui/Button'; +import type { ProtocolWithInterviews } from '~/types/types'; type DeleteProtocolsDialogProps = { open: boolean; @@ -27,7 +26,7 @@ export const DeleteProtocolsDialog = ({ setOpen, protocolsToDelete, }: DeleteProtocolsDialogProps) => { - const router = useRouter(); + const isDeleting = false; const [protocolsInfo, setProtocolsInfo] = useState<{ hasInterviews: boolean; @@ -47,32 +46,6 @@ export const DeleteProtocolsDialog = ({ }); }, [protocolsToDelete]); - const utils = api.useUtils(); - - const { mutateAsync: deleteProtocols, isLoading: isDeleting } = - api.protocol.delete.byHash.useMutation({ - async onMutate(hashes) { - await utils.protocol.get.all.cancel(); - - // snapshot current protocols - const previousValue = utils.protocol.get.all.getData(); - - // Optimistically update to the new value - const newValue = previousValue?.filter((p) => !hashes.includes(p.hash)); - - utils.protocol.get.all.setData(undefined, newValue); - - return { previousValue }; - }, - onSuccess() { - router.refresh(); - }, - onError(error, hashes, context) { - utils.protocol.get.all.setData(undefined, context?.previousValue); - throw new Error(error.message); - }, - }); - const handleConfirm = async () => { await deleteProtocols(protocolsToDelete.map((d) => d.hash)); setOpen(false); diff --git a/app/dashboard/protocols/loading.tsx b/app/dashboard/protocols/loading.tsx new file mode 100644 index 00000000..c5b8fbd9 --- /dev/null +++ b/app/dashboard/protocols/loading.tsx @@ -0,0 +1,22 @@ +import ResponsiveContainer from '~/components/ResponsiveContainer'; +import { DataTableSkeleton } from '~/components/data-table/data-table-skeleton'; +import Section from '~/components/layout/Section'; +import PageHeader from '~/components/ui/typography/PageHeader'; + +export default function Loading() { + return ( + <> + + + + +
    + +
    +
    + + ); +} diff --git a/app/dashboard/protocols/page.tsx b/app/dashboard/protocols/page.tsx new file mode 100644 index 00000000..59577616 --- /dev/null +++ b/app/dashboard/protocols/page.tsx @@ -0,0 +1,27 @@ +import ResponsiveContainer from '~/components/ResponsiveContainer'; +import Section from '~/components/layout/Section'; +import PageHeader from '~/components/ui/typography/PageHeader'; +import { requireAppNotExpired } from '~/queries/appSettings'; +import { requirePageAuth } from '~/utils/auth'; +import ProtocolsTable from '../_components/ProtocolsTable/ProtocolsTable'; + +export default async function ProtocolsPage() { + await requireAppNotExpired(); + await requirePageAuth(); + + return ( + <> + + + + +
    + +
    +
    + + ); +} diff --git a/app/dashboard/settings/loading.tsx b/app/dashboard/settings/loading.tsx new file mode 100644 index 00000000..b8a488ac --- /dev/null +++ b/app/dashboard/settings/loading.tsx @@ -0,0 +1,46 @@ +import ResponsiveContainer from '~/components/ResponsiveContainer'; +import { SettingsSectionSkeleton } from '~/components/layout/SettingsSection'; +import { ButtonSkeleton } from '~/components/ui/Button'; +import { Skeleton } from '~/components/ui/skeleton'; +import { SwitchSkeleton } from '~/components/ui/switch'; +import PageHeader from '~/components/ui/typography/PageHeader'; +import { env } from '~/env'; + +export default function Loading() { + return ( + <> + + + + + + } + /> + } /> + } /> + {!env.SANDBOX_MODE && ( + + } + /> + )} + + {env.NODE_ENV === 'development' && ( + <> + } + /> + } + /> + + )} + + + ); +} diff --git a/app/(dashboard)/dashboard/settings/page.tsx b/app/dashboard/settings/page.tsx similarity index 78% rename from app/(dashboard)/dashboard/settings/page.tsx rename to app/dashboard/settings/page.tsx index eab21622..c931344e 100644 --- a/app/(dashboard)/dashboard/settings/page.tsx +++ b/app/dashboard/settings/page.tsx @@ -1,22 +1,21 @@ +import { Suspense } from 'react'; +import AnonymousRecruitmentSwitch from '~/components/AnonymousRecruitmentSwitch'; +import LimitInterviewsSwitch from '~/components/LimitInterviewsSwitch'; import ResponsiveContainer from '~/components/ResponsiveContainer'; +import VersionSection from '~/components/VersionSection'; +import SettingsSection from '~/components/layout/SettingsSection'; import PageHeader from '~/components/ui/typography/PageHeader'; import Paragraph from '~/components/ui/typography/Paragraph'; -import ResetButton from '../_components/ResetButton'; +import { env } from '~/env'; +import { getInstallationId, requireAppNotExpired } from '~/queries/appSettings'; +import { requirePageAuth } from '~/utils/auth'; import AnalyticsButton from '../_components/AnalyticsButton'; -import RecruitmentTestSection from '../_components/RecruitmentTestSection'; -import SettingsSection from '~/components/layout/SettingsSection'; -import AnonymousRecruitmentSwitch from '~/components/ServerAnonymousRecruitmentSwitch/AnonymousRecruitmentSwitch'; -import LimitInterviewsSwitch from '~/components/LimitInterviewsSwitch/LimitInterviewsSwitch'; -import { api } from '~/trpc/server'; -import VersionSection from '~/components/VersionSection'; -import { env } from '~/env.mjs'; -import { getInstallationId } from '~/analytics/utils'; - -export const dynamic = 'force-dynamic'; +import RecruitmentTestSectionServer from '../_components/RecruitmentTestSectionServer'; +import ResetButton from '../_components/ResetButton'; export default async function Settings() { - const allowAnonymousRecruitment = - await api.appSettings.getAnonymousRecruitmentStatus.query(); + await requireAppNotExpired(); + await requirePageAuth(); const installationIdPromise = getInstallationId(); @@ -33,9 +32,9 @@ export default async function Settings() { + + + } > @@ -46,7 +45,11 @@ export default async function Settings() { } + controlArea={ + + + + } > If this option is enabled, each participant will only be able to @@ -79,9 +82,7 @@ export default async function Settings() { server. - + )}
    diff --git a/app/layout.tsx b/app/layout.tsx index 6be28a6b..52ba3588 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,10 +1,5 @@ -import { revalidatePath, revalidateTag } from 'next/cache'; -import RedirectWrapper from '~/components/RedirectWrapper'; import { Toaster } from '~/components/ui/toaster'; import '~/styles/globals.scss'; -import { api } from '~/trpc/server'; -import { getServerSession } from '~/utils/auth'; -import Providers from '../providers/Providers'; import { Quicksand } from 'next/font/google'; export const metadata = { @@ -12,38 +7,18 @@ export const metadata = { description: 'Fresco.', }; -const poppins = Quicksand({ +const quicksand = Quicksand({ weight: ['300', '400', '500', '600', '700'], subsets: ['latin', 'latin-ext'], display: 'swap', }); -export const dynamic = 'force-dynamic'; - -async function RootLayout({ children }: { children: React.ReactNode }) { - const session = await getServerSession(); - const appSettings = await api.appSettings.get.query(); - - // If this is the first run, app settings must be created - if (!appSettings) { - await api.appSettings.create.mutate(); - revalidateTag('appSettings.get'); - revalidatePath('/'); - } - +function RootLayout({ children }: { children: React.ReactNode }) { return ( - - - - {children} - - - + + {children} + ); diff --git a/app/page.tsx b/app/page.tsx index 6cb4a614..f889cb61 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,7 +1,5 @@ +import { redirect } from 'next/navigation'; + export default function Home() { - return ( -
    -

    Loading...

    -
    - ); + redirect('/dashboard'); } diff --git a/app/reset/route.ts b/app/reset/route.ts index 074b8b48..cd14d6b1 100644 --- a/app/reset/route.ts +++ b/app/reset/route.ts @@ -1,5 +1,5 @@ import { revalidatePath, revalidateTag } from 'next/cache'; -import { getBaseUrl } from '~/trpc/shared'; +import { redirect } from 'next/navigation'; /** * @@ -7,7 +7,7 @@ import { getBaseUrl } from '~/trpc/shared'; * data (such as appSettings, or session) that cannot be cleared by the user. * * For example, if a database is wiped outside of the app, app settings won't - * be refetched automatically because they are addressively cached. This can + * be refetched automatically because they are aggressively cached. This can * cause issues such as being redirected to the login screen, even though the * app is unconfigured and there are no users. * @@ -16,11 +16,11 @@ import { getBaseUrl } from '~/trpc/shared'; */ export function GET() { revalidatePath('/'); - revalidateTag('appSettings.get'); - revalidateTag('appSettings.getAnonymousRecruitmentStatus'); - revalidateTag('interview.get.all'); - revalidateTag('participant.get.all'); - revalidateTag('dashboard.getActivities'); + revalidateTag('appSettings'); + revalidateTag('getInterviews'); + revalidateTag('getParticipants'); + revalidateTag('getProtocols'); + revalidateTag('activityFeed'); - return Response.redirect(getBaseUrl()); + redirect('/'); } diff --git a/auth.d.ts b/auth.d.ts index cd6f8a86..45e9b163 100644 --- a/auth.d.ts +++ b/auth.d.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/consistent-type-imports */ /// declare namespace Lucia { type Auth = import('./utils/auth').Auth; diff --git a/components/AnonymousRecruitmentSwitch.tsx b/components/AnonymousRecruitmentSwitch.tsx new file mode 100644 index 00000000..4981c4e1 --- /dev/null +++ b/components/AnonymousRecruitmentSwitch.tsx @@ -0,0 +1,16 @@ +import { getAnonymousRecruitmentStatus } from '~/queries/appSettings'; +import SwitchWithOptimisticUpdate from './SwitchWithOptimisticUpdate'; +import { setAnonymousRecruitment } from '~/actions/appSettings'; + +const AnonymousRecruitmentSwitch = async () => { + const allowAnonymousRecruitment = await getAnonymousRecruitmentStatus(); + return ( + + ); +}; + +export default AnonymousRecruitmentSwitch; diff --git a/components/AnonymousRecruitmentSwitchClient.tsx b/components/AnonymousRecruitmentSwitchClient.tsx new file mode 100644 index 00000000..16e3f6cc --- /dev/null +++ b/components/AnonymousRecruitmentSwitchClient.tsx @@ -0,0 +1,19 @@ +'use client'; +import SwitchWithOptimisticUpdate from './SwitchWithOptimisticUpdate'; +import { setAnonymousRecruitment } from '~/actions/appSettings'; + +const AnonymousRecruitmentSwitchClient = ({ + allowAnonymousRecruitment, +}: { + allowAnonymousRecruitment: boolean; +}) => { + return ( + + ); +}; + +export default AnonymousRecruitmentSwitchClient; diff --git a/components/BackgroundBlobs/BackgroundBlobs.stories.js b/components/BackgroundBlobs/BackgroundBlobs.stories.js deleted file mode 100644 index c4823080..00000000 --- a/components/BackgroundBlobs/BackgroundBlobs.stories.js +++ /dev/null @@ -1,120 +0,0 @@ -import BackgroundBlobs from '../src/components/art/BackgroundBlobs'; -import '../src/styles/_all.scss'; - -export default { title: 'Art/Background Blobs' }; - -export const normal = () => ( -
    - -
    -); - -export const fullScreen = () => ( -
    - -
    -); - -export const small = () => ( -
    - -
    -); - -export const customNumbers = () => ( -
    -
    -

    Loadsa large

    -
    - -
    -
    -
    -

    Twenty Small

    -
    - -
    -
    -
    -

    Medium and Small

    -
    - -
    -
    -
    -); diff --git a/components/BackgroundBlobs/BackgroundBlobs.tsx b/components/BackgroundBlobs/BackgroundBlobs.tsx index 424e4040..540b5e9f 100644 --- a/components/BackgroundBlobs/BackgroundBlobs.tsx +++ b/components/BackgroundBlobs/BackgroundBlobs.tsx @@ -1,9 +1,9 @@ 'use client'; -import { memo, useMemo } from 'react'; import * as blobs2 from 'blobs/v2'; import { interpolatePath as interpolate } from 'd3-interpolate-path'; -import { random, randomInt } from '~/utils/lodash-replacements'; +import { memo, useMemo } from 'react'; +import { random, randomInt } from '~/utils/general'; import Canvas from './Canvas'; const gradients = [ diff --git a/components/ContainerClasses.ts b/components/ContainerClasses.ts new file mode 100644 index 00000000..057b65b7 --- /dev/null +++ b/components/ContainerClasses.ts @@ -0,0 +1,6 @@ +import { cn } from '~/utils/shadcn'; + +export const containerClasses = cn( + 'relative mt-[-60px] flex flex-col rounded-xl min-w-full-[30rem] bg-card p-8', + 'after:absolute after:inset-[-20px] after:z-[-1] after:rounded-3xl after:bg-panel/30 after:shadow-2xl after:backdrop-blur-sm', +); diff --git a/components/ErrorReportNotifier.tsx b/components/ErrorReportNotifier.tsx index 5a862599..6c08843a 100644 --- a/components/ErrorReportNotifier.tsx +++ b/components/ErrorReportNotifier.tsx @@ -1,7 +1,7 @@ import { AnimatePresence, motion } from 'framer-motion'; import { CheckIcon, Loader2, XCircle } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; -import { trackEvent } from '~/analytics/utils'; +import trackEvent from '~/lib/analytics'; const labelAnimationVariants = { hidden: { opacity: 0, y: '-100%' }, @@ -11,7 +11,7 @@ const labelAnimationVariants = { type ReportStates = 'idle' | 'loading' | 'success' | 'error'; -export function ReportNotifier({ state = 'idle' }: { state?: ReportStates }) { +function ReportNotifier({ state = 'idle' }: { state?: ReportStates }) { return (
    diff --git a/components/Feedback/SignOutModal.tsx b/components/Feedback/SignOutModal.tsx index 8ad9e518..5be33822 100644 --- a/components/Feedback/SignOutModal.tsx +++ b/components/Feedback/SignOutModal.tsx @@ -1,3 +1,5 @@ +'use client'; + import { type Dispatch, type SetStateAction } from 'react'; import { AlertDialog, @@ -9,7 +11,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from '../ui/AlertDialog'; -import { api } from '~/trpc/client'; +import { logout } from '~/actions/auth'; type SignOutModalProps = { openSignOutModal: boolean; @@ -20,13 +22,6 @@ const SignOutModal = ({ openSignOutModal, setOpenSignOutModal, }: SignOutModalProps) => { - const utils = api.useUtils(); - const { mutateAsync: signOut } = api.session.signOut.useMutation({ - onSuccess: async () => { - await utils.session.get.invalidate(); - }, - }); - return ( @@ -44,7 +39,7 @@ const SignOutModal = ({ setOpenSignOutModal(false)}> Cancel - void signOut()}> + void logout()}> Sign Out and Hide Banner diff --git a/components/InterviewCard.tsx b/components/InterviewCard.tsx deleted file mode 100644 index 97bdbd0e..00000000 --- a/components/InterviewCard.tsx +++ /dev/null @@ -1,37 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import type { Prisma } from '@prisma/client'; - -type Props = { - interview: Prisma.InterviewGetPayload<{ - include: { protocol: true; user: { select: { name: true } } }; - }>; -}; - -const InterviewCard = ({ interview }: Props) => { - const { - id, - protocol: { name: protocolName }, - lastUpdated, - user: { name: userName }, - } = interview; - - return ( - -
    ID: {id}
    -
    - Protocol: {protocolName} -
    -
    User: {userName}
    -
    - Last Updated: {lastUpdated.toDateString()} -
    - - ); -}; - -export default InterviewCard; diff --git a/components/LimitInterviewsSwitch.tsx b/components/LimitInterviewsSwitch.tsx index e15efb68..f1c5948d 100644 --- a/components/LimitInterviewsSwitch.tsx +++ b/components/LimitInterviewsSwitch.tsx @@ -1,52 +1,16 @@ -'use client'; +import 'server-only'; +import Switch from './SwitchWithOptimisticUpdate'; +import { setLimitInterviews } from '~/actions/appSettings'; +import { getLimitInterviewsStatus } from '~/queries/appSettings'; -import { api } from '~/trpc/client'; -import { Switch } from './ui/switch'; -import { clientRevalidateTag } from '~/utils/clientRevalidate'; - -const LimitInterviewsSwitch = () => { - const { data: appSettings, isLoading } = api.appSettings.get.useQuery( - undefined, - {}, - ); - - const utils = api.useUtils(); - - const { mutate: updateLimitInterviews } = - api.appSettings.updateLimitInterviews.useMutation({ - onMutate: async (limitInterviews: boolean) => { - await utils.appSettings.get.cancel(); - - const appSettingsGetAll = utils.appSettings.get.getData(); - - if (!appSettingsGetAll) { - return; - } - - utils.appSettings.get.setData(undefined, { - ...appSettingsGetAll, - limitInterviews, - }); - - return { appSettingsGetAll }; - }, - onSettled: () => { - void utils.appSettings.get.invalidate(); - void clientRevalidateTag('appSettings.get'); - }, - onError: (_error, _limitInterviews, context) => { - utils.appSettings.get.setData(undefined, context?.appSettingsGetAll); - }, - }); +const LimitInterviewsSwitch = async () => { + const limitInterviews = await getLimitInterviewsStatus(); return ( { - updateLimitInterviews(value); - }} + action={setLimitInterviews} /> ); }; diff --git a/components/LimitInterviewsSwitch/LimitInterviewsSwitch.tsx b/components/LimitInterviewsSwitch/LimitInterviewsSwitch.tsx deleted file mode 100644 index 09a475db..00000000 --- a/components/LimitInterviewsSwitch/LimitInterviewsSwitch.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import 'server-only'; -import { api } from '~/trpc/server'; -import Switch from './Switch'; - -export const dynamic = 'force-dynamic'; - -const LimitInterviewsSwitch = async () => { - const limitInterviews = - await api.appSettings.getLimitInterviewsStatus.query(); - - return ; -}; - -export default LimitInterviewsSwitch; diff --git a/components/LimitInterviewsSwitch/Switch.tsx b/components/LimitInterviewsSwitch/Switch.tsx deleted file mode 100644 index b9b3d1e5..00000000 --- a/components/LimitInterviewsSwitch/Switch.tsx +++ /dev/null @@ -1,29 +0,0 @@ -'use client'; - -import { Switch as SwitchUI } from '~/components/ui/switch'; -import { useOptimistic, useTransition } from 'react'; -import { setLimitInterviews } from './utils'; - -const Switch = ({ limitInterviews }: { limitInterviews: boolean }) => { - const [, startTransition] = useTransition(); - const [optimisticLimitInterviews, setOptimisticLimitInterviews] = - useOptimistic( - limitInterviews, - (state: boolean, newState: boolean) => newState, - ); - - return ( - { - startTransition(async () => { - setOptimisticLimitInterviews(value); - await setLimitInterviews(value); - }); - }} - /> - ); -}; - -export default Switch; diff --git a/components/LimitInterviewsSwitch/utils.ts b/components/LimitInterviewsSwitch/utils.ts deleted file mode 100644 index 4310c4eb..00000000 --- a/components/LimitInterviewsSwitch/utils.ts +++ /dev/null @@ -1,9 +0,0 @@ -'use server'; - -import { api } from '~/trpc/server'; - -export async function setLimitInterviews(state: boolean) { - const result = await api.appSettings.updateLimitInterviews.mutate(state); - - return result; -} diff --git a/components/LimitInterviewsSwitchClient.tsx b/components/LimitInterviewsSwitchClient.tsx new file mode 100644 index 00000000..2171b119 --- /dev/null +++ b/components/LimitInterviewsSwitchClient.tsx @@ -0,0 +1,18 @@ +import Switch from './SwitchWithOptimisticUpdate'; +import { setLimitInterviews } from '~/actions/appSettings'; + +const LimitInterviewsSwitchClient = ({ + limitInterviews, +}: { + limitInterviews: boolean; +}) => { + return ( + + ); +}; + +export default LimitInterviewsSwitchClient; diff --git a/components/ProtocolCard.tsx b/components/ProtocolCard.tsx deleted file mode 100644 index 2933cf49..00000000 --- a/components/ProtocolCard.tsx +++ /dev/null @@ -1,45 +0,0 @@ -'use client'; - -import type { Prisma } from '@prisma/client'; -import Image from 'next/image'; -import Link from 'next/link'; - -type Props = { - protocol: Prisma.ProtocolGetPayload; -}; - -const ProtocolCard = ({ protocol }: Props) => { - const { id, name, description } = protocol; - - return ( - -
    -
    -
    {name}
    -
    - {description} -
    -
    -
    -
    -
      -
    • Installed: 12/45/2344, 3:31pm
    • -
    • Last Modified: 12/45/2344, 3:31pm
    • -
    • Schema Version: 7
    • -
    -
    -
    - protocol card -
    -
    -
    - - ); -}; - -export default ProtocolCard; diff --git a/components/ProtocolImport/JobReducer.ts b/components/ProtocolImport/JobReducer.ts index e55aadf0..8bd82a8b 100644 --- a/components/ProtocolImport/JobReducer.ts +++ b/components/ProtocolImport/JobReducer.ts @@ -1,6 +1,6 @@ -import { trackEvent } from '~/analytics/utils'; +import trackEvent from '~/lib/analytics'; -export const importStatuses = [ +const importStatuses = [ 'Queued', 'Extracting protocol', 'Validating protocol', @@ -11,7 +11,7 @@ export const importStatuses = [ type ImportStatus = (typeof importStatuses)[number]; -export type ErrorState = { +type ErrorState = { title: string; description: React.ReactNode; additionalContent?: React.ReactNode; @@ -150,6 +150,6 @@ export function jobReducer(state: ImportJob[], action: Action) { }); } default: - throw new Error('Unknown error occured'); + throw new Error('Unknown error occurred'); } } diff --git a/components/RecruitmentSwitch.tsx b/components/RecruitmentSwitch.tsx deleted file mode 100644 index c3aeae6b..00000000 --- a/components/RecruitmentSwitch.tsx +++ /dev/null @@ -1,54 +0,0 @@ -'use client'; - -import { api } from '~/trpc/client'; -import { Switch } from './ui/switch'; -import { clientRevalidateTag } from '~/utils/clientRevalidate'; - -const RecruitmentSwitch = () => { - const { data: appSettings, isLoading } = api.appSettings.get.useQuery( - undefined, - {}, - ); - - const utils = api.useUtils(); - - const { mutate: updateAnonymousRecruitment } = - api.appSettings.updateAnonymousRecruitment.useMutation({ - onMutate: async (allowAnonymousRecruitment: boolean) => { - await utils.appSettings.get.cancel(); - - const appSettingsGetAll = utils.appSettings.get.getData(); - - if (!appSettingsGetAll) { - return; - } - - utils.appSettings.get.setData(undefined, { - ...appSettingsGetAll, - allowAnonymousRecruitment, - }); - - return { appSettingsGetAll }; - }, - onSettled: () => { - void utils.appSettings.get.invalidate(); - void clientRevalidateTag('appSettings.get'); - }, - onError: (_error, _allowAnonymousRecruitment, context) => { - utils.appSettings.get.setData(undefined, context?.appSettingsGetAll); - }, - }); - - return ( - { - updateAnonymousRecruitment(value); - }} - /> - ); -}; - -export default RecruitmentSwitch; diff --git a/components/RedirectWrapper.tsx b/components/RedirectWrapper.tsx deleted file mode 100644 index 2543dd6c..00000000 --- a/components/RedirectWrapper.tsx +++ /dev/null @@ -1,47 +0,0 @@ -'use client'; - -import type { Session } from 'lucia'; -import type { Route } from 'next'; -import { usePathname, redirect, useSearchParams } from 'next/navigation'; -import { calculateRedirect } from '~/utils/calculateRedirectedRoutes'; - -/** - * - * This wrapper component determines if we need to redirect based on if the - * user is logged in, if the app is configured, and if the configuration window - * is expired. - * - * Initially implemented within the root layout, but this caused maximum update - * depth exceeded errors for unknown reasons. - * - * Logic for redirection is in utils/calculateRedirectedRoutes.ts - */ - -export default function RedirectWrapper({ - session, - children, - configured, - expired, -}: { - session: Session | null; - children: React.ReactNode; - configured: boolean; - expired: boolean; -}) { - const path = usePathname() as Route; - const searchParams = useSearchParams(); - - const shouldRedirect = calculateRedirect({ - session, - path, - searchParams, - expired, - configured, - }); - - if (shouldRedirect) { - redirect(shouldRedirect); - } - - return children; -} diff --git a/components/ResponsiveContainer.tsx b/components/ResponsiveContainer.tsx index d89a2c98..0a25cc19 100644 --- a/components/ResponsiveContainer.tsx +++ b/components/ResponsiveContainer.tsx @@ -24,7 +24,7 @@ const containerVariants = cva('mx-auto flex flex-col my-6 md:my-10 ', { }, }); -export type ContainerProps = { +type ContainerProps = { maxWidth?: VariantProps['maxWidth']; baseSize?: VariantProps['baseSize']; } & HTMLAttributes; diff --git a/components/ServerAnonymousRecruitmentSwitch/AnonymousRecruitmentSwitch.tsx b/components/ServerAnonymousRecruitmentSwitch/AnonymousRecruitmentSwitch.tsx deleted file mode 100644 index c1c33c90..00000000 --- a/components/ServerAnonymousRecruitmentSwitch/AnonymousRecruitmentSwitch.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import 'server-only'; -import Switch from './Switch'; - -const AnonymousRecruitmentSwitch = ({ - allowAnonymousRecruitment, -}: { - allowAnonymousRecruitment: boolean; -}) => { - return ; -}; - -export default AnonymousRecruitmentSwitch; diff --git a/components/ServerAnonymousRecruitmentSwitch/Switch.tsx b/components/ServerAnonymousRecruitmentSwitch/Switch.tsx deleted file mode 100644 index 50a853a1..00000000 --- a/components/ServerAnonymousRecruitmentSwitch/Switch.tsx +++ /dev/null @@ -1,35 +0,0 @@ -'use client'; - -import { Switch as SwitchUI } from '~/components/ui/switch'; -import { useOptimistic, useTransition } from 'react'; -import { setAnonymousRecruitment } from './utils'; - -const Switch = ({ - allowAnonymousRecruitment, -}: { - allowAnonymousRecruitment: boolean; -}) => { - const [, startTransition] = useTransition(); - const [ - optimisticAllowAnonymousRecruitment, - setOptimisticAllowAnonymousRecruitment, - ] = useOptimistic( - allowAnonymousRecruitment, - (state: boolean, newState: boolean) => newState, - ); - - return ( - { - startTransition(async () => { - setOptimisticAllowAnonymousRecruitment(value); - await setAnonymousRecruitment(value); - }); - }} - /> - ); -}; - -export default Switch; diff --git a/components/ServerAnonymousRecruitmentSwitch/utils.ts b/components/ServerAnonymousRecruitmentSwitch/utils.ts deleted file mode 100644 index 3f15b512..00000000 --- a/components/ServerAnonymousRecruitmentSwitch/utils.ts +++ /dev/null @@ -1,9 +0,0 @@ -'use server'; - -import { api } from '~/trpc/server'; - -export async function setAnonymousRecruitment(state: boolean) { - const result = await api.appSettings.updateAnonymousRecruitment.mutate(state); - - return result; -} diff --git a/components/SwitchWithOptimisticUpdate.tsx b/components/SwitchWithOptimisticUpdate.tsx new file mode 100644 index 00000000..56ddfa70 --- /dev/null +++ b/components/SwitchWithOptimisticUpdate.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { Switch as SwitchUI } from '~/components/ui/switch'; +import { useOptimistic, useTransition } from 'react'; + +const SwitchWithOptimisticUpdate = ({ + initialValue, + name, + action, +}: { + initialValue: boolean; + name: string; + action: (value: boolean) => Promise; +}) => { + const [isTransitioning, startTransition] = useTransition(); + const [optimisticIsActive, setOptimisticIsActive] = useOptimistic( + initialValue, + (_, newValue: boolean) => newValue, + ); + + const updateIsActive = async (newValue: boolean) => { + setOptimisticIsActive(newValue); + await action(newValue); // this is a server action which calls `revalidateTag` + }; + + return ( + + startTransition(() => updateIsActive(checked)) + } + disabled={isTransitioning} + /> + ); +}; + +export default SwitchWithOptimisticUpdate; diff --git a/components/VersionSection.tsx b/components/VersionSection.tsx index 298483a9..4e31c4d1 100644 --- a/components/VersionSection.tsx +++ b/components/VersionSection.tsx @@ -1,13 +1,13 @@ 'use client'; -import { env } from '~/env.mjs'; +import { CheckCircle2, Info, Loader2, XCircle } from 'lucide-react'; +import { use, useEffect, useState } from 'react'; +import { z } from 'zod'; +import { env } from '~/env'; +import { type getInstallationId } from '~/queries/appSettings'; +import { ensureError } from '~/utils/ensureError'; import SettingsSection from './layout/SettingsSection'; import Paragraph from './ui/typography/Paragraph'; -import { useQuery } from '@tanstack/react-query'; -import { z } from 'zod'; -import { CheckCircle2, Info, Loader2, XCircle } from 'lucide-react'; -import { use } from 'react'; -import { getInstallationId } from '~/analytics/utils'; const GithubApiResponseSchema = z.object({ status: z.string(), @@ -15,39 +15,46 @@ const GithubApiResponseSchema = z.object({ behind_by: z.number(), }); -// Use the github API to compare the current COMMIT_HASH against the head of the repo -const checkIfUpdateAvailable = async () => { - try { - const res = await fetch( - `https://api.github.com/repos/complexdatacollective/fresco/compare/${env.COMMIT_HASH}...main`, - ); - const raw = await res.json(); - - const data = GithubApiResponseSchema.parse(raw); +export default function VersionSection({ + installationIdPromise, +}: { + installationIdPromise: ReturnType; +}) { + const installationID = use(installationIdPromise); - return { - upToDate: data.status === 'identical' || data.status === 'behind', - aheadBy: data.ahead_by, - behindBy: data.behind_by, - error: null, - }; - } catch (e) { - return { - upToDate: null, - aheadBy: null, - behindBy: null, - error: e, - }; - } -}; + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState<{ + upToDate: boolean; + aheadBy: number; + behindBy: number; + } | null>(null); -export default function VersionSection({ installationIdPromise}: { installationIdPromise: ReturnType }) { - const { isLoading, data, isError } = useQuery({ - queryKey: ['repoData'], - queryFn: checkIfUpdateAvailable, - }); + useEffect(() => { + setIsLoading(true); + fetch( + `https://api.github.com/repos/complexdatacollective/fresco/compare/${env.COMMIT_HASH}...main`, + ) + .then((res) => res.json()) + .then( + (result: unknown) => { + setIsLoading(false); - const installationID = use(installationIdPromise); + const response = GithubApiResponseSchema.parse(result); + setData({ + upToDate: + response.status === 'identical' || response.status === 'behind', + aheadBy: response.ahead_by, + behindBy: response.behind_by, + }); + }, + (error) => { + const e = ensureError(error); + setIsLoading(false); + setError(e.message); + }, + ); + }, []); return ( )} - {data?.upToDate === false && ( + {!isLoading && data?.upToDate === false && (
    )} - {data?.upToDate === true && ( + {!isLoading && data?.upToDate === true && (
    @@ -84,11 +91,11 @@ export default function VersionSection({ installationIdPromise}: { installation
    )} - {isError && ( + {error && (
    @@ -100,11 +107,10 @@ export default function VersionSection({ installationIdPromise}: { installation } > - You are currently running Fresco v.{env.APP_VERSION} ({env.COMMIT_HASH}). - - - Your unique installation ID is: {installationID} + You are currently running Fresco v.{env.APP_VERSION} ({env.COMMIT_HASH} + ). + Your unique installation ID is: {installationID} ); } diff --git a/components/data-table/advanced/data-table-advanced-filter.tsx b/components/data-table/advanced/data-table-advanced-filter.tsx index 5a74b55f..3ecd9ac5 100644 --- a/components/data-table/advanced/data-table-advanced-filter.tsx +++ b/components/data-table/advanced/data-table-advanced-filter.tsx @@ -1,5 +1,3 @@ -'use client'; - import { ChevronDown, ChevronsUpDown, Plus, TextIcon } from 'lucide-react'; import * as React from 'react'; diff --git a/components/data-table/data-table-faceted-filter.tsx b/components/data-table/data-table-faceted-filter.tsx index 649edbda..a6c473ea 100644 --- a/components/data-table/data-table-faceted-filter.tsx +++ b/components/data-table/data-table-faceted-filter.tsx @@ -1,6 +1,6 @@ import { type Column } from '@tanstack/react-table'; import { Check, PlusCircle } from 'lucide-react'; -import { getBadgeColorsForActivityType } from '~/app/(dashboard)/dashboard/_components/ActivityFeed/utils'; +import { getBadgeColorsForActivityType } from '~/app/dashboard/_components/ActivityFeed/utils'; import { Badge } from '~/components/ui/badge'; import { Button } from '~/components/ui/Button'; import { diff --git a/components/data-table/data-table-toolbar.tsx b/components/data-table/data-table-toolbar.tsx index 41e20dae..77b430c0 100644 --- a/components/data-table/data-table-toolbar.tsx +++ b/components/data-table/data-table-toolbar.tsx @@ -1,18 +1,18 @@ 'use client'; -import * as React from 'react'; -import Link from 'next/link'; import type { Table } from '@tanstack/react-table'; +import { PlusCircle, Trash, X } from 'lucide-react'; +import Link from 'next/link'; +import * as React from 'react'; +import { type UrlObject } from 'url'; +import { DataTableFacetedFilter } from '~/components/data-table/data-table-faceted-filter'; import { Button, buttonVariants } from '~/components/ui/Button'; import { Input } from '~/components/ui/Input'; -import { DataTableFacetedFilter } from '~/components/data-table/data-table-faceted-filter'; import { type DataTableFilterableColumn, type DataTableSearchableColumn, } from '~/lib/data-table/types'; -import { PlusCircle, Trash, X } from 'lucide-react'; import { cn } from '~/utils/shadcn'; -import { type UrlObject } from 'url'; type DataTableToolbarProps = { table: Table; @@ -29,7 +29,7 @@ export function DataTableToolbar({ newRowLink, deleteRowsAction, }: DataTableToolbarProps) { - const isFiltered = table.getState().columnFilters.length > 0; + const isFiltered = table.getState().columnFilters?.length > 0 ?? false; const [isPending, startTransition] = React.useTransition(); return ( @@ -119,4 +119,4 @@ export function DataTableToolbar({
    ); -} +} \ No newline at end of file diff --git a/components/data-table/data-table-view-options.tsx b/components/data-table/data-table-view-options.tsx deleted file mode 100644 index 461faf96..00000000 --- a/components/data-table/data-table-view-options.tsx +++ /dev/null @@ -1,59 +0,0 @@ -'use client'; - -import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'; -import { type Table } from '@tanstack/react-table'; -import { SlidersHorizontal } from 'lucide-react'; -import { Button } from '~/components/ui/Button'; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuSeparator, -} from '~/components/ui/dropdown-menu'; - -type DataTableViewOptionsProps = { - table: Table; -}; - -export function DataTableViewOptions({ - table, -}: DataTableViewOptionsProps) { - return ( - - - - - - Toggle columns - - {table - .getAllColumns() - .filter( - (column) => - typeof column.accessorFn !== 'undefined' && column.getCanHide(), - ) - .map((column) => { - return ( - column.toggleVisibility(!!value)} - > - {column.id} - - ); - })} - - - ); -} diff --git a/components/layout/SettingsSection.tsx b/components/layout/SettingsSection.tsx index 438f8645..606e1fcc 100644 --- a/components/layout/SettingsSection.tsx +++ b/components/layout/SettingsSection.tsx @@ -1,6 +1,8 @@ +import { type ReactNode } from 'react'; import { cn } from '~/utils/shadcn'; -import Section from './Section'; +import { Skeleton } from '../ui/skeleton'; import Heading from '../ui/typography/Heading'; +import Section from './Section'; export default function SettingsSection({ heading, @@ -9,8 +11,8 @@ export default function SettingsSection({ classNames, }: { heading: string; - children: React.ReactNode; - controlArea: React.ReactNode; + children: ReactNode; + controlArea: ReactNode; classNames?: string; }) { return ( @@ -27,3 +29,21 @@ export default function SettingsSection({ ); } + +export function SettingsSectionSkeleton({ + controlAreaSkelton, +}: { + controlAreaSkelton: ReactNode; +}) { + return ( +
    +
    + + +
    +
    + {controlAreaSkelton} +
    +
    + ); +} diff --git a/components/ui/AlertDialog.tsx b/components/ui/AlertDialog.tsx index 234388ca..2639f6b1 100644 --- a/components/ui/AlertDialog.tsx +++ b/components/ui/AlertDialog.tsx @@ -10,8 +10,6 @@ import { DialogTitle, DialogDescription } from '~/components/ui/dialog'; const AlertDialog = AlertDialogPrimitive.Root; -const AlertDialogTrigger = AlertDialogPrimitive.Trigger; - const AlertDialogPortal = ({ ...props }: AlertDialogPrimitive.AlertDialogPortalProps) => ( @@ -118,7 +116,6 @@ AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; export { AlertDialog, - AlertDialogTrigger, AlertDialogContent, AlertDialogHeader, AlertDialogFooter, diff --git a/components/ui/Button.tsx b/components/ui/Button.tsx index 9bf909ef..a45c499c 100644 --- a/components/ui/Button.tsx +++ b/components/ui/Button.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { Slot } from '@radix-ui/react-slot'; import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '~/utils/shadcn'; +import { Skeleton } from './skeleton'; const buttonVariants = cva( 'inline-flex items-center justify-center rounded-full text-sm font-semibold ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 text-nowrap truncate text-foreground', @@ -63,4 +64,13 @@ const Button = React.forwardRef( ); Button.displayName = 'Button'; -export { Button, buttonVariants }; +const ButtonSkeleton = (props: ButtonProps) => { + const classes = cn( + buttonVariants({ variant: props.variant, size: props.size }), + props.className, + ); + + return ; +}; + +export { Button, ButtonSkeleton, buttonVariants }; diff --git a/components/ui/FancyButton.tsx b/components/ui/FancyButton.tsx deleted file mode 100644 index bd4e3cca..00000000 --- a/components/ui/FancyButton.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import * as React from 'react'; -import { cva, type VariantProps } from 'class-variance-authority'; -import { cn } from '~/utils/shadcn'; -import BackgroundBlobs from '../BackgroundBlobs/BackgroundBlobs'; - -const fancyButtonVariants = cva( - 'inline-flex items-center justify-center rounded-xl text-lg font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 relative overflow-hidden', - { - variants: { - variant: { - default: - 'bg-primary text-primary-foreground hover:bg-primary/90 border border-primary-foreground hover:border-primary-foreground/90', - }, - size: { - default: 'px-8 py-4', - }, - }, - defaultVariants: { - variant: 'default', - size: 'default', - }, - }, -); - -export type FancyButtonProps = { - asChild?: boolean; -} & React.ButtonHTMLAttributes & - VariantProps; - -const FancyButton = React.forwardRef( - ({ children, className, variant, size, ...props }, ref) => { - return ( - - ); - }, -); - -FancyButton.displayName = 'Button'; - -export { FancyButton, fancyButtonVariants }; diff --git a/components/ui/FormattedDate.tsx b/components/ui/FormattedDate.tsx deleted file mode 100644 index 46323b10..00000000 --- a/components/ui/FormattedDate.tsx +++ /dev/null @@ -1,33 +0,0 @@ -'use client'; - -import { withNoSSRWrapper } from '~/utils/NoSSRWrapper'; - -// Display options for dates: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat#using_options -export const dateOptions: Intl.DateTimeFormatOptions = { - year: 'numeric', - month: 'numeric', - day: 'numeric', - hour: 'numeric', - minute: 'numeric', -}; - -function FormattedDate({ - date, - options = dateOptions, -}: { - date: string | number | Date; - options?: Intl.DateTimeFormatOptions; -}) { - const formattedDate = Intl.DateTimeFormat( - navigator.languages as string[], - options, - ).format(new Date(date)); - - return ( - - ); -} - -export default withNoSSRWrapper(FormattedDate); diff --git a/components/ui/Input.tsx b/components/ui/Input.tsx index 23c3b1cd..5cc14f4d 100644 --- a/components/ui/Input.tsx +++ b/components/ui/Input.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; -import { cn } from '~/utils/shadcn'; import { Label } from '~/components/ui/Label'; +import { cn } from '~/utils/shadcn'; -export type InputProps = { +type InputProps = { inputClassName?: string; label?: string; hint?: React.ReactNode; @@ -49,8 +49,9 @@ const Input = React.forwardRef( type={type} className={cn( 'focus-visible:ring-ring flex h-10 w-full rounded-input border border-border bg-input px-3 py-2 text-sm text-input-foreground ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', - leftAdornment && 'pl-10', - rightAdornment && 'pr-10', + !!leftAdornment && 'pl-10', + !!rightAdornment && 'pr-10', + !!error && 'border-destructive', inputClassName, )} ref={ref} diff --git a/components/ui/TimeAgo.tsx b/components/ui/TimeAgo.tsx index 23ed90bd..84674cd9 100644 --- a/components/ui/TimeAgo.tsx +++ b/components/ui/TimeAgo.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useMemo } from 'react'; +import { dateOptions } from '~/fresco.config'; import { withNoSSRWrapper } from '~/utils/NoSSRWrapper'; -import { dateOptions } from './FormattedDate'; type TimeAgoProps = { date: Date | string | number; diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx deleted file mode 100644 index 4a5ec346..00000000 --- a/components/ui/accordion.tsx +++ /dev/null @@ -1,58 +0,0 @@ -"use client" - -import * as React from "react" -import * as AccordionPrimitive from "@radix-ui/react-accordion" -import { ChevronDown } from "lucide-react" - -import { cn } from "~/utils/shadcn" - -const Accordion = AccordionPrimitive.Root - -const AccordionItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AccordionItem.displayName = "AccordionItem" - -const AccordionTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - svg]:rotate-180", - className - )} - {...props} - > - {children} - - - -)) -AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName - -const AccordionContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - -
    {children}
    -
    -)) - -AccordionContent.displayName = AccordionPrimitive.Content.displayName - -export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx index bf5eae22..089181d9 100644 --- a/components/ui/badge.tsx +++ b/components/ui/badge.tsx @@ -23,7 +23,7 @@ const badgeVariants = cva( }, ); -export type BadgeProps = object & +type BadgeProps = object & React.HTMLAttributes & VariantProps; @@ -33,4 +33,4 @@ function Badge({ className, variant, ...props }: BadgeProps) { ); } -export { Badge, badgeVariants }; +export { Badge, }; diff --git a/components/ui/card.tsx b/components/ui/card.tsx index f7becc5c..b314873b 100644 --- a/components/ui/card.tsx +++ b/components/ui/card.tsx @@ -61,12 +61,3 @@ const CardFooter = React.forwardRef< /> )); CardFooter.displayName = 'CardFooter'; - -export { - Card, - CardHeader, - CardFooter, - CardTitle, - CardDescription, - CardContent, -}; diff --git a/components/ui/command.tsx b/components/ui/command.tsx index fe2d2b33..51b3aea0 100644 --- a/components/ui/command.tsx +++ b/components/ui/command.tsx @@ -1,12 +1,10 @@ 'use client'; import * as React from 'react'; -import { type DialogProps } from '@radix-ui/react-dialog'; import { Command as CommandPrimitive } from 'cmdk'; import { Search } from 'lucide-react'; import { cn } from '~/utils/shadcn'; -import { Dialog, DialogContent } from '~/components/ui/dialog'; const Command = React.forwardRef< React.ElementRef, @@ -23,20 +21,6 @@ const Command = React.forwardRef< )); Command.displayName = CommandPrimitive.displayName; -type CommandDialogProps = object & DialogProps; - -const CommandDialog = ({ children, ...props }: CommandDialogProps) => { - return ( - - - - {children} - - - - ); -}; - const CommandInput = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -46,7 +30,7 @@ const CommandInput = React.forwardRef< , React.ComponentPropsWithoutRef & { @@ -130,7 +122,7 @@ const DropdownMenuRadioItem = React.forwardRef< > - + {children} @@ -186,15 +178,5 @@ export { DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioItem, DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuGroup, - DropdownMenuPortal, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuRadioGroup, }; diff --git a/components/ui/form.tsx b/components/ui/form.tsx deleted file mode 100644 index 0c4a3dd0..00000000 --- a/components/ui/form.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import * as React from 'react'; -import type * as LabelPrimitive from '@radix-ui/react-label'; -import { Slot } from '@radix-ui/react-slot'; -import { - Controller, - type ControllerProps, - type FieldPath, - type FieldValues, - FormProvider, - useFormContext, -} from 'react-hook-form'; - -import { cn } from '~/utils/shadcn'; -import { Label } from '~/components/ui/Label'; - -const Form = FormProvider; - -type FormFieldContextValue< - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, -> = { - name: TName; -}; - -const FormFieldContext = React.createContext( - {} as FormFieldContextValue, -); - -const FormField = < - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, ->({ - ...props -}: ControllerProps) => { - return ( - - - - ); -}; - -const useFormField = () => { - const fieldContext = React.useContext(FormFieldContext); - const itemContext = React.useContext(FormItemContext); - const { getFieldState, formState } = useFormContext(); - - const fieldState = getFieldState(fieldContext.name, formState); - - if (!fieldContext) { - throw new Error('useFormField should be used within '); - } - - const { id } = itemContext; - - return { - id, - name: fieldContext.name, - formItemId: `${id}-form-item`, - formDescriptionId: `${id}-form-item-description`, - formMessageId: `${id}-form-item-message`, - ...fieldState, - }; -}; - -type FormItemContextValue = { - id: string; -}; - -const FormItemContext = React.createContext( - {} as FormItemContextValue, -); - -const FormItem = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => { - const id = React.useId(); - - return ( - -
    - - ); -}); -FormItem.displayName = 'FormItem'; - -const FormLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { error, formItemId } = useFormField(); - - return ( -