diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 0e82ea6..4a6d30a 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -1,5 +1,7 @@ name: "Production Deployment" on: + push: + branches: [main] pull_request: branches: [main] types: [closed] @@ -34,7 +36,7 @@ jobs: - name: Destroy Preview Terraform Deployment working-directory: ./terraform/dev run: | - if terraform workspace show | grep -q "pr${{ github.event.number }}"; then + if terraform workspace list | grep -q "pr${{ github.event.number }}"; then terraform workspace select pr${{ github.event.number }} terraform destroy -auto-approve -no-color terraform workspace select default diff --git a/.github/workflows/terraform-pr-preview.yaml b/.github/workflows/terraform-pr-preview.yaml index 6e8967d..20bdf8d 100644 --- a/.github/workflows/terraform-pr-preview.yaml +++ b/.github/workflows/terraform-pr-preview.yaml @@ -10,13 +10,12 @@ env: AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} TF_IN_AUTOMATION: true -defaults: - run: - working-directory: ./terraform/dev - jobs: terraform: name: "Terraform PR Preview Deployment" + defaults: + run: + working-directory: ./terraform/dev runs-on: ubuntu-latest permissions: contents: read @@ -106,3 +105,43 @@ jobs: body }); } + + vercel-preview: + name: "Update Vercel Preview Environment" + runs-on: ubuntu-latest + needs: terraform + if: ${{ needs.terraform.outputs.api_url != '' }} + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + outputs: + skip_redeploy: ${{ steps.apply_api_url.outputs.skip_redeploy }} + + steps: + - name: Install Vercel CLI + run: npm install -g vercel + + - name: Check and update NEXT_PUBLIC_API_URL + id: apply_api_url + run: | + BRANCH="${{ github.head_ref }}" + API_URL="${{ needs.terraform.outputs.api_url }}" + vercel env pull --environment preview --git-branch "$BRANCH" --token="$VERCEL_TOKEN" --no-color + CURRENT=$(cat .env.local | grep "NEXT_PUBLIC_API_URL" || true) + + if [ "$CURRENT" = "NEXT_PUBLIC_API_URL=$API_URL" ]; then + echo "NEXT_PUBLIC_API_URL already set to correct value, skipping." + echo "skip_redeploy=true" >> $GITHUB_OUTPUT + exit 0 + fi + + echo "$API_URL" | vercel env add NEXT_PUBLIC_API_URL preview "$BRANCH" --token="$VERCEL_TOKEN" --force --no-color + echo "Set NEXT_PUBLIC_API_URL=$API_URL for branch $BRANCH" + + - name: Redeploy Vercel preview + if: ${{ steps.apply_api_url.outputs.skip_redeploy != 'true' }} + run: | + BRANCH="${{ github.head_ref }}" + DEPLOYMENT_URL="laprogram-git-$BRANCH-xalbd-team.vercel.app" + vercel redeploy "$DEPLOYMENT_URL" --token="$VERCEL_TOKEN" --scope xalbd-team --no-color --no-wait diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ff48ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# Remove LLM skills as they are too large and clog commits +# Perhaps find a better way to share these later; at the moment simply +# use skills-lock.json to get the current skills. +.agents/ +.claude/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fe5062c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,56 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +Monorepo for the UCLA Learning Assistant Program web app (). Used by LAs and PDT to manage the feedback and observation cycle each quarter. Three top-level directories: `frontend/`, `backend/`, `terraform/`. + +## Commands + +### Frontend + +```bash +cd frontend +npm i # install deps +npm run dev # dev server at localhost:3000 +npm run build # production build (catches type errors) +npm run lint # ESLint +``` + +### Backend + +No local dev server. Deploy and test backend routes by opening a PR — GitHub Actions will provision a test environment and post environment variable instructions as a PR comment. Set `NEXT_PUBLIC_API_URL` in the frontend to point at the test API URL. + +## Architecture + +### Frontend (`frontend/`) + +- **Next.js 16** (App Router), React 19, TypeScript, Tailwind CSS v4, shadcn/ui, TanStack Form, Zod 4 +- `app/` — file-based routing. Current routes: + - `/` — landing page (hero with CTA links to `/feedback` and `/login`) + - `/feedback` — multi-variant feedback form (the main feature) + - `/login` — email-based OTP login (stub — `handleSubmit` is a server action that currently just logs) +- `components/ui/` — shadcn components. Add new ones with `npx shadcn add `. Never copy-paste component source manually. + +#### Feedback form (`app/feedback/`) + +The feedback form is the most complex part of the frontend. It conditionally renders different sections based on role (`student`, `la`, `ta`) and feedback type. + +- **Schema** (`schema.ts`): validation uses nested `z.discriminatedUnion` — first on `role`, then on `feedback_type` (and `la_head_type` for LA→Head LA). Shared field groups (`headerFields`, `closingFields`, `mqFields`, `eqFields`, `laPedFields`, `laLccFields`, `obsFields`, `taFields`, etc.) are defined once and spread into both variant schemas and `baseSchema`. `baseSchema` exists only for type inference (`FeedbackFormValues`) and generating `defaultValues` — it is built from the field groups, not maintained manually. When adding a new field, add it to the relevant field group; it will flow into `baseSchema`, `defaultValues`, and the variant schema automatically. +- **Constants** (`constants.ts`): all dropdown/radio options and question lists. Courses and LAs are currently hardcoded (will eventually be fetched from backend). +- The exported `feedbackFormSchema` is cast to `z.ZodType` because the discriminated union's inferred type is narrower than the flat `FeedbackFormValues` that TanStack Form expects. This cast is safe — runtime validation is correct. + +### Backend (`backend/`) + +- Python 3.14. Each `.py` file = one Lambda function = one API route. +- Lambdas use AWS Lambda format 2.0 payload. Each handler parses HTTP method, path, body, etc. from the `event` dict directly. +- No shared runtime or framework — each file is standalone. + +### Infrastructure (`terraform/`) + +- Avoid changing Terraform files; they should only be edited by humans. Only read Terraform files to figure out how to properly access AWS resources for backend routes. +- Two Terraform modules: `deploy` (per-PR, duplicatable: API Gateway + Lambdas) and `prod` (production-only: domain, TLS cert). +- Two configs: `terraform/dev/` (PR previews, one workspace per PR named e.g. `pr150`) and `terraform/release/` (production, `default` workspace). +- Terraform state is in S3; AWS credentials are in GitHub Secrets. +- `deploy` automatically provisions a Lambda for every `.py` file found in `backend/`. diff --git a/README.md b/README.md index 04e5cac..4a5ecff 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,36 @@ # UCLA Learning Assistant Website -LA Program site for observations and feedback. -## Development Requirements +This monorepo contains the code for the Learning Assistant website at UCLA. This is not an informational website; it is actively used by both LAs and PDT to manage the feedback and observation cycle each quarter. -### Terraform -Install Terraform: refer to the [Terraform installation documentation](https://developer.hashicorp.com/terraform/tutorials/aws-get-started/install-cli) +Please visit to see the production website. -### AWS -1. Install AWS CLI: refer to the [AWS CLI installation documentation](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) -2. Obtain LA Program's AWS credentials and account (TODO) +## Development Requirements/Tech Stack +- Node.js (preferably LTS) +- Python 3.14 + +We use: + +- Next.js on the frontend deployed through Vercel +- Python on the backend run through AWS Lambdas +- AWS for infrastructure, managed using Terraform + +### Frontend/Backend + +To develop the app, run: + +```bash +cd frontend +npm i +npm run dev +``` + +This starts a Next.js development server in the `frontend` directory at . You can see any changes you make in real time here. For more information, including in-depth development patterns, please read [frontend/README.md](frontend/README.md) before proceeding. + +To develop API routes, make any changes you wish to make to `backend`, then make a PR targeting `main`. Please refer to [backend/README.md](backend/README.md) for more information on how to access AWS resources in a way that allows for both test and production deployments with the same code. + +Once the Github Actions pipelines run, check the PR for a comment with instructions on how to set your environment variables properly to allow your locally hosted app to access a testing version of the API; this will let you test front-end and back-end changes together without affecting production data. + +### Infrastructure + +Please refer to [terraform/README.md](terraform/README.md) for more information. This should not need to be edited more than roughly once a quarter (for new database deployments). diff --git a/amplify.yml b/amplify.yml deleted file mode 100644 index ec6284c..0000000 --- a/amplify.yml +++ /dev/null @@ -1,17 +0,0 @@ -version: 1 -frontend: - buildPath: "/frontend" - phases: - preBuild: - commands: - - npm ci - build: - commands: - - npm run build - artifacts: - baseDirectory: .next - files: - - "**/*" - cache: - paths: - - node_modules/**/* diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..503d77a --- /dev/null +++ b/backend/README.md @@ -0,0 +1,18 @@ +# Backend + +## Technical Guide + +All back-end API routes are deployed as AWS Lambdas (serverless functions). Each route is associated with exactly one `.py` file. These are served through an HTTP API using API Gateway. + +For instance, creating an API on the `/test` route can be accomplished by creating a `test.py` file in the `backend` folder. Thus, each `.py` file needs to parse for HTTP method, etc, individually. + +Please read the AWS documentation if you have questions after looking at existing routes. For reference: + +- [Defining Python Lambda handlers](https://docs.aws.amazon.com/lambda/latest/dg/python-handler.html) +- [Lambda Payload Documentation](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html) - note: we use format 2.0 + +## AWS Resource Access + +We need to support both production and test deployments on one codebase; this means that unless a resource is global (i.e. shared between production and test deployments), we can not completely hardcode information like database names. + +To accomplish this, please: TODO diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..4b73e25 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +NEXT_PUBLIC_API_URL= \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore index 5ef6a52..7b8da95 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/frontend/README.md b/frontend/README.md index e215bc4..50851f6 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,36 +1,46 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Frontend + +This is a [Next.js](https://nextjs.org/docs) project. + +## Technical Guide + +We use a subset of Next.js features in order to ease development and reduce cost. These include that we: + +- use the App Router +- do not use Vercel for anything other than front-end deployment (all API routes are deployed through Lambdas) +- minimize usage of Next.js Route Handlers +- minimize usage of SSR (server-side rendering) as this app does not require SEO optimization and we wish to minimize requests to Vercel + +In addition, we use [shadcn](https://ui.shadcn.com/docs/components) as our component/styling library. We aim to minimize custom styling to ease development. ## Getting Started -First, run the development server: +First, install dependencies. ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +cd frontend +npm i ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Run the development server: -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +```bash +npm run dev +``` -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. -## Learn More +Edit the `page.tsx` files in `/app` to change the site's contents. Please read the codebase and the [Next.js docs](https://nextjs.org/docs) for more information. -To learn more about Next.js, take a look at the following resources: +## API Access -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +> [!WARNING] +> Do not hardcode API routes. Instead, set the `NEXT_PUBLIC_API_ROUTE` environment variable while developing. This ensures your API calls will work on the production deployment as this URL changes between test and production deployments. -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +You can get a test API URL by making a PR on Github. More information is in [the root README.md](../README.md). -## Deploy on Vercel +You can set the environment variable by making a copy of `.env.example`, naming it `.env`, and setting the environment variable there. Next.js automatically loads this variable upon starting the development server. -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## Styling -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +Use shadcn to add components. Use the `npx` command listed in the component documentation to add it to the project. diff --git a/frontend/app/[slug]/route.ts b/frontend/app/[slug]/route.ts deleted file mode 100644 index f978db4..0000000 --- a/frontend/app/[slug]/route.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { notFound } from "next/navigation"; - -function getMapping(slug: string): string | null { - // replace with Dyanmo lookup - switch (slug) { - case "syllabus": - return "https://docs.google.com/document/d/1ynJSRhLkGigDWusufc7HGjG-T-gWBAreWKcoyhlA9Sc/edit?usp=sharing"; - case "google": - return "https://www.google.com"; - default: - return null; - } -} - -export async function GET(_: Request, { params }: RouteContext<"/[slug]">) { - const { slug } = await params; - const mapping = getMapping(slug); - - if (!mapping) { - notFound(); - } - - return new Response(null, { - status: 302, - headers: { - Location: mapping, - }, - }); -} diff --git a/frontend/app/feedback/constants.ts b/frontend/app/feedback/constants.ts new file mode 100644 index 0000000..9243d6b --- /dev/null +++ b/frontend/app/feedback/constants.ts @@ -0,0 +1,413 @@ +export const COURSES = [ + "CS 31 – Introduction to Computer Science I", + "CS 32 – Object-Oriented Programming", + "CS 33 – Introduction to Computer Organization", + "CS 35L – Software Construction Laboratory", + "MATH 31A – Differential and Integral Calculus", + "MATH 31B – Integration and Infinite Series", + "MATH 32A – Calculus of Several Variables", + "PHYSICS 1A – Physics for Scientists and Engineers", + "PHYSICS 1B – Physics for Scientists and Engineers", + "CHEM 14A – Atomic and Molecular Structure", + "CHEM 14B – Thermodynamics and Kinetics", + "No LA", +]; + +export const LAS = ["AAAA", "BBBB", "CCCC", "No LA"]; + +export const IMPROVEMENT_OPTIONS = [ + { value: "na", label: "N/A" }, + { value: "no_change", label: "No change, but still room to improve" }, + { value: "little_improvement", label: "Little improvement" }, + { value: "big_improvement", label: "Big improvement" }, + { + value: "no_room_for_improvement", + label: "No room to improve (because of how well they were already doing)", + }, +]; + +export const AGREEMENT_OPTIONS = [ + { value: "na", label: "N/A" }, + { value: "strongly_disagree", label: "Strongly Disagree" }, + { value: "disagree", label: "Disagree" }, + { value: "agree", label: "Agree" }, + { value: "strongly_agree", label: "Strongly Agree" }, +]; + +export const OBSERVATION_OPTIONS = [ + { value: "na", label: "N/A (no opportunities to do so)" }, + { value: "not_yet", label: "Not yet" }, + { value: "almost_never", label: "Almost never" }, + { value: "sometimes", label: "Sometimes" }, + { + value: "most_missed", + label: "Most of the time (because the LA missed opportunities)", + }, + { + value: "most_instructor", + label: + "Most of the time (because the instructor was talking >50% of the time)", + }, + { value: "always", label: "Always" }, +]; + +export const ACTIVITIES = [ + { value: "discussion", label: "Discussion or lab section" }, + { + value: "lecture", + label: "Lecture (if you have interacted with LAs during lecture)", + }, + { value: "office_hours", label: "Office hours" }, + { value: "study_session", label: "Review / study session" }, +]; + +export const TA_QUESTIONS = [ + { + value: "ta_comfortable", + label: + "The LA is comfortable with the material and/or asks me when needed...", + }, + { + value: "ta_circulates", + label: + "The LA circulates so every group gets to interact with an LA or TA during each section...", + }, + { + value: "ta_peer_names", + label: + "The LA uses peer names (even those that are harder to pronounce)...", + }, + { + value: "ta_devotes", + label: + "The LA devotes their time to their peers and does not become distracted...", + }, + { + value: "ta_empathizes", + label: "The LA empathizes with struggling peers...", + }, + { + value: "ta_redirects", + label: + "The LA redirects questions to their peers to foster collaboration...", + }, + { + value: "ta_waits", + label: + "The LA waits a few seconds for students to respond after asking a question...", + }, + { + value: "ta_checks", + label: + "The LA checks student understanding before moving on to another topic (e.g., by asking a follow-up question)...", + }, + { + value: "ta_encourages", + label: "The LA encourages participation and effort over correct answers...", + }, + { + value: "ta_creates", + label: + "The LA creates an environment within each group where every group member is engaged...", + }, +]; + +export const ROLE_OPTIONS = [ + { value: "la", label: "an LA." }, + { value: "student", label: "a student in an LA-supported course." }, + { value: "ta", label: "a TA who interacts with an LA." }, +]; + +export const FEEDBACK_TYPE_OPTIONS = [ + { value: "mid_quarter", label: "Student to LA Mid-Quarter Feedback" }, + { value: "end_of_quarter", label: "Student to LA End-of-Quarter Feedback" }, +]; + +export const LA_FEEDBACK_TYPE_OPTIONS = [ + { value: "la_observation", label: "LA Observation Feedback" }, + { value: "la_head_la", label: "LA to Head LA Feedback" }, +]; + +export const LA_HEAD_TYPE_OPTIONS = [ + { value: "ped_head", label: "Pedagogy Head LA" }, + { value: "lcc", label: "LA Course Coordinator (LCC)" }, + { value: "ped_lcc", label: "Both Ped Head and LCC" }, +]; + +export const LA_PED_QUESTIONS = [ + { + value: "la_ped_seminars", + label: + "My Head LA tries to engage all New LAs during Pedagogy Seminar discussions.", + }, + { + value: "la_ped_applies", + label: + "My Head LA helps me apply pedagogy techniques to my content course.", + }, + { + value: "la_ped_discusses", + label: + "My Head LA is happy to discuss any questions I have about pedagogy techniques.", + }, + { + value: "la_ped_feedback", + label: + "My Head LA gives me feedback to improve my LA skills if/when I ask for it.", + }, + { + value: "la_ped_content_meeting", + label: "The content meeting for my course is well-run and organized.", + }, +]; + +export const LA_LCC_QUESTIONS = [ + { + value: "la_lcc_emails", + label: "My Head LA responds to emails in a timely manner.", + }, + { + value: "la_lcc_comfortable", + label: + "I feel comfortable reaching out to my Head LA for logistical questions.", + }, + { + value: "la_lcc_answers", + label: + "My Head LA is able to answer questions about the LA Program, or they direct me to the right person.", + }, + { + value: "la_lcc_announcements", + label: + "My Head LA provides useful announcements and reminders during content meetings.", + }, + { + value: "la_lcc_expectations", + label: + "Expectations for my assigned sections and content meetings are made clear to me.", + }, +]; + +export const BECOME_LA_OPTIONS = [ + { value: "yes_this", label: "Yes, for this course." }, + { value: "yes_other", label: "Yes, for another course." }, + { value: "maybe", label: "Maybe." }, + { value: "no_graduating", label: "No, because I am graduating." }, + { value: "no_uninterested", label: "No, because I am not interested." }, + { value: "na_already", label: "N/A – I am/was already an LA." }, +]; + +export const GENDER_OPTIONS = [ + { value: "man", label: "Man" }, + { value: "woman", label: "Woman" }, + { value: "nonbinary", label: "Nonbinary" }, + { value: "self_describe", label: "Prefer to self-describe" }, + { value: "prefer_not_to_say", label: "Prefer not to say" }, +]; + +export const GROUP_OPTIONS = [ + { value: "african", label: "African" }, + { value: "african_american_black", label: "African American / Black" }, + { value: "other_black", label: "Other Black" }, + { value: "caribbean", label: "Caribbean" }, + { value: "mexican", label: "Mexican / Mexican American" }, + { value: "central_american", label: "Central American" }, + { value: "south_american", label: "South American" }, + { value: "puerto_rican", label: "Puerto Rican" }, + { value: "other_hispanic", label: "Other Hispanic or Latine/o/a" }, + { value: "chicane", label: "Chicane/o/a" }, + { + value: "native_american", + label: "Native American: American Indian or Alaskan Native", + }, + { + value: "pacific_islander", + label: "Native Hawaiian or Other Pacific Islander", + }, + { + value: "east_asian", + label: "East Asian (e.g., Chinese, Japanese, Korean, Taiwanese)", + }, + { value: "mena_central_asian", label: "MENA / Central Asian" }, + { + value: "south_asian", + label: "South Asian (e.g., Pakistani, Indian, Nepalese, Sri Lankan)", + }, + { + value: "southeast_asian", + label: "Southeast Asian (e.g., Filipino, Indonesian, Vietnamese)", + }, + { value: "european", label: "European / European American" }, + { value: "other_white", label: "Other White" }, + { value: "other", label: "Other (specify below)" }, + { value: "prefer_not_to_say", label: "Prefer not to say" }, +]; + +export const OBSERVATION_ROUND_OPTIONS = [ + { value: "round_1", label: "Observations Round 1 (Weeks 3-4)" }, + { value: "round_2", label: "Observations Round 2 (Weeks 7-8)" }, +]; + +export const LA_POSITION_OPTIONS = [ + { value: "new_la", label: "New LA" }, + { value: "returning_la", label: "Returning LA" }, + { value: "ped_head", label: "Pedagogy Head LA" }, + { value: "lcc", label: "LA Course Coordinator (LCC)" }, + { value: "lcc_ped_head", label: "LCC + Pedagogy Head LA" }, +]; + +export const OBSERVATION_QUESTIONS = [ + { + value: "obs_empathized", + label: "The LA empathized with struggling peers...", + }, + { + value: "obs_redirected", + label: "The LA redirected questions to foster collaboration...", + }, + { value: "obs_wait_time", label: "The LA used wait time..." }, + { + value: "obs_open_closed", + label: + "The LA asked a mix of open and closed questions to encourage engagement...", + }, + { + value: "obs_closed_check", + label: + "The LA asked closed questions to check for understanding before moving on...", + }, + { + value: "obs_peer_names", + label: + "The LA used peer names (even those that are harder to pronounce)...", + }, + { + value: "obs_growth_mindset", + label: + "The LA used growth mindset feedback (e.g., encouraging participation and effort over correct answers)...", + }, + { + value: "obs_circulated", + label: + "The LA circulated so that every group got to interact with an LA during the section...", + }, + { + value: "obs_environment", + label: + "The LA created an environment within each group where every group member was engaged...", + }, + { + value: "obs_familiarity", + label: + "The LA demonstrated familiarity with the material (and/or asked the TA when needed)...", + }, + { + value: "obs_devoted", + label: + "The LA devoted their attention to their peers and did not become distracted...", + }, +]; + +export const MID_QUARTER_QUESTIONS = [ + { value: "mq_approachable", label: "My LA is approachable." }, + { + value: "mq_helpful", + label: "My LA is helpful to my learning in this course.", + }, + { + value: "mq_familiar", + label: + "My LA is familiar with the course material (and/or asks the TA when needed).", + }, + { + value: "mq_engagement", + label: + "My LA helps create an environment in which every student in my group engages.", + }, + { + value: "mq_questioning", + label: + "My LA asks me why something is true more often than they explain to me why.", + }, + { + value: "mq_supportive", + label: "My LA supports me if I am struggling and/or frustrated.", + }, + { value: "mq_name", label: "My LA uses my name when interacting with me." }, + { + value: "mq_belonging", + label: "My LA helps me feel more like I belong in STEM.", + }, + { + value: "mq_checkin", + label: + "An LA checks in on my understanding in every section, especially if I am struggling.", + }, + { + value: "mq_small_groups", + label: + "This course allows LAs to facilitate learning by having students work in small groups.", + }, +]; + +export const END_OF_QUARTER_QUESTIONS = [ + { + value: "eq_approachability", + label: "How much improvement have you seen in your LA's approachability?", + options: IMPROVEMENT_OPTIONS, + }, + { + value: "eq_helpfulness", + label: + "How much improvement have you seen in your LA's helpfulness to your learning in this course?", + options: IMPROVEMENT_OPTIONS, + }, + { + value: "eq_familiarity", + label: + "How much improvement have you seen in your LA's familiarity with the course material?", + options: IMPROVEMENT_OPTIONS, + }, + { + value: "eq_engagement", + label: + "How much improvement have you seen in your LA's ability to engage everyone in your group?", + options: IMPROVEMENT_OPTIONS, + }, + { + value: "eq_questioning", + label: + "How much improvement have you seen in your LA's focus on asking questions before explaining?", + options: IMPROVEMENT_OPTIONS, + }, + { + value: "eq_supportiveness", + label: + "How much improvement have you seen in your LA's supportiveness when you are struggling and/or frustrated?", + options: IMPROVEMENT_OPTIONS, + }, + { + value: "eq_name_use", + label: + "How much improvement have you seen in your LA's use of your name when interacting with you?", + options: IMPROVEMENT_OPTIONS, + }, + { + value: "eq_belonging_stem", + label: + "How much improvement have you seen in your LA's support of you feeling more like you belong in STEM?", + options: IMPROVEMENT_OPTIONS, + }, + { + value: "eq_group_belonging", + label: + "My group in discussion section helped me feel more like I belong in this class.", + options: AGREEMENT_OPTIONS, + }, + { + value: "eq_group_reliance", + label: + "My group in discussion section helped me feel more like I can rely on other students for academic support.", + options: AGREEMENT_OPTIONS, + }, +]; diff --git a/frontend/app/feedback/feedback-form.tsx b/frontend/app/feedback/feedback-form.tsx new file mode 100644 index 0000000..e8554bc --- /dev/null +++ b/frontend/app/feedback/feedback-form.tsx @@ -0,0 +1,442 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { + Field, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, + FieldSeparator, +} from "@/components/ui/field"; +import { ClosingSection } from "./sections/closing-section"; +import { EndOfQuarterSection } from "./sections/end-of-quarter-section"; +import { MidQuarterSection } from "./sections/mid-quarter-section"; +import { TASection } from "./sections/ta-section"; +import { LAHeadLASection } from "./sections/la-head-la-section"; +import { ObservationSection } from "./sections/observation-section"; +import { + COURSES, + FEEDBACK_TYPE_OPTIONS, + LA_FEEDBACK_TYPE_OPTIONS, + LAS, + ROLE_OPTIONS, +} from "./constants"; +import { useAppForm, defaultValues, feedbackFormSchema } from "./form"; +import { + Combobox, + ComboboxContent, + ComboboxItem, + ComboboxInput, + ComboboxEmpty, + ComboboxList, +} from "@/components/ui/combobox"; + +export function FeedbackForm() { + const form = useAppForm({ + defaultValues, + validators: { + onSubmit: feedbackFormSchema, + }, + onSubmit: async ({ value }) => { + console.log("Form submitted:", value); + }, + onSubmitInvalid({ value }) { + console.log("Form submitted invalid:", value); + }, + }); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + onReset={() => { + form.reset(); + }} + > + + {/* Name */} + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + + Name * + + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + aria-invalid={isInvalid} + /> + {isInvalid && } + + ); + }} + + + {/* Email */} + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + + Email * + + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + aria-invalid={isInvalid} + /> + {isInvalid && } + + ); + }} + + + {/* Role */} + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + + I am… * + + field.handleChange(v)} + onBlur={field.handleBlur} + > + {ROLE_OPTIONS.map(({ value, label }) => ( +
+ + +
+ ))} +
+ {isInvalid && } +
+ ); + }} +
+ + {/* Course */} + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + + Course of LA being given feedback{" "} + * + + field.handleChange(v as string)} + value={field.state.value} + items={COURSES} + > + + + No option found. + + {(c) => ( + + {c} + + )} + + + + {isInvalid && } + + ); + }} + + + {/* LA */} + state.values.course}> + {(course) => + course && ( + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + + Please select the name of the LA you are providing + feedback to * + + field.handleChange(v as string)} + value={field.state.value} + items={LAS} + > + + + No option found. + + {(laName) => ( + + {laName} + + )} + + + + {isInvalid && ( + + )} + + ); + }} + + ) + } + + + {/* Feedback Type — student only */} + ({ + la: state.values.la, + role: state.values.role, + })} + > + {({ la, role }) => + la && + role === "student" && ( + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + + What kind of feedback are you providing?{" "} + * + + + Students provide LAs with feedback in the middle of the + quarter (Weeks 5–6) and at the end of the quarter (Weeks + 9–10). + + { + field.handleChange(v); + }} + onBlur={field.handleBlur} + > + {FEEDBACK_TYPE_OPTIONS.map(({ value, label }) => ( +
+ + +
+ ))} +
+ {isInvalid && ( + + )} +
+ ); + }} +
+ ) + } +
+ + {/* Feedback Type — LA only */} + ({ + la: state.values.la, + role: state.values.role, + })} + > + {({ la, role }) => + la && + role === "la" && ( + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + + LAs: What kind of feedback are you providing?{" "} + * + + field.handleChange(v)} + onBlur={field.handleBlur} + > + {LA_FEEDBACK_TYPE_OPTIONS.map(({ value, label }) => ( +
+ + +
+ ))} +
+ {isInvalid && ( + + )} +
+ ); + }} +
+ ) + } +
+ + {/* LA sections */} + ({ + role: state.values.role, + feedbackType: state.values.feedback_type, + })} + > + {({ role, feedbackType }) => + role === "la" && + feedbackType && ( + <> + + {feedbackType === "la_observation" && ( + + )} + {feedbackType === "la_head_la" && ( + + )} + + ) + } + + + {/* Student sections */} + ({ + role: state.values.role, + feedbackType: state.values.feedback_type, + })} + > + {({ role, feedbackType }) => + role === "student" && + feedbackType && ( + <> + + {feedbackType === "mid_quarter" && ( + + )} + {feedbackType === "end_of_quarter" && ( + + )} + + + + ) + } + + + {/* TA section */} + ({ + la: state.values.la, + role: state.values.role, + })} + > + {({ la, role }) => + la && + role === "ta" && ( + <> + + + + ) + } + + + + {/* Actions */} +
+ + s.isSubmitting}> + {(isSubmitting) => ( + + )} + +
+
+
+ ); +} diff --git a/frontend/app/feedback/fields/activities-field.tsx b/frontend/app/feedback/fields/activities-field.tsx new file mode 100644 index 0000000..57a876c --- /dev/null +++ b/frontend/app/feedback/fields/activities-field.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { Checkbox } from "@/components/ui/checkbox"; +import { + Field, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, +} from "@/components/ui/field"; +import { ACTIVITIES } from "../constants"; +import { withForm, defaultValues, feedbackFormSchema } from "../form"; + +export const ActivitiesField = withForm({ + defaultValues, + validators: { onSubmit: feedbackFormSchema }, + render: ({ form }) => ( + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + + Which LA-supported activities have you attended for this course?{" "} + * + + Check all that apply. + + {ACTIVITIES.map(({ value, label }) => ( + + { + const current = field.state.value; + field.handleChange( + checked + ? [...current, value] + : current.filter((v) => v !== value), + ); + }} + aria-invalid={isInvalid} + /> + {label} + + ))} + + {isInvalid && } + + ); + }} + + ), +}); diff --git a/frontend/app/feedback/fields/hours-field.tsx b/frontend/app/feedback/fields/hours-field.tsx new file mode 100644 index 0000000..84312df --- /dev/null +++ b/frontend/app/feedback/fields/hours-field.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Input } from "@/components/ui/input"; +import { + Field, + FieldDescription, + FieldError, + FieldLabel, +} from "@/components/ui/field"; +import { withForm, defaultValues, feedbackFormSchema } from "../form"; + +export const HoursField = withForm({ + defaultValues, + validators: { onSubmit: feedbackFormSchema }, + render: ({ form }) => ( + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + + Approximately how many hours per week do you spend in LA-supported + activities for this course?{" "} + * + + + If you attend a 2-hour discussion section each week, put 2. If you + don't attend an LA-supported section, put 0. If you attend a + 1-hour discussion section AND an LA-supported office hour every + 2–3 weeks, put 1.5. + + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + aria-invalid={isInvalid} + /> + {isInvalid && } + + ); + }} + + ), +}); diff --git a/frontend/app/feedback/fields/likert-field.tsx b/frontend/app/feedback/fields/likert-field.tsx new file mode 100644 index 0000000..386b093 --- /dev/null +++ b/frontend/app/feedback/fields/likert-field.tsx @@ -0,0 +1,56 @@ +"use client"; + +import type { FeedbackFormValues } from "../schema"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { Field, FieldError, FieldLabel } from "@/components/ui/field"; +import { withForm, defaultValues, feedbackFormSchema } from "../form"; + +export const LikertField = withForm({ + defaultValues, + validators: { onSubmit: feedbackFormSchema }, + props: {} as { + fieldName: keyof FeedbackFormValues; + label: string; + options: { value: string; label: string }[]; + }, + render: ({ form, fieldName, label, options }) => ( + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + + {label} * + + { + field.handleChange(value ?? ""); + field.handleBlur(); + }} + > + {options.map((opt) => ( + + {opt.label} + + ))} + + {isInvalid && } + + ); + }} + + ), +}); diff --git a/frontend/app/feedback/fields/textarea-form-field.tsx b/frontend/app/feedback/fields/textarea-form-field.tsx new file mode 100644 index 0000000..7767131 --- /dev/null +++ b/frontend/app/feedback/fields/textarea-form-field.tsx @@ -0,0 +1,55 @@ +"use client"; + +import type { ReactNode } from "react"; +import type { FeedbackFormValues } from "../schema"; +import { Textarea } from "@/components/ui/textarea"; +import { + Field, + FieldDescription, + FieldError, + FieldLabel, +} from "@/components/ui/field"; +import { withForm, defaultValues, feedbackFormSchema } from "../form"; + +export const TextareaFormField = withForm({ + defaultValues, + validators: { onSubmit: feedbackFormSchema }, + props: {} as { + fieldName: keyof FeedbackFormValues; + label: ReactNode; + required?: boolean; + description?: ReactNode; + rows?: number; + }, + render: ({ form, fieldName, label, required, description, rows = 3 }) => ( + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + + {label} + {required && ( + <> + {" "} + * + + )} + + {description && {description}} +