diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/entity/DirectorEntity.java b/services/spring-event/src/main/java/tum/devoops/eventservice/entity/DirectorEntity.java new file mode 100644 index 00000000..51f01072 --- /dev/null +++ b/services/spring-event/src/main/java/tum/devoops/eventservice/entity/DirectorEntity.java @@ -0,0 +1,40 @@ +package tum.devoops.eventservice.entity; + +import java.io.Serializable; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * Read-only shadow of {@code organization.directors}, owned by the organization service. + */ +@Entity +@Table(schema = "organization", name = "directors") +@Getter +@NoArgsConstructor +public class DirectorEntity { + + // Composite PK: (sport_id, member_id). + @EmbeddedId + private Id id; + + @Embeddable + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Id implements Serializable { + @Column(name = "sport_id", nullable = false) + private UUID sportId; + + @Column(name = "member_id", nullable = false) + private UUID memberId; + } +} diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/entity/TeamEntity.java b/services/spring-event/src/main/java/tum/devoops/eventservice/entity/TeamEntity.java index 8af6bac3..d45d522d 100644 --- a/services/spring-event/src/main/java/tum/devoops/eventservice/entity/TeamEntity.java +++ b/services/spring-event/src/main/java/tum/devoops/eventservice/entity/TeamEntity.java @@ -25,4 +25,7 @@ public class TeamEntity { @Column(name = "name", insertable = false, updatable = false) private String name; + + @Column(name = "sport_id", insertable = false, updatable = false) + private UUID sportId; } diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/entity/TrainerEntity.java b/services/spring-event/src/main/java/tum/devoops/eventservice/entity/TrainerEntity.java new file mode 100644 index 00000000..418e042f --- /dev/null +++ b/services/spring-event/src/main/java/tum/devoops/eventservice/entity/TrainerEntity.java @@ -0,0 +1,40 @@ +package tum.devoops.eventservice.entity; + +import java.io.Serializable; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * Read-only shadow of {@code organization.trainers}, owned by the organization service. + */ +@Entity +@Table(schema = "organization", name = "trainers") +@Getter +@NoArgsConstructor +public class TrainerEntity { + + // Composite PK: (team_id, member_id). + @EmbeddedId + private Id id; + + @Embeddable + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Id implements Serializable { + @Column(name = "team_id", nullable = false) + private UUID teamId; + + @Column(name = "member_id", nullable = false) + private UUID memberId; + } +} diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/repository/DirectorRepository.java b/services/spring-event/src/main/java/tum/devoops/eventservice/repository/DirectorRepository.java new file mode 100644 index 00000000..78fbad69 --- /dev/null +++ b/services/spring-event/src/main/java/tum/devoops/eventservice/repository/DirectorRepository.java @@ -0,0 +1,8 @@ +package tum.devoops.eventservice.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import tum.devoops.eventservice.entity.DirectorEntity; + +public interface DirectorRepository extends JpaRepository { +} diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/repository/TrainerRepository.java b/services/spring-event/src/main/java/tum/devoops/eventservice/repository/TrainerRepository.java new file mode 100644 index 00000000..88829205 --- /dev/null +++ b/services/spring-event/src/main/java/tum/devoops/eventservice/repository/TrainerRepository.java @@ -0,0 +1,8 @@ +package tum.devoops.eventservice.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import tum.devoops.eventservice.entity.TrainerEntity; + +public interface TrainerRepository extends JpaRepository { +} diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/service/EventService.java b/services/spring-event/src/main/java/tum/devoops/eventservice/service/EventService.java index ce02b1a0..9bea2005 100644 --- a/services/spring-event/src/main/java/tum/devoops/eventservice/service/EventService.java +++ b/services/spring-event/src/main/java/tum/devoops/eventservice/service/EventService.java @@ -15,9 +15,11 @@ import tum.devoops.eventservice.converter.EventConverter; import tum.devoops.eventservice.entity.AttendanceEntity; +import tum.devoops.eventservice.entity.DirectorEntity; import tum.devoops.eventservice.entity.EventEntity; import tum.devoops.eventservice.entity.SportEventEntity; import tum.devoops.eventservice.entity.TeamEventEntity; +import tum.devoops.eventservice.entity.TrainerEntity; import tum.devoops.eventservice.exception.BadRequestException; import tum.devoops.eventservice.exception.ForbiddenException; import tum.devoops.eventservice.exception.NotFoundException; @@ -26,12 +28,14 @@ import tum.devoops.eventservice.model.EventPartialUpdate; import tum.devoops.eventservice.model.EventSummary; import tum.devoops.eventservice.repository.AttendanceRepository; +import tum.devoops.eventservice.repository.DirectorRepository; import tum.devoops.eventservice.repository.EventRepository; import tum.devoops.eventservice.repository.MemberRepository; import tum.devoops.eventservice.repository.SportEventRepository; import tum.devoops.eventservice.repository.SportRepository; import tum.devoops.eventservice.repository.TeamEventRepository; import tum.devoops.eventservice.repository.TeamRepository; +import tum.devoops.eventservice.repository.TrainerRepository; @Service public class EventService { @@ -50,6 +54,10 @@ public class EventService { private SportRepository sportRepository; @Autowired private TeamRepository teamRepository; + @Autowired + private TrainerRepository trainerRepository; + @Autowired + private DirectorRepository directorRepository; @Transactional(readOnly = true) public List getAllEvents(UUID requesterId, boolean isAdmin) { @@ -99,6 +107,10 @@ public Event createEvent(EventCreate body, UUID requesterId, boolean isAdmin) { throw new BadRequestException("Event end time must be after start time"); } + if (!isAdmin && !canCreateEvent(requesterId, body)) { + throw new ForbiddenException("Access denied"); + } + EventEntity entity = new EventEntity(); entity.setName(body.getName()); entity.setDescription(body.getDescription()); @@ -177,6 +189,30 @@ public void deleteEvent(UUID eventId, UUID requesterId, boolean isAdmin) { eventRepository.delete(entity); } + // Per the API contract: trainers may only create events for a team they coach; directors + // only for a sport they direct (either linked directly via sports_linked, or via one of the + // linked teams' sport). Admins bypass this entirely (checked by the caller). + private boolean canCreateEvent(UUID requesterId, EventCreate body) { + List teamIds = body.getTeamsLinked() == null + ? List.of() + : body.getTeamsLinked().stream().map(t -> parseUuid(t, "teams_linked")).collect(Collectors.toList()); + + boolean isTrainerOfTeam = teamIds.stream() + .anyMatch(teamId -> trainerRepository.existsById(new TrainerEntity.Id(teamId, requesterId))); + if (isTrainerOfTeam) { + return true; + } + + Set sportIds = new HashSet<>(); + if (body.getSportsLinked() != null) { + sportIds.addAll(body.getSportsLinked()); + } + teamRepository.findAllById(teamIds).forEach(team -> sportIds.add(team.getSportId())); + + return sportIds.stream() + .anyMatch(sportId -> directorRepository.existsById(new DirectorEntity.Id(sportId, requesterId))); + } + private void persistLinks(UUID eventId, List attendees, List sports, List teams) { if (attendees != null) { attendanceRepository.saveAll(buildAttendanceEntities(eventId, attendees)); diff --git a/services/spring-event/src/test/java/tum/devoops/eventservice/service/EventServiceTest.java b/services/spring-event/src/test/java/tum/devoops/eventservice/service/EventServiceTest.java index 3d41fbfe..4e06c343 100644 --- a/services/spring-event/src/test/java/tum/devoops/eventservice/service/EventServiceTest.java +++ b/services/spring-event/src/test/java/tum/devoops/eventservice/service/EventServiceTest.java @@ -22,10 +22,13 @@ import org.mockito.junit.jupiter.MockitoExtension; import tum.devoops.eventservice.entity.AttendanceEntity; +import tum.devoops.eventservice.entity.DirectorEntity; import tum.devoops.eventservice.entity.MemberEntity; import tum.devoops.eventservice.entity.EventEntity; import tum.devoops.eventservice.entity.SportEventEntity; +import tum.devoops.eventservice.entity.TeamEntity; import tum.devoops.eventservice.entity.TeamEventEntity; +import tum.devoops.eventservice.entity.TrainerEntity; import tum.devoops.eventservice.exception.BadRequestException; import tum.devoops.eventservice.exception.ForbiddenException; import tum.devoops.eventservice.exception.NotFoundException; @@ -35,12 +38,14 @@ import tum.devoops.eventservice.model.EventSummary; import tum.devoops.eventservice.model.Reference; import tum.devoops.eventservice.repository.AttendanceRepository; +import tum.devoops.eventservice.repository.DirectorRepository; import tum.devoops.eventservice.repository.EventRepository; import tum.devoops.eventservice.repository.MemberRepository; import tum.devoops.eventservice.repository.SportEventRepository; import tum.devoops.eventservice.repository.SportRepository; import tum.devoops.eventservice.repository.TeamEventRepository; import tum.devoops.eventservice.repository.TeamRepository; +import tum.devoops.eventservice.repository.TrainerRepository; @ExtendWith(MockitoExtension.class) class EventServiceTest { @@ -59,6 +64,10 @@ class EventServiceTest { private SportRepository sportRepository; @Mock private TeamRepository teamRepository; + @Mock + private TrainerRepository trainerRepository; + @Mock + private DirectorRepository directorRepository; @InjectMocks private EventService service; @@ -218,6 +227,65 @@ void createEventWithInvalidAttendeeUuidThrowsBadRequest() { .isInstanceOf(BadRequestException.class); } + @Test + void createEventAsPlainMemberThrowsForbidden() { + EventCreate body = validCreate().teamsLinked(List.of(TEAM_ID.toString())); + + assertThatThrownBy(() -> service.createEvent(body, REQUESTER_ID, false)) + .isInstanceOf(ForbiddenException.class); + verify(eventRepository, never()).save(any()); + } + + @Test + void createEventAsTrainerOfLinkedTeamSucceeds() { + when(trainerRepository.existsById(new TrainerEntity.Id(TEAM_ID, REQUESTER_ID))).thenReturn(true); + when(eventRepository.save(any())).thenReturn(eventEntity(EVENT_ID, REQUESTER_ID)); + EventCreate body = validCreate().teamsLinked(List.of(TEAM_ID.toString())); + + Event result = service.createEvent(body, REQUESTER_ID, false); + + assertThat(result.getId()).isEqualTo(EVENT_ID); + } + + @Test + void createEventAsTrainerOfUnrelatedTeamThrowsForbidden() { + // Trainer of some other team, not the one this event links to. + when(trainerRepository.existsById(new TrainerEntity.Id(TEAM_ID, REQUESTER_ID))).thenReturn(false); + when(teamRepository.findAllById(List.of(TEAM_ID))).thenReturn(List.of()); + EventCreate body = validCreate().teamsLinked(List.of(TEAM_ID.toString())); + + assertThatThrownBy(() -> service.createEvent(body, REQUESTER_ID, false)) + .isInstanceOf(ForbiddenException.class); + verify(eventRepository, never()).save(any()); + } + + @Test + void createEventAsDirectorOfLinkedTeamsSportSucceeds() { + TeamEntity team = new TeamEntity(); + team.setId(TEAM_ID); + team.setSportId(SPORT_ID); + when(trainerRepository.existsById(new TrainerEntity.Id(TEAM_ID, REQUESTER_ID))).thenReturn(false); + when(teamRepository.findAllById(List.of(TEAM_ID))).thenReturn(List.of(team)); + when(directorRepository.existsById(new DirectorEntity.Id(SPORT_ID, REQUESTER_ID))).thenReturn(true); + when(eventRepository.save(any())).thenReturn(eventEntity(EVENT_ID, REQUESTER_ID)); + EventCreate body = validCreate().teamsLinked(List.of(TEAM_ID.toString())); + + Event result = service.createEvent(body, REQUESTER_ID, false); + + assertThat(result.getId()).isEqualTo(EVENT_ID); + } + + @Test + void createEventAsDirectorOfExplicitSportSucceeds() { + when(directorRepository.existsById(new DirectorEntity.Id(SPORT_ID, REQUESTER_ID))).thenReturn(true); + when(eventRepository.save(any())).thenReturn(eventEntity(EVENT_ID, REQUESTER_ID)); + EventCreate body = validCreate().sportsLinked(List.of(SPORT_ID)); + + Event result = service.createEvent(body, REQUESTER_ID, false); + + assertThat(result.getId()).isEqualTo(EVENT_ID); + } + // ─── getEventDetails ─────────────────────────────────────────────────────── @Test diff --git a/web-client/src/__tests__/DashboardPage.test.tsx b/web-client/src/__tests__/DashboardPage.test.tsx new file mode 100644 index 00000000..228c9f20 --- /dev/null +++ b/web-client/src/__tests__/DashboardPage.test.tsx @@ -0,0 +1,96 @@ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/features/auth', async () => { + const { MOCK_PERSONAS } = await import('@/mocks/personas') + + return { + useAuth: () => ({ user: MOCK_PERSONAS.coach }), + } +}) + +vi.mock('@/app/pages/api/dashboardQueries', async () => { + const { dashboardFixtures } = await import('@/mocks/fixtures/dashboard') + + return { + useDashboard: () => ({ + data: dashboardFixtures.coach, + isLoading: false, + error: null, + }), + } +}) + +vi.mock('@/features/sport-events/api/queries', async () => { + const { eventSummaryFixtures } = await import('@/mocks/fixtures') + const { MOCK_PERSONAS } = await import('@/mocks/personas') + const { scopeEvents } = await import('@/mocks/scope') + + return { + useEventsList: () => ({ + data: scopeEvents(eventSummaryFixtures, MOCK_PERSONAS.coach), + isLoading: false, + error: null, + }), + } +}) + +vi.mock('@/features/organization/api/queries', () => ({ + useSportsList: () => ({ + data: [], + isLoading: false, + error: null, + }), + useTeamsList: () => ({ + data: [], + isLoading: false, + error: null, + }), +})) + +const { DashboardPage } = await import('@/app/pages/DashboardPage') +const { dashboardFixtures } = await import('@/mocks/fixtures/dashboard') + +describe('DashboardPage', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + vi.clearAllMocks() + document.body.innerHTML = '
' + container = document.getElementById('root') as HTMLDivElement + root = createRoot(container) + }) + + afterEach(async () => { + await act(async () => { + root.unmount() + }) + document.body.innerHTML = '' + }) + + async function render() { + await act(async () => { + root.render( + + + , + ) + }) + } + + it('renders the coach team and roster size from the dashboard fixture', async () => { + const dashboard = dashboardFixtures.coach + + expect(dashboard.role).toBe('trainer') + + await render() + + if (dashboard.role === 'trainer') { + expect(container.textContent).toContain(dashboard.team.name) + expect(container.textContent).toContain(`${dashboard.total_members} roster members`) + } + }) +}) diff --git a/web-client/src/app/pages/DashboardPage.tsx b/web-client/src/app/pages/DashboardPage.tsx index 291116ea..d597422f 100644 --- a/web-client/src/app/pages/DashboardPage.tsx +++ b/web-client/src/app/pages/DashboardPage.tsx @@ -25,6 +25,7 @@ const initials = (name: string) => export function DashboardPage() { const { view, states } = useDashboardViewModel() const showBalanceCard = Boolean(view.myBalance || states.myBalance?.isLoading) + const showTeamCard = Boolean(view.myTeam || states.myTeam?.isLoading) const showEventsCards = Boolean(view.myEvents || states.myEvents?.isLoading) const showFeedbackStat = Boolean(view.myFeedback || states.myFeedback?.isLoading) @@ -40,9 +41,10 @@ export function DashboardPage() { )} - {(showBalanceCard || showEventsCards || showFeedbackStat) && ( + {(showBalanceCard || showTeamCard || showEventsCards || showFeedbackStat) && (
{showBalanceCard && } + {showTeamCard && } {showEventsCards && } {showFeedbackStat && ( @@ -85,6 +87,25 @@ function AdminCountsSection({ ) } +function TeamCard({ + team, + state, +}: { + team?: NonNullable['view']['myTeam']> + state?: DashboardSectionState +}) { + if (state?.isLoading) return + if (!team) return null + + return ( + + ) +} + function BalanceCard({ balance, state, diff --git a/web-client/src/app/pages/model/useDashboardViewModel.ts b/web-client/src/app/pages/model/useDashboardViewModel.ts index 39212f6a..96a101a7 100644 --- a/web-client/src/app/pages/model/useDashboardViewModel.ts +++ b/web-client/src/app/pages/model/useDashboardViewModel.ts @@ -48,6 +48,11 @@ export interface DashboardFeedbackSection { items: DashboardFeedbackItem[] } +export interface DashboardTeamSection { + teamName: string + totalMembers: number +} + export interface DashboardAdminCountsSection { totalTeams: number directors: number @@ -74,6 +79,7 @@ export interface DashboardView { myEvents?: DashboardEventsSection myBalance?: DashboardBalanceSection myFeedback?: DashboardFeedbackSection + myTeam?: DashboardTeamSection adminCounts?: DashboardAdminCountsSection sports?: DashboardSportSection[] } @@ -88,6 +94,7 @@ export interface DashboardViewModel { states: { myEvents?: DashboardSectionState myBalance?: DashboardSectionState + myTeam?: DashboardSectionState myFeedback?: DashboardSectionState adminCounts?: DashboardSectionState sports?: DashboardSectionState @@ -281,6 +288,9 @@ function fillSections( if (shouldShowBalance(view.role)) { states.myBalance = state } + if (view.role === 'trainer') { + states.myTeam = state + } states.myEvents = state } return @@ -297,8 +307,13 @@ function fillSections( break case 'trainer': + view.myTeam = { + teamName: data.team.name, + totalMembers: data.total_members, + } view.myEvents = buildEventsSection(data.upcoming_events, null, org.events) view.myFeedback = buildFeedbackSection(data.recent_feedback) + states.myTeam = state states.myEvents = org.eventsState states.myFeedback = state break diff --git a/web-client/src/components/ui/alert-dialog.tsx b/web-client/src/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..9ae8d2c1 --- /dev/null +++ b/web-client/src/components/ui/alert-dialog.tsx @@ -0,0 +1,147 @@ +"use client" + +import * as React from "react" +import { AlertDialog as AlertDialogPrimitive } from "radix-ui" + +import { buttonVariants } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/web-client/src/components/ui/dialog.tsx b/web-client/src/components/ui/dialog.tsx new file mode 100644 index 00000000..cc97dc0a --- /dev/null +++ b/web-client/src/components/ui/dialog.tsx @@ -0,0 +1,167 @@ +"use client" + +import * as React from "react" +import { XIcon } from "lucide-react" +import { Dialog as DialogPrimitive } from "radix-ui" + +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/web-client/src/components/ui/input.tsx b/web-client/src/components/ui/input.tsx index 14021ac4..b959abfe 100644 --- a/web-client/src/components/ui/input.tsx +++ b/web-client/src/components/ui/input.tsx @@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) { type={type} data-slot="input" className={cn( - "h-10 w-full min-w-0 border border-transparent border-b-input bg-transparent px-0 py-1 text-base transition-[color,border-color] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-text-tertiary focus-visible:border-b-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-b-destructive md:text-sm dark:aria-invalid:border-b-destructive/50", + "h-10 w-full min-w-0 border border-input bg-transparent px-3 py-1 text-base transition-[color,border-color] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-text-tertiary focus-visible:border-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive md:text-sm dark:aria-invalid:border-destructive/50", className )} {...props} diff --git a/web-client/src/components/ui/label.tsx b/web-client/src/components/ui/label.tsx new file mode 100644 index 00000000..a4822b14 --- /dev/null +++ b/web-client/src/components/ui/label.tsx @@ -0,0 +1,22 @@ +import * as React from "react" +import { Label as LabelPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/web-client/src/components/ui/table-toolbar.tsx b/web-client/src/components/ui/table-toolbar.tsx index 15983ef0..00093d27 100644 --- a/web-client/src/components/ui/table-toolbar.tsx +++ b/web-client/src/components/ui/table-toolbar.tsx @@ -24,6 +24,12 @@ export function TableToolbar({ }: TableToolbarProps) { const searchId = useId() const [draftSearch, setDraftSearch] = useState(searchValue) + const [prevSearchValue, setPrevSearchValue] = useState(searchValue) + + if (searchValue !== prevSearchValue) { + setPrevSearchValue(searchValue) + setDraftSearch(searchValue) + } useEffect(() => { const timeout = window.setTimeout(() => onSearchChange(draftSearch), 250) diff --git a/web-client/src/components/ui/textarea.tsx b/web-client/src/components/ui/textarea.tsx new file mode 100644 index 00000000..05bd0936 --- /dev/null +++ b/web-client/src/components/ui/textarea.tsx @@ -0,0 +1,18 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { + return ( +