From 25629ffcc720bed4097bf842353bae8a0b08d829 Mon Sep 17 00:00:00 2001 From: Gaurav Pandey Date: Sat, 25 Jul 2026 14:39:36 +0200 Subject: [PATCH 1/2] refactor: update styling to use theme tokens in TeamOverviewTab and common components fix: replace hardcoded palette colors with theme-based colors in model and theme files test: add unit tests for AddLeaveModal, CalendarTab, EntityLeaveTrackerContent, LeaveTrackerPage, TeamOverviewTab, and common components chore: setup testing environment with jest-dom for better assertions Signed-off-by: Gaurav Pandey --- package.json | 2 +- .../team-leave-tracker-backend/package.json | 4 +- plugins/team-leave-tracker/package.json | 4 +- .../src/components/AddLeaveModal.test.tsx | 109 ++++ .../src/components/AddLeaveModal.tsx | 199 ++++---- .../src/components/CalendarTab.test.tsx | 97 ++++ .../src/components/CalendarTab.tsx | 475 +++++++++--------- .../EntityLeaveTrackerContent.test.tsx | 101 ++++ .../components/EntityLeaveTrackerContent.tsx | 76 +-- .../src/components/LeaveTrackerPage.test.tsx | 97 ++++ .../src/components/LeaveTrackerPage.tsx | 319 ++++++------ .../src/components/TeamOverviewTab.test.tsx | 111 ++++ .../src/components/TeamOverviewTab.tsx | 283 +++++------ .../src/components/common.test.tsx | 51 ++ .../src/components/common.tsx | 89 ++-- plugins/team-leave-tracker/src/data/model.ts | 4 +- plugins/team-leave-tracker/src/setupTests.ts | 1 + plugins/team-leave-tracker/src/theme.ts | 66 ++- 18 files changed, 1357 insertions(+), 731 deletions(-) create mode 100644 plugins/team-leave-tracker/src/components/AddLeaveModal.test.tsx create mode 100644 plugins/team-leave-tracker/src/components/CalendarTab.test.tsx create mode 100644 plugins/team-leave-tracker/src/components/EntityLeaveTrackerContent.test.tsx create mode 100644 plugins/team-leave-tracker/src/components/LeaveTrackerPage.test.tsx create mode 100644 plugins/team-leave-tracker/src/components/TeamOverviewTab.test.tsx create mode 100644 plugins/team-leave-tracker/src/components/common.test.tsx create mode 100644 plugins/team-leave-tracker/src/setupTests.ts diff --git a/package.json b/package.json index ee49f24..850a12b 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "test:all": "backstage-cli repo test --coverage", "test:e2e": "playwright test", "fix": "backstage-cli repo fix", - "lint": "backstage-cli repo lint --since origin/master", + "lint": "backstage-cli repo lint --since origin/main", "lint:all": "backstage-cli repo lint", "prettier:check": "prettier --check .", "prettier:fix": "prettier --write .", diff --git a/plugins/team-leave-tracker-backend/package.json b/plugins/team-leave-tracker-backend/package.json index 793538f..2047ffb 100644 --- a/plugins/team-leave-tracker-backend/package.json +++ b/plugins/team-leave-tracker-backend/package.json @@ -1,6 +1,6 @@ { "name": "@codeverse-gp/plugin-team-leave-tracker-backend", - "version": "1.0.0", + "version": "1.1.0", "description": "A Backstage backend plugin for tracking team leave and vacation.", "license": "MIT", "main": "src/index.ts", @@ -48,7 +48,7 @@ "author": "CodeVerse-GP", "repository": { "type": "git", - "url": "git+https://github.com/CodeVerse-GP/team-leave-tracker.git", + "url": "git+https://github.com/CodeVerse-GP/leave-tracker.git", "directory": "plugins/team-leave-tracker-backend" }, "backstage": { diff --git a/plugins/team-leave-tracker/package.json b/plugins/team-leave-tracker/package.json index 63bbaa8..54546f4 100644 --- a/plugins/team-leave-tracker/package.json +++ b/plugins/team-leave-tracker/package.json @@ -1,6 +1,6 @@ { "name": "@codeverse-gp/plugin-team-leave-tracker", - "version": "1.0.0", + "version": "1.1.0", "description": "A Backstage plugin for tracking team leave and vacation.", "license": "MIT", "sideEffects": false, @@ -51,7 +51,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/CodeVerse-GP/team-leave-tracker.git", + "url": "git+https://github.com/CodeVerse-GP/leave-tracker.git", "directory": "plugins/team-leave-tracker" }, "author": "CodeVerse-GP", diff --git a/plugins/team-leave-tracker/src/components/AddLeaveModal.test.tsx b/plugins/team-leave-tracker/src/components/AddLeaveModal.test.tsx new file mode 100644 index 0000000..a8a5776 --- /dev/null +++ b/plugins/team-leave-tracker/src/components/AddLeaveModal.test.tsx @@ -0,0 +1,109 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; + +import type { LeaveEntry, TeamMember } from '../data/model'; +import { startOfToday, toIso } from '../data/model'; +import { AddLeaveModal } from './AddLeaveModal'; + +const jane: TeamMember = { + id: 'user:default/jane', + name: 'Jane Doe', + initials: 'JD', + color: '#1A2332', +}; + +const todayIso = toIso(startOfToday()); + +const renderModal = ( + props: Partial> = {}, +) => + render( + , + ); + +describe('AddLeaveModal', () => { + it('shows the "Add Leave" title and prefills the current member', () => { + renderModal(); + + const dialog = screen.getByRole('dialog'); + expect(within(dialog).getByText('Add Leave')).toBeInTheDocument(); + expect(screen.getByLabelText('Team member')).toHaveValue('Jane Doe'); + }); + + it('shows the "Edit Leave" title and a save button when editing', () => { + const editingLeave: LeaveEntry = { + id: 'leave-1', + memberId: jane.id, + startDate: todayIso, + endDate: todayIso, + type: 'sick', + }; + + renderModal({ editingLeave }); + + const dialog = screen.getByRole('dialog'); + expect(within(dialog).getByText('Edit Leave')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /save changes/i }), + ).toBeInTheDocument(); + }); + + it('validates that the end date is not before the start date', () => { + const onSubmit = jest.fn(); + renderModal({ onSubmit }); + + fireEvent.change(screen.getByLabelText('Start date'), { + target: { value: '2026-07-10' }, + }); + fireEvent.change(screen.getByLabelText('End date'), { + target: { value: '2026-07-05' }, + }); + fireEvent.click(screen.getByRole('button', { name: /add leave/i })); + + expect( + screen.getByText('End date cannot be before the start date.'), + ).toBeInTheDocument(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('submits a valid leave and closes the modal', () => { + const onSubmit = jest.fn(); + const onClose = jest.fn(); + renderModal({ onSubmit, onClose }); + + fireEvent.change(screen.getByLabelText('Start date'), { + target: { value: '2026-07-05' }, + }); + fireEvent.change(screen.getByLabelText('End date'), { + target: { value: '2026-07-07' }, + }); + fireEvent.change(screen.getByLabelText('Leave type'), { + target: { value: 'conference' }, + }); + fireEvent.click(screen.getByRole('button', { name: /add leave/i })); + + expect(onSubmit).toHaveBeenCalledWith({ + memberId: jane.id, + startDate: '2026-07-05', + endDate: '2026-07-07', + type: 'conference', + note: undefined, + }); + expect(onClose).toHaveBeenCalled(); + }); + + it('calls onClose when cancelled', () => { + const onClose = jest.fn(); + renderModal({ onClose }); + + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/plugins/team-leave-tracker/src/components/AddLeaveModal.tsx b/plugins/team-leave-tracker/src/components/AddLeaveModal.tsx index 57c5074..e75b301 100644 --- a/plugins/team-leave-tracker/src/components/AddLeaveModal.tsx +++ b/plugins/team-leave-tracker/src/components/AddLeaveModal.tsx @@ -6,7 +6,7 @@ import { useEffect, useState } from 'react'; import type { LeaveEntry, LeaveTypeId, TeamMember } from '../data/model'; import { LEAVE_TYPES, startOfToday, toIso } from '../data/model'; -import { fonts, palette } from '../theme'; +import { fonts, themeTokens } from '../theme'; export interface NewLeaveInput { memberId: string; @@ -16,103 +16,106 @@ export interface NewLeaveInput { note?: string; } -const useStyles = makeStyles()({ - paper: { - backgroundColor: palette.surface, - borderRadius: 14, - border: `1px solid ${palette.border}`, - width: '100%', - maxWidth: 440, - }, - header: { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - padding: '20px 24px 0', - }, - title: { - fontFamily: fonts.body, - fontSize: 18, - fontWeight: 700, - color: palette.navy, - letterSpacing: '-0.01em', - }, - close: { - border: 'none', - background: 'transparent', - color: palette.muted, - cursor: 'pointer', - display: 'inline-flex', - padding: 4, - borderRadius: 6, - '&:hover': { color: palette.navy, backgroundColor: palette.paper }, - }, - form: { - padding: '16px 24px 24px', - display: 'flex', - flexDirection: 'column', - gap: 16, - }, - field: { display: 'flex', flexDirection: 'column', gap: 6 }, - label: { - fontFamily: fonts.mono, - fontSize: 11, - letterSpacing: '0.06em', - textTransform: 'uppercase', - color: palette.muted, - }, - input: { - fontFamily: fonts.body, - fontSize: 14, - color: palette.navy, - padding: '10px 12px', - borderRadius: 8, - border: `1px solid ${palette.border}`, - backgroundColor: '#FFFFFF', - outline: 'none', - transition: 'border-color 150ms ease', - '&:focus': { borderColor: palette.navy }, - }, - row: { display: 'flex', gap: 12 }, - rowField: { flex: 1 }, - error: { - fontFamily: fonts.mono, - fontSize: 12, - color: palette.terracotta, - }, - actions: { - display: 'flex', - justifyContent: 'flex-end', - gap: 10, - marginTop: 4, - }, - cancel: { - fontFamily: fonts.mono, - fontSize: 12, - letterSpacing: '0.04em', - textTransform: 'uppercase', - padding: '10px 16px', - borderRadius: 8, - border: `1px solid ${palette.border}`, - background: 'transparent', - color: palette.navy, - cursor: 'pointer', - '&:hover': { borderColor: palette.borderStrong }, - }, - submit: { - fontFamily: fonts.mono, - fontSize: 12, - letterSpacing: '0.04em', - textTransform: 'uppercase', - padding: '10px 18px', - borderRadius: 8, - border: `1px solid ${palette.terracotta}`, - backgroundColor: palette.terracotta, - color: '#FBFAF7', - cursor: 'pointer', - transition: 'opacity 150ms ease', - '&:hover': { opacity: 0.9 }, - }, +const useStyles = makeStyles()(theme => { + const palette = themeTokens(theme); + return { + paper: { + backgroundColor: palette.surface, + borderRadius: 14, + border: `1px solid ${palette.border}`, + width: '100%', + maxWidth: 440, + }, + header: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '20px 24px 0', + }, + title: { + fontFamily: fonts.body, + fontSize: 18, + fontWeight: 700, + color: palette.navy, + letterSpacing: '-0.01em', + }, + close: { + border: 'none', + background: 'transparent', + color: palette.muted, + cursor: 'pointer', + display: 'inline-flex', + padding: 4, + borderRadius: 6, + '&:hover': { color: palette.navy, backgroundColor: palette.paper }, + }, + form: { + padding: '16px 24px 24px', + display: 'flex', + flexDirection: 'column', + gap: 16, + }, + field: { display: 'flex', flexDirection: 'column', gap: 6 }, + label: { + fontFamily: fonts.mono, + fontSize: 11, + letterSpacing: '0.06em', + textTransform: 'uppercase', + color: palette.muted, + }, + input: { + fontFamily: fonts.body, + fontSize: 14, + color: palette.navy, + padding: '10px 12px', + borderRadius: 8, + border: `1px solid ${palette.border}`, + backgroundColor: palette.surface, + outline: 'none', + transition: 'border-color 150ms ease', + '&:focus': { borderColor: palette.navy }, + }, + row: { display: 'flex', gap: 12 }, + rowField: { flex: 1 }, + error: { + fontFamily: fonts.mono, + fontSize: 12, + color: palette.terracotta, + }, + actions: { + display: 'flex', + justifyContent: 'flex-end', + gap: 10, + marginTop: 4, + }, + cancel: { + fontFamily: fonts.mono, + fontSize: 12, + letterSpacing: '0.04em', + textTransform: 'uppercase', + padding: '10px 16px', + borderRadius: 8, + border: `1px solid ${palette.border}`, + background: 'transparent', + color: palette.navy, + cursor: 'pointer', + '&:hover': { borderColor: palette.borderStrong }, + }, + submit: { + fontFamily: fonts.mono, + fontSize: 12, + letterSpacing: '0.04em', + textTransform: 'uppercase', + padding: '10px 18px', + borderRadius: 8, + border: `1px solid ${palette.terracotta}`, + backgroundColor: palette.terracotta, + color: palette.onAccent, + cursor: 'pointer', + transition: 'opacity 150ms ease', + '&:hover': { opacity: 0.9 }, + }, + }; }); export const AddLeaveModal = ({ diff --git a/plugins/team-leave-tracker/src/components/CalendarTab.test.tsx b/plugins/team-leave-tracker/src/components/CalendarTab.test.tsx new file mode 100644 index 0000000..7756f8d --- /dev/null +++ b/plugins/team-leave-tracker/src/components/CalendarTab.test.tsx @@ -0,0 +1,97 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import type { LeaveEntry, TeamMember } from '../data/model'; +import { addDays, startOfToday, toIso } from '../data/model'; +import { CalendarTab } from './CalendarTab'; + +const jane: TeamMember = { + id: 'user:default/jane', + name: 'Jane Doe', + initials: 'JD', + color: '#1A2332', +}; + +const today = startOfToday(); +const todayIso = toIso(today); + +const janeLeave: LeaveEntry = { + id: 'leave-1', + memberId: jane.id, + startDate: todayIso, + endDate: toIso(addDays(today, 1)), + type: 'annual', +}; + +const currentMonthLabel = new Date( + today.getFullYear(), + today.getMonth(), + 1, +).toLocaleDateString(undefined, { month: 'long', year: 'numeric' }); + +const nextMonthLabel = new Date( + today.getFullYear(), + today.getMonth() + 1, + 1, +).toLocaleDateString(undefined, { month: 'long', year: 'numeric' }); + +const renderTab = ( + props: Partial> = {}, +) => render(); + +describe('CalendarTab', () => { + it('renders the current month and year label', () => { + renderTab(); + + expect(screen.getByText(currentMonthLabel)).toBeInTheDocument(); + }); + + it('renders the leave type legend', () => { + renderTab(); + + // These types have no leaves in the fixture, so they only appear in the + // legend (a single match each). + expect(screen.getByText('Sick')).toBeInTheDocument(); + expect(screen.getByText('Personal')).toBeInTheDocument(); + expect(screen.getByText('Conference')).toBeInTheDocument(); + expect(screen.getByText('Parental')).toBeInTheDocument(); + }); + + it('renders a pill for a leave that falls in the visible month', () => { + renderTab(); + + // Member initials appear in the day pill. + expect(screen.getAllByText('JD').length).toBeGreaterThan(0); + }); + + it('lists upcoming leaves in the sidebar', () => { + renderTab(); + + expect(screen.getByText('Jane Doe')).toBeInTheDocument(); + expect(screen.getByText(/on leave now/i)).toBeInTheDocument(); + }); + + it('shows an empty sidebar message when there are no upcoming leaves', () => { + renderTab({ leaves: [] }); + + expect( + screen.getByText('No upcoming leaves scheduled.'), + ).toBeInTheDocument(); + }); + + it('navigates to the next month', () => { + renderTab(); + + fireEvent.click(screen.getByRole('button', { name: /next month/i })); + + expect(screen.getByText(nextMonthLabel)).toBeInTheDocument(); + }); + + it('returns to the current month with the Today button', () => { + renderTab(); + + fireEvent.click(screen.getByRole('button', { name: /next month/i })); + fireEvent.click(screen.getByRole('button', { name: /^today$/i })); + + expect(screen.getByText(currentMonthLabel)).toBeInTheDocument(); + }); +}); diff --git a/plugins/team-leave-tracker/src/components/CalendarTab.tsx b/plugins/team-leave-tracker/src/components/CalendarTab.tsx index 834548d..20ff403 100644 --- a/plugins/team-leave-tracker/src/components/CalendarTab.tsx +++ b/plugins/team-leave-tracker/src/components/CalendarTab.tsx @@ -14,7 +14,7 @@ import { startOfToday, toIso, } from '../data/model'; -import { fonts, palette } from '../theme'; +import { fonts, themeTokens } from '../theme'; import { LeaveTypeLegend, MemberAvatar, TypeBadge } from './common'; const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; @@ -59,243 +59,246 @@ function buildMonthGrid(viewYear: number, viewMonth: number): CalendarCell[] { return cells; } -const useStyles = makeStyles()({ - root: { - display: 'grid', - gridTemplateColumns: 'minmax(0, 1fr) 320px', - gap: 24, - alignItems: 'start', - '@media (max-width: 1100px)': { gridTemplateColumns: '1fr' }, - }, - main: { minWidth: 0 }, - toolbar: { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - gap: 16, - marginBottom: 16, - flexWrap: 'wrap', - }, - monthLabel: { - fontFamily: fonts.body, - fontSize: 20, - fontWeight: 700, - color: palette.navy, - letterSpacing: '-0.01em', - }, - navGroup: { display: 'flex', alignItems: 'center', gap: 8 }, - navButton: { - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - width: 34, - height: 34, - borderRadius: 8, - border: `1px solid ${palette.border}`, - background: palette.surface, - color: palette.navy, - cursor: 'pointer', - transition: 'border-color 150ms ease, background-color 150ms ease', - '&:hover': { - borderColor: palette.borderStrong, - backgroundColor: '#FFFFFF', +const useStyles = makeStyles()(theme => { + const palette = themeTokens(theme); + return { + root: { + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr) 320px', + gap: 24, + alignItems: 'start', + '@media (max-width: 1100px)': { gridTemplateColumns: '1fr' }, }, - '& svg': { fontSize: 20 }, - }, - todayButton: { - fontFamily: fonts.mono, - fontSize: 12, - letterSpacing: '0.04em', - textTransform: 'uppercase', - padding: '0 14px', - height: 34, - borderRadius: 8, - border: `1px solid ${palette.border}`, - background: palette.surface, - color: palette.navy, - cursor: 'pointer', - transition: 'border-color 150ms ease, background-color 150ms ease', - '&:hover': { - borderColor: palette.borderStrong, - backgroundColor: '#FFFFFF', + main: { minWidth: 0 }, + toolbar: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 16, + marginBottom: 16, + flexWrap: 'wrap', }, - }, - legendRow: { marginBottom: 16 }, - grid: { - border: `1px solid ${palette.border}`, - borderRadius: 12, - overflow: 'hidden', - backgroundColor: palette.border, - display: 'grid', - gridTemplateColumns: 'repeat(7, 1fr)', - gap: 1, - }, - weekdayHead: { - backgroundColor: palette.surface, - fontFamily: fonts.mono, - fontSize: 11, - letterSpacing: '0.08em', - textTransform: 'uppercase', - color: palette.muted, - textAlign: 'center', - padding: '10px 0', - }, - cell: { - backgroundColor: palette.surface, - minHeight: 104, - padding: 8, - display: 'flex', - flexDirection: 'column', - gap: 6, - textAlign: 'left', - border: 'none', - font: 'inherit', - cursor: 'default', - transition: 'background-color 150ms ease', - }, - cellClickable: { - cursor: 'pointer', - '&:hover': { backgroundColor: '#FFFFFF' }, - }, - cellOutside: { backgroundColor: '#EEEBE4' }, - dateNumber: { - fontFamily: fonts.mono, - fontSize: 13, - fontWeight: 500, - color: palette.navy, - alignSelf: 'flex-start', - width: 24, - height: 24, - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - borderRadius: '50%', - }, - dateNumberOutside: { color: '#A8A294' }, - dateNumberToday: { - backgroundColor: palette.terracotta, - color: '#FBFAF7', - }, - pill: { - display: 'flex', - alignItems: 'center', - gap: 6, - padding: '2px 6px', - borderRadius: 6, - fontFamily: fonts.mono, - fontSize: 11, - fontWeight: 500, - color: palette.navy, - overflow: 'hidden', - whiteSpace: 'nowrap', - textOverflow: 'ellipsis', - }, - pillDot: { width: 7, height: 7, borderRadius: '50%', flexShrink: 0 }, - pillInitials: { fontWeight: 600 }, - moreLabel: { - fontFamily: fonts.mono, - fontSize: 10, - color: palette.muted, - paddingLeft: 6, - }, - // Sidebar - sidebar: { - border: `1px solid ${palette.border}`, - borderRadius: 12, - backgroundColor: palette.surface, - padding: 16, - }, - sidebarTitle: { - fontFamily: fonts.mono, - fontSize: 11, - letterSpacing: '0.08em', - textTransform: 'uppercase', - color: palette.muted, - marginBottom: 12, - }, - upcomingItem: { - display: 'flex', - gap: 12, - padding: '10px 0', - borderTop: `1px solid ${palette.border}`, - }, - upcomingActive: { - backgroundColor: `${palette.terracotta}0F`, - borderRadius: 8, - padding: 10, - margin: '6px 0', - borderTop: 'none', - }, - upcomingBody: { minWidth: 0, flex: 1 }, - upcomingName: { - fontFamily: fonts.body, - fontSize: 14, - fontWeight: 600, - color: palette.navy, - }, - upcomingMeta: { - fontFamily: fonts.mono, - fontSize: 11, - color: palette.muted, - marginTop: 2, - }, - activeTag: { - fontFamily: fonts.mono, - fontSize: 10, - fontWeight: 600, - letterSpacing: '0.06em', - textTransform: 'uppercase', - color: palette.terracotta, - }, - empty: { - fontFamily: fonts.body, - fontSize: 13, - color: palette.muted, - padding: '12px 0', - }, - // Popover - popover: { - padding: 16, - minWidth: 260, - maxWidth: 320, - backgroundColor: palette.surface, - border: `1px solid ${palette.border}`, - }, - popoverTitle: { - fontFamily: fonts.mono, - fontSize: 11, - letterSpacing: '0.06em', - textTransform: 'uppercase', - color: palette.muted, - marginBottom: 12, - }, - popRow: { - display: 'flex', - gap: 12, - paddingTop: 12, - marginTop: 12, - borderTop: `1px solid ${palette.border}`, - '&:first-child': { paddingTop: 0, marginTop: 0, borderTop: 'none' }, - }, - popBody: { minWidth: 0, flex: 1 }, - popName: { - fontFamily: fonts.body, - fontSize: 14, - fontWeight: 600, - color: palette.navy, - }, - popMeta: { - fontFamily: fonts.mono, - fontSize: 11, - color: palette.muted, - margin: '4px 0 6px', - }, - popNote: { - fontFamily: fonts.body, - fontSize: 12, - color: palette.muted, - fontStyle: 'italic', - }, + monthLabel: { + fontFamily: fonts.body, + fontSize: 20, + fontWeight: 700, + color: palette.navy, + letterSpacing: '-0.01em', + }, + navGroup: { display: 'flex', alignItems: 'center', gap: 8 }, + navButton: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: 34, + height: 34, + borderRadius: 8, + border: `1px solid ${palette.border}`, + background: palette.surface, + color: palette.navy, + cursor: 'pointer', + transition: 'border-color 150ms ease, background-color 150ms ease', + '&:hover': { + borderColor: palette.borderStrong, + backgroundColor: palette.hover, + }, + '& svg': { fontSize: 20 }, + }, + todayButton: { + fontFamily: fonts.mono, + fontSize: 12, + letterSpacing: '0.04em', + textTransform: 'uppercase', + padding: '0 14px', + height: 34, + borderRadius: 8, + border: `1px solid ${palette.border}`, + background: palette.surface, + color: palette.navy, + cursor: 'pointer', + transition: 'border-color 150ms ease, background-color 150ms ease', + '&:hover': { + borderColor: palette.borderStrong, + backgroundColor: palette.hover, + }, + }, + legendRow: { marginBottom: 16 }, + grid: { + border: `1px solid ${palette.border}`, + borderRadius: 12, + overflow: 'hidden', + backgroundColor: palette.border, + display: 'grid', + gridTemplateColumns: 'repeat(7, 1fr)', + gap: 1, + }, + weekdayHead: { + backgroundColor: palette.surface, + fontFamily: fonts.mono, + fontSize: 11, + letterSpacing: '0.08em', + textTransform: 'uppercase', + color: palette.muted, + textAlign: 'center', + padding: '10px 0', + }, + cell: { + backgroundColor: palette.surface, + minHeight: 104, + padding: 8, + display: 'flex', + flexDirection: 'column', + gap: 6, + textAlign: 'left', + border: 'none', + font: 'inherit', + cursor: 'default', + transition: 'background-color 150ms ease', + }, + cellClickable: { + cursor: 'pointer', + '&:hover': { backgroundColor: palette.hover }, + }, + cellOutside: { backgroundColor: palette.paper }, + dateNumber: { + fontFamily: fonts.mono, + fontSize: 13, + fontWeight: 500, + color: palette.navy, + alignSelf: 'flex-start', + width: 24, + height: 24, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: '50%', + }, + dateNumberOutside: { color: palette.muted }, + dateNumberToday: { + backgroundColor: palette.terracotta, + color: palette.onAccent, + }, + pill: { + display: 'flex', + alignItems: 'center', + gap: 6, + padding: '2px 6px', + borderRadius: 6, + fontFamily: fonts.mono, + fontSize: 11, + fontWeight: 500, + color: palette.navy, + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + }, + pillDot: { width: 7, height: 7, borderRadius: '50%', flexShrink: 0 }, + pillInitials: { fontWeight: 600 }, + moreLabel: { + fontFamily: fonts.mono, + fontSize: 10, + color: palette.muted, + paddingLeft: 6, + }, + // Sidebar + sidebar: { + border: `1px solid ${palette.border}`, + borderRadius: 12, + backgroundColor: palette.surface, + padding: 16, + }, + sidebarTitle: { + fontFamily: fonts.mono, + fontSize: 11, + letterSpacing: '0.08em', + textTransform: 'uppercase', + color: palette.muted, + marginBottom: 12, + }, + upcomingItem: { + display: 'flex', + gap: 12, + padding: '10px 0', + borderTop: `1px solid ${palette.border}`, + }, + upcomingActive: { + backgroundColor: `${palette.terracotta}0F`, + borderRadius: 8, + padding: 10, + margin: '6px 0', + borderTop: 'none', + }, + upcomingBody: { minWidth: 0, flex: 1 }, + upcomingName: { + fontFamily: fonts.body, + fontSize: 14, + fontWeight: 600, + color: palette.navy, + }, + upcomingMeta: { + fontFamily: fonts.mono, + fontSize: 11, + color: palette.muted, + marginTop: 2, + }, + activeTag: { + fontFamily: fonts.mono, + fontSize: 10, + fontWeight: 600, + letterSpacing: '0.06em', + textTransform: 'uppercase', + color: palette.terracotta, + }, + empty: { + fontFamily: fonts.body, + fontSize: 13, + color: palette.muted, + padding: '12px 0', + }, + // Popover + popover: { + padding: 16, + minWidth: 260, + maxWidth: 320, + backgroundColor: palette.surface, + border: `1px solid ${palette.border}`, + }, + popoverTitle: { + fontFamily: fonts.mono, + fontSize: 11, + letterSpacing: '0.06em', + textTransform: 'uppercase', + color: palette.muted, + marginBottom: 12, + }, + popRow: { + display: 'flex', + gap: 12, + paddingTop: 12, + marginTop: 12, + borderTop: `1px solid ${palette.border}`, + '&:first-child': { paddingTop: 0, marginTop: 0, borderTop: 'none' }, + }, + popBody: { minWidth: 0, flex: 1 }, + popName: { + fontFamily: fonts.body, + fontSize: 14, + fontWeight: 600, + color: palette.navy, + }, + popMeta: { + fontFamily: fonts.mono, + fontSize: 11, + color: palette.muted, + margin: '4px 0 6px', + }, + popNote: { + fontFamily: fonts.body, + fontSize: 12, + color: palette.muted, + fontStyle: 'italic', + }, + }; }); export const CalendarTab = ({ diff --git a/plugins/team-leave-tracker/src/components/EntityLeaveTrackerContent.test.tsx b/plugins/team-leave-tracker/src/components/EntityLeaveTrackerContent.test.tsx new file mode 100644 index 0000000..1b26997 --- /dev/null +++ b/plugins/team-leave-tracker/src/components/EntityLeaveTrackerContent.test.tsx @@ -0,0 +1,101 @@ +import { identityApiRef } from '@backstage/core-plugin-api'; +import type { Entity } from '@backstage/catalog-model'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { + mockApis, + renderInTestApp, + TestApiProvider, +} from '@backstage/frontend-test-utils'; +import { screen } from '@testing-library/react'; + +import type { TeamMember } from '../data/model'; +import { useGroupMembers } from '../hooks/useGroupMembers'; +import { useLeaves } from '../api/useLeaves'; +import { EntityLeaveTrackerContent } from './EntityLeaveTrackerContent'; + +jest.mock('../hooks/useGroupMembers'); +jest.mock('../api/useLeaves'); + +const mockUseGroupMembers = useGroupMembers as jest.MockedFunction< + typeof useGroupMembers +>; +const mockUseLeaves = useLeaves as jest.MockedFunction; + +const jane: TeamMember = { + id: 'user:default/jane', + name: 'Jane Doe', + initials: 'JD', + color: '#1A2332', +}; + +const groupEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'team-a', namespace: 'default', title: 'Team Atlas' }, + spec: { type: 'team', children: [] }, +}; + +const renderContent = (ownershipEntityRefs: string[]) => + renderInTestApp( + + + + + , + ); + +describe('EntityLeaveTrackerContent', () => { + beforeEach(() => { + mockUseGroupMembers.mockReturnValue({ members: [jane], loading: false }); + mockUseLeaves.mockReturnValue({ + leaves: [], + loading: false, + addLeave: jest.fn().mockResolvedValue(undefined), + updateLeave: jest.fn().mockResolvedValue(undefined), + removeLeave: jest.fn().mockResolvedValue(undefined), + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the tracker for a catalog member without ownership refs', async () => { + // Guest-style identity: no group ownership refs, but the user is listed in + // the group's catalog members. + mockUseGroupMembers.mockReturnValue({ members: [jane], loading: false }); + + await renderContent([]); + + expect(await screen.findByText('Team Leave Tracker')).toBeInTheDocument(); + }); + + it('renders the tracker when ownership refs include the group', async () => { + // Not a catalog member, but the identity owns the group. + mockUseGroupMembers.mockReturnValue({ members: [], loading: false }); + + await renderContent(['group:default/team-a']); + + expect(await screen.findByText('Team Leave Tracker')).toBeInTheDocument(); + }); + + it('denies access to non-members', async () => { + // Neither a catalog member nor an owner of the group. + mockUseGroupMembers.mockReturnValue({ members: [], loading: false }); + + await renderContent([]); + + expect(await screen.findByText('Members only')).toBeInTheDocument(); + expect(screen.queryByText('Team Leave Tracker')).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/team-leave-tracker/src/components/EntityLeaveTrackerContent.tsx b/plugins/team-leave-tracker/src/components/EntityLeaveTrackerContent.tsx index ec78f24..1a42430 100644 --- a/plugins/team-leave-tracker/src/components/EntityLeaveTrackerContent.tsx +++ b/plugins/team-leave-tracker/src/components/EntityLeaveTrackerContent.tsx @@ -5,35 +5,35 @@ import { useEntity } from '@backstage/plugin-catalog-react'; import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; import { makeStyles } from 'tss-react/mui'; import { useEffect, useState } from 'react'; - import { useLeaves } from '../api/useLeaves'; import type { TeamMember } from '../data/model'; import { useGroupMembers } from '../hooks/useGroupMembers'; -import { fonts, palette } from '../theme'; +import { fonts, themeTokens } from '../theme'; import { LeaveTrackerPage } from './LeaveTrackerPage'; -const useStyles = makeStyles()({ - denied: { - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - gap: 12, - textAlign: 'center', - padding: '64px 24px', - color: palette.muted, - fontFamily: fonts.body, - '& svg': { fontSize: 40, color: palette.terracotta }, - }, - deniedTitle: { - fontSize: 18, - fontWeight: 700, - color: palette.navy, - }, - deniedBody: { fontSize: 14, maxWidth: 420 }, +const useStyles = makeStyles()(theme => { + const palette = themeTokens(theme); + return { + denied: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 12, + textAlign: 'center', + padding: '64px 24px', + color: palette.muted, + fontFamily: fonts.body, + '& svg': { fontSize: 40, color: palette.terracotta }, + }, + deniedTitle: { + fontSize: 18, + fontWeight: 700, + color: palette.navy, + }, + deniedBody: { fontSize: 14, maxWidth: 420 }, + }; }); -type MembershipState = 'denied' | 'loading' | 'member'; - /** * Loads persisted leaves for the group from the backend and renders the * tracker. Split out so the data hook only runs once membership is confirmed. @@ -71,15 +71,19 @@ const LeaveTrackerDataView = ({ /** * Renders the Team Leave Tracker only when the signed-in user is a member of - * the group entity the tab is attached to. Membership is derived from the - * identity API's ownership refs, which include every group the user belongs to. + * the group entity the tab is attached to. Membership is granted when the user + * appears in the group's catalog members (its `hasMember` relations) or when + * the identity's ownership refs include the group. Checking the catalog members + * makes this robust to identity providers (such as the guest provider) that do + * not populate group memberships in the ownership refs. */ export const EntityLeaveTrackerContent = () => { const { classes } = useStyles(); const { entity } = useEntity(); const identityApi = useApi(identityApiRef); - const [state, setState] = useState('loading'); + const [identityResolved, setIdentityResolved] = useState(false); const [currentUserRef, setCurrentUserRef] = useState(''); + const [ownedRefs, setOwnedRefs] = useState([]); const { members, loading: membersLoading } = useGroupMembers(entity); const groupRef = stringifyEntityRef(entity).toLocaleLowerCase('en-US'); @@ -93,27 +97,35 @@ export const EntityLeaveTrackerContent = () => { return; } setCurrentUserRef(identity.userEntityRef.toLocaleLowerCase('en-US')); - const ownedRefs = identity.ownershipEntityRefs.map(ref => - ref.toLocaleLowerCase('en-US'), + setOwnedRefs( + identity.ownershipEntityRefs.map(ref => + ref.toLocaleLowerCase('en-US'), + ), ); - setState(ownedRefs.includes(groupRef) ? 'member' : 'denied'); + setIdentityResolved(true); }) .catch(() => { if (!cancelled) { - setState('denied'); + setIdentityResolved(true); } }); return () => { cancelled = true; }; - }, [entity, identityApi, groupRef]); + }, [identityApi]); - if (state === 'loading' || membersLoading) { + if (!identityResolved || membersLoading) { return ; } - if (state === 'denied') { + const isMember = + ownedRefs.includes(groupRef) || + members.some( + member => member.id.toLocaleLowerCase('en-US') === currentUserRef, + ); + + if (!isMember) { return (
diff --git a/plugins/team-leave-tracker/src/components/LeaveTrackerPage.test.tsx b/plugins/team-leave-tracker/src/components/LeaveTrackerPage.test.tsx new file mode 100644 index 0000000..917b519 --- /dev/null +++ b/plugins/team-leave-tracker/src/components/LeaveTrackerPage.test.tsx @@ -0,0 +1,97 @@ +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; + +import type { LeaveEntry, TeamMember } from '../data/model'; +import { addDays, startOfToday, toIso } from '../data/model'; +import { LeaveTrackerPage } from './LeaveTrackerPage'; + +const jane: TeamMember = { + id: 'user:default/jane', + name: 'Jane Doe', + initials: 'JD', + color: '#1A2332', +}; + +const today = startOfToday(); + +const janeLeave: LeaveEntry = { + id: 'leave-1', + memberId: jane.id, + startDate: toIso(today), + endDate: toIso(addDays(today, 1)), + type: 'annual', +}; + +const renderPage = ( + props: Partial> = {}, +) => + render( + , + ); + +describe('LeaveTrackerPage', () => { + it('renders the header and defaults to the calendar tab', () => { + renderPage(); + + expect(screen.getByText('Team Leave Tracker')).toBeInTheDocument(); + // The calendar month label is only present on the calendar tab. + const monthLabel = new Date( + today.getFullYear(), + today.getMonth(), + 1, + ).toLocaleDateString(undefined, { month: 'long', year: 'numeric' }); + expect(screen.getByText(monthLabel)).toBeInTheDocument(); + }); + + it('shows the "on leave today" indicator', () => { + renderPage(); + + expect(screen.getByText(/on leave today/i)).toBeInTheDocument(); + }); + + it('switches to the Team Overview tab', () => { + renderPage(); + + fireEvent.click(screen.getByRole('button', { name: /team overview/i })); + + expect(screen.getByText('Total leave days')).toBeInTheDocument(); + }); + + it('opens the add-leave modal and submits a new leave', async () => { + const onAddLeave = jest.fn().mockResolvedValue(undefined); + renderPage({ onAddLeave, leaves: [] }); + + fireEvent.click(screen.getByRole('button', { name: /^add leave$/i })); + + const dialog = screen.getByRole('dialog'); + fireEvent.change(within(dialog).getByLabelText('Start date'), { + target: { value: '2026-07-05' }, + }); + fireEvent.change(within(dialog).getByLabelText('End date'), { + target: { value: '2026-07-07' }, + }); + fireEvent.click(within(dialog).getByRole('button', { name: /add leave/i })); + + await waitFor(() => expect(onAddLeave).toHaveBeenCalledTimes(1)); + expect(onAddLeave).toHaveBeenCalledWith( + expect.objectContaining({ + memberId: jane.id, + startDate: '2026-07-05', + endDate: '2026-07-07', + }), + ); + }); +}); diff --git a/plugins/team-leave-tracker/src/components/LeaveTrackerPage.tsx b/plugins/team-leave-tracker/src/components/LeaveTrackerPage.tsx index cd9e1b6..41599a6 100644 --- a/plugins/team-leave-tracker/src/components/LeaveTrackerPage.tsx +++ b/plugins/team-leave-tracker/src/components/LeaveTrackerPage.tsx @@ -6,7 +6,7 @@ import { useMemo, useState } from 'react'; import type { EditLeaveInput } from '../api/useLeaves'; import type { LeaveEntry, TeamMember } from '../data/model'; import { isLeaveOnDay, startOfToday, toIso } from '../data/model'; -import { fonts, palette, useArchivalFonts } from '../theme'; +import { fonts, themeTokens, useArchivalFonts } from '../theme'; import type { NewLeaveInput } from './AddLeaveModal'; import { AddLeaveModal } from './AddLeaveModal'; import { CalendarTab } from './CalendarTab'; @@ -28,163 +28,166 @@ const TABS: { id: TabId; label: string }[] = [ { id: 'overview', label: 'Team Overview' }, ]; -const useStyles = makeStyles()({ - root: { - backgroundColor: palette.paper, - color: palette.navy, - fontFamily: fonts.body, - minHeight: '100%', - }, - header: { - position: 'sticky', - top: 0, - zIndex: 10, - backgroundColor: palette.paper, - borderBottom: `1px solid ${palette.border}`, - padding: '20px 28px 0', - }, - titleRow: { - display: 'flex', - alignItems: 'flex-start', - justifyContent: 'space-between', - gap: 16, - flexWrap: 'wrap', - }, - titleBlock: { display: 'flex', alignItems: 'center', gap: 12 }, - title: { - fontFamily: fonts.body, - fontSize: 24, - fontWeight: 700, - letterSpacing: '-0.02em', - color: palette.navy, - }, - rightGroup: { display: 'flex', alignItems: 'center', gap: 12 }, - onLeaveToday: { - display: 'inline-flex', - alignItems: 'center', - gap: 8, - fontFamily: fonts.mono, - fontSize: 12, - fontWeight: 600, - letterSpacing: '0.03em', - color: palette.terracotta, - backgroundColor: `${palette.terracotta}14`, - border: `1px solid ${palette.terracotta}33`, - borderRadius: 999, - padding: '6px 12px', - }, - pulse: { - width: 8, - height: 8, - borderRadius: '50%', - backgroundColor: palette.terracotta, - }, - requestButton: { - display: 'inline-flex', - alignItems: 'center', - gap: 8, - fontFamily: fonts.mono, - fontSize: 12, - fontWeight: 600, - letterSpacing: '0.04em', - textTransform: 'uppercase', - padding: '9px 16px', - borderRadius: 8, - border: `1px solid ${palette.navy}`, - backgroundColor: palette.navy, - color: '#FBFAF7', - cursor: 'pointer', - transition: 'opacity 150ms ease', - '&:hover': { opacity: 0.9 }, - '& svg': { fontSize: 18 }, - }, - tabBar: { - display: 'flex', - gap: 28, - marginTop: 18, - }, - tab: { - fontFamily: fonts.mono, - fontSize: 13, - letterSpacing: '0.04em', - textTransform: 'uppercase', - padding: '10px 2px', - border: 'none', - borderBottom: '2px solid transparent', - background: 'transparent', - color: palette.muted, - cursor: 'pointer', - transition: 'color 150ms ease, border-color 150ms ease', - '&:hover': { color: palette.navy }, - }, - tabActive: { - color: palette.navy, - borderBottomColor: palette.terracotta, - }, - content: { padding: 28 }, - addError: { - fontFamily: fonts.mono, - fontSize: 12, - color: palette.terracotta, - backgroundColor: `${palette.terracotta}14`, - border: `1px solid ${palette.terracotta}33`, - borderRadius: 8, - padding: '8px 12px', - margin: '0 28px', - }, - confirmPaper: { - backgroundColor: palette.surface, - borderRadius: 14, - border: `1px solid ${palette.border}`, - width: '100%', - maxWidth: 400, - padding: 24, - }, - confirmTitle: { - fontFamily: fonts.body, - fontSize: 18, - fontWeight: 700, - color: palette.navy, - }, - confirmBody: { - fontFamily: fonts.body, - fontSize: 14, - color: palette.muted, - margin: '12px 0 20px', - }, - confirmActions: { - display: 'flex', - justifyContent: 'flex-end', - gap: 10, - }, - confirmCancel: { - fontFamily: fonts.mono, - fontSize: 12, - letterSpacing: '0.04em', - textTransform: 'uppercase', - padding: '10px 16px', - borderRadius: 8, - border: `1px solid ${palette.border}`, - background: 'transparent', - color: palette.navy, - cursor: 'pointer', - '&:hover': { borderColor: palette.borderStrong }, - }, - confirmDelete: { - fontFamily: fonts.mono, - fontSize: 12, - letterSpacing: '0.04em', - textTransform: 'uppercase', - padding: '10px 18px', - borderRadius: 8, - border: `1px solid ${palette.terracotta}`, - backgroundColor: palette.terracotta, - color: '#FBFAF7', - cursor: 'pointer', - transition: 'opacity 150ms ease', - '&:hover': { opacity: 0.9 }, - '&:disabled': { opacity: 0.6, cursor: 'default' }, - }, +const useStyles = makeStyles()(theme => { + const palette = themeTokens(theme); + return { + root: { + backgroundColor: palette.paper, + color: palette.navy, + fontFamily: fonts.body, + minHeight: '100%', + }, + header: { + position: 'sticky', + top: 0, + zIndex: 10, + backgroundColor: palette.paper, + borderBottom: `1px solid ${palette.border}`, + padding: '20px 28px 0', + }, + titleRow: { + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'space-between', + gap: 16, + flexWrap: 'wrap', + }, + titleBlock: { display: 'flex', alignItems: 'center', gap: 12 }, + title: { + fontFamily: fonts.body, + fontSize: 24, + fontWeight: 700, + letterSpacing: '-0.02em', + color: palette.navy, + }, + rightGroup: { display: 'flex', alignItems: 'center', gap: 12 }, + onLeaveToday: { + display: 'inline-flex', + alignItems: 'center', + gap: 8, + fontFamily: fonts.mono, + fontSize: 12, + fontWeight: 600, + letterSpacing: '0.03em', + color: palette.terracotta, + backgroundColor: `${palette.terracotta}14`, + border: `1px solid ${palette.terracotta}33`, + borderRadius: 999, + padding: '6px 12px', + }, + pulse: { + width: 8, + height: 8, + borderRadius: '50%', + backgroundColor: palette.terracotta, + }, + requestButton: { + display: 'inline-flex', + alignItems: 'center', + gap: 8, + fontFamily: fonts.mono, + fontSize: 12, + fontWeight: 600, + letterSpacing: '0.04em', + textTransform: 'uppercase', + padding: '9px 16px', + borderRadius: 8, + border: `1px solid ${palette.terracotta}`, + backgroundColor: palette.terracotta, + color: palette.onAccent, + cursor: 'pointer', + transition: 'opacity 150ms ease', + '&:hover': { opacity: 0.9 }, + '& svg': { fontSize: 18 }, + }, + tabBar: { + display: 'flex', + gap: 28, + marginTop: 18, + }, + tab: { + fontFamily: fonts.mono, + fontSize: 13, + letterSpacing: '0.04em', + textTransform: 'uppercase', + padding: '10px 2px', + border: 'none', + borderBottom: '2px solid transparent', + background: 'transparent', + color: palette.muted, + cursor: 'pointer', + transition: 'color 150ms ease, border-color 150ms ease', + '&:hover': { color: palette.navy }, + }, + tabActive: { + color: palette.navy, + borderBottomColor: palette.terracotta, + }, + content: { padding: 28 }, + addError: { + fontFamily: fonts.mono, + fontSize: 12, + color: palette.terracotta, + backgroundColor: `${palette.terracotta}14`, + border: `1px solid ${palette.terracotta}33`, + borderRadius: 8, + padding: '8px 12px', + margin: '0 28px', + }, + confirmPaper: { + backgroundColor: palette.surface, + borderRadius: 14, + border: `1px solid ${palette.border}`, + width: '100%', + maxWidth: 400, + padding: 24, + }, + confirmTitle: { + fontFamily: fonts.body, + fontSize: 18, + fontWeight: 700, + color: palette.navy, + }, + confirmBody: { + fontFamily: fonts.body, + fontSize: 14, + color: palette.muted, + margin: '12px 0 20px', + }, + confirmActions: { + display: 'flex', + justifyContent: 'flex-end', + gap: 10, + }, + confirmCancel: { + fontFamily: fonts.mono, + fontSize: 12, + letterSpacing: '0.04em', + textTransform: 'uppercase', + padding: '10px 16px', + borderRadius: 8, + border: `1px solid ${palette.border}`, + background: 'transparent', + color: palette.navy, + cursor: 'pointer', + '&:hover': { borderColor: palette.borderStrong }, + }, + confirmDelete: { + fontFamily: fonts.mono, + fontSize: 12, + letterSpacing: '0.04em', + textTransform: 'uppercase', + padding: '10px 18px', + borderRadius: 8, + border: `1px solid ${palette.terracotta}`, + backgroundColor: palette.terracotta, + color: palette.onAccent, + cursor: 'pointer', + transition: 'opacity 150ms ease', + '&:hover': { opacity: 0.9 }, + '&:disabled': { opacity: 0.6, cursor: 'default' }, + }, + }; }); export const LeaveTrackerPage = ({ diff --git a/plugins/team-leave-tracker/src/components/TeamOverviewTab.test.tsx b/plugins/team-leave-tracker/src/components/TeamOverviewTab.test.tsx new file mode 100644 index 0000000..923f790 --- /dev/null +++ b/plugins/team-leave-tracker/src/components/TeamOverviewTab.test.tsx @@ -0,0 +1,111 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import type { LeaveEntry, TeamMember } from '../data/model'; +import { addDays, startOfToday, toIso } from '../data/model'; +import { TeamOverviewTab } from './TeamOverviewTab'; + +const jane: TeamMember = { + id: 'user:default/jane', + name: 'Jane Doe', + initials: 'JD', + color: '#1A2332', +}; +const bob: TeamMember = { + id: 'user:default/bob', + name: 'Bob Smith', + initials: 'BS', + color: '#C84B31', +}; + +const today = startOfToday(); +const todayIso = toIso(today); +const inTwoDaysIso = toIso(addDays(today, 1)); + +const janeOnLeave: LeaveEntry = { + id: 'leave-1', + memberId: jane.id, + startDate: todayIso, + endDate: inTwoDaysIso, + type: 'annual', +}; + +const renderTab = ( + props: Partial> = {}, +) => + render( + , + ); + +describe('TeamOverviewTab', () => { + it('renders a card for every member', () => { + renderTab(); + + expect(screen.getByText('Jane Doe')).toBeInTheDocument(); + expect(screen.getByText('Bob Smith')).toBeInTheDocument(); + }); + + it('shows an empty state when the group has no members', () => { + renderTab({ members: [], leaves: [] }); + + expect( + screen.getByText(/this group has no members yet/i), + ).toBeInTheDocument(); + }); + + it('flags a member who is on leave today', () => { + renderTab(); + + expect(screen.getByText(/on leave now/i)).toBeInTheDocument(); + }); + + it('shows "Nothing scheduled." for members without upcoming leaves', () => { + renderTab(); + + expect(screen.getByText('Nothing scheduled.')).toBeInTheDocument(); + }); + + it('totals the leave days for a member', () => { + renderTab(); + + // The leave spans today + the next day = 2 inclusive days. + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getAllByText('Total leave days')).toHaveLength(2); + }); + + it('only renders edit/delete actions for the current user and fires callbacks', () => { + const onEditLeave = jest.fn(); + const onDeleteLeave = jest.fn(); + renderTab({ onEditLeave, onDeleteLeave }); + + const editButton = screen.getByRole('button', { + name: /edit leave/i, + }); + const deleteButton = screen.getByRole('button', { + name: /delete leave/i, + }); + + fireEvent.click(editButton); + fireEvent.click(deleteButton); + + expect(onEditLeave).toHaveBeenCalledWith(janeOnLeave); + expect(onDeleteLeave).toHaveBeenCalledWith(janeOnLeave); + }); + + it('does not render actions when the current user is not the leave owner', () => { + renderTab({ currentUserRef: bob.id }); + + expect( + screen.queryByRole('button', { name: /edit leave/i }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /delete leave/i }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/plugins/team-leave-tracker/src/components/TeamOverviewTab.tsx b/plugins/team-leave-tracker/src/components/TeamOverviewTab.tsx index fef6caf..a3ae530 100644 --- a/plugins/team-leave-tracker/src/components/TeamOverviewTab.tsx +++ b/plugins/team-leave-tracker/src/components/TeamOverviewTab.tsx @@ -12,149 +12,152 @@ import { startOfToday, toIso, } from '../data/model'; -import { fonts, palette } from '../theme'; +import { fonts, themeTokens } from '../theme'; import { MemberAvatar, TypeBadge } from './common'; -const useStyles = makeStyles()({ - grid: { - display: 'grid', - gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', - gap: 16, - }, - card: { - display: 'flex', - flexDirection: 'column', - border: `1px solid ${palette.border}`, - borderRadius: 12, - backgroundColor: palette.surface, - padding: 20, - transition: - 'border-color 150ms ease, box-shadow 150ms ease, transform 150ms ease', - '&:hover': { - borderColor: palette.borderStrong, - transform: 'translateY(-2px)', - boxShadow: '0 8px 24px rgba(26, 35, 50, 0.08)', +const useStyles = makeStyles()(theme => { + const palette = themeTokens(theme); + return { + grid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', + gap: 16, }, - }, - header: { display: 'flex', alignItems: 'center', gap: 12 }, - identity: { minWidth: 0, flex: 1 }, - name: { - fontFamily: fonts.body, - fontSize: 16, - fontWeight: 700, - color: palette.navy, - letterSpacing: '-0.01em', - }, - onLeaveBadge: { - display: 'inline-flex', - alignItems: 'center', - gap: 6, - padding: '3px 10px', - borderRadius: 999, - backgroundColor: palette.terracotta, - color: '#FBFAF7', - fontFamily: fonts.mono, - fontSize: 10, - fontWeight: 600, - letterSpacing: '0.05em', - textTransform: 'uppercase', - whiteSpace: 'nowrap', - }, - badgeDot: { - width: 6, - height: 6, - borderRadius: '50%', - backgroundColor: '#FBFAF7', - }, - sectionLabel: { - fontFamily: fonts.mono, - fontSize: 10, - letterSpacing: '0.08em', - textTransform: 'uppercase', - color: palette.muted, - margin: '18px 0 10px', - }, - upcomingRow: { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - gap: 10, - padding: '7px 0', - borderTop: `1px solid ${palette.border}`, - }, - upcomingDate: { - fontFamily: fonts.mono, - fontSize: 12, - color: palette.navy, - whiteSpace: 'nowrap', - }, - upcomingRight: { - display: 'flex', - alignItems: 'center', - gap: 6, - }, - actionButton: { - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - padding: 4, - border: 'none', - background: 'transparent', - color: palette.muted, - cursor: 'pointer', - borderRadius: 6, - '&:hover': { color: palette.navy, backgroundColor: palette.paper }, - '& svg': { fontSize: 16 }, - }, - deleteButton: { - '&:hover': { color: palette.terracotta, backgroundColor: palette.paper }, - }, - noUpcoming: { - fontFamily: fonts.body, - fontSize: 13, - color: palette.muted, - padding: '4px 0', - }, - footer: { - display: 'flex', - gap: 12, - marginTop: 'auto', - paddingTop: 16, - }, - stat: { - flex: 1, - backgroundColor: palette.paper, - borderRadius: 8, - padding: '10px 12px', - }, - statValue: { - fontFamily: fonts.mono, - fontSize: 20, - fontWeight: 600, - color: palette.navy, - lineHeight: 1.1, - }, - statLabel: { - fontFamily: fonts.mono, - fontSize: 10, - letterSpacing: '0.05em', - textTransform: 'uppercase', - color: palette.muted, - marginTop: 4, - }, - spacer: { marginTop: 12 }, - emptyState: { - fontFamily: fonts.body, - fontSize: 14, - color: palette.muted, - border: `1px solid ${palette.border}`, - borderRadius: 12, - backgroundColor: palette.surface, - padding: '48px 24px', - textAlign: 'center', - maxWidth: 460, - margin: '0 auto', - }, + card: { + display: 'flex', + flexDirection: 'column', + border: `1px solid ${palette.border}`, + borderRadius: 12, + backgroundColor: palette.surface, + padding: 20, + transition: + 'border-color 150ms ease, box-shadow 150ms ease, transform 150ms ease', + '&:hover': { + borderColor: palette.borderStrong, + transform: 'translateY(-2px)', + boxShadow: '0 8px 24px rgba(26, 35, 50, 0.08)', + }, + }, + header: { display: 'flex', alignItems: 'center', gap: 12 }, + identity: { minWidth: 0, flex: 1 }, + name: { + fontFamily: fonts.body, + fontSize: 16, + fontWeight: 700, + color: palette.navy, + letterSpacing: '-0.01em', + }, + onLeaveBadge: { + display: 'inline-flex', + alignItems: 'center', + gap: 6, + padding: '3px 10px', + borderRadius: 999, + backgroundColor: palette.terracotta, + color: palette.onAccent, + fontFamily: fonts.mono, + fontSize: 10, + fontWeight: 600, + letterSpacing: '0.05em', + textTransform: 'uppercase', + whiteSpace: 'nowrap', + }, + badgeDot: { + width: 6, + height: 6, + borderRadius: '50%', + backgroundColor: palette.onAccent, + }, + sectionLabel: { + fontFamily: fonts.mono, + fontSize: 10, + letterSpacing: '0.08em', + textTransform: 'uppercase', + color: palette.muted, + margin: '18px 0 10px', + }, + upcomingRow: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 10, + padding: '7px 0', + borderTop: `1px solid ${palette.border}`, + }, + upcomingDate: { + fontFamily: fonts.mono, + fontSize: 12, + color: palette.navy, + whiteSpace: 'nowrap', + }, + upcomingRight: { + display: 'flex', + alignItems: 'center', + gap: 6, + }, + actionButton: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + padding: 4, + border: 'none', + background: 'transparent', + color: palette.muted, + cursor: 'pointer', + borderRadius: 6, + '&:hover': { color: palette.navy, backgroundColor: palette.paper }, + '& svg': { fontSize: 16 }, + }, + deleteButton: { + '&:hover': { color: palette.terracotta, backgroundColor: palette.paper }, + }, + noUpcoming: { + fontFamily: fonts.body, + fontSize: 13, + color: palette.muted, + padding: '4px 0', + }, + footer: { + display: 'flex', + gap: 12, + marginTop: 'auto', + paddingTop: 16, + }, + stat: { + flex: 1, + backgroundColor: palette.paper, + borderRadius: 8, + padding: '10px 12px', + }, + statValue: { + fontFamily: fonts.mono, + fontSize: 20, + fontWeight: 600, + color: palette.navy, + lineHeight: 1.1, + }, + statLabel: { + fontFamily: fonts.mono, + fontSize: 10, + letterSpacing: '0.05em', + textTransform: 'uppercase', + color: palette.muted, + marginTop: 4, + }, + spacer: { marginTop: 12 }, + emptyState: { + fontFamily: fonts.body, + fontSize: 14, + color: palette.muted, + border: `1px solid ${palette.border}`, + borderRadius: 12, + backgroundColor: palette.surface, + padding: '48px 24px', + textAlign: 'center', + maxWidth: 460, + margin: '0 auto', + }, + }; }); export const TeamOverviewTab = ({ diff --git a/plugins/team-leave-tracker/src/components/common.test.tsx b/plugins/team-leave-tracker/src/components/common.test.tsx new file mode 100644 index 0000000..a7d6439 --- /dev/null +++ b/plugins/team-leave-tracker/src/components/common.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/react'; + +import type { LeaveType, TeamMember } from '../data/model'; +import { LEAVE_TYPES } from '../data/model'; +import { LeaveTypeLegend, MemberAvatar, TypeBadge } from './common'; + +const member: TeamMember = { + id: 'user:default/jane', + name: 'Jane Doe', + initials: 'JD', + color: '#1A2332', +}; + +describe('common components', () => { + describe('MemberAvatar', () => { + it('renders the member initials and exposes the name for a11y', () => { + render(); + + const avatar = screen.getByLabelText('Jane Doe'); + expect(avatar).toHaveTextContent('JD'); + expect(avatar).toHaveAttribute('title', 'Jane Doe'); + }); + + it('applies the requested size to width and height', () => { + render(); + + const avatar = screen.getByLabelText('Jane Doe'); + expect(avatar).toHaveStyle({ width: '48px', height: '48px' }); + }); + }); + + describe('TypeBadge', () => { + it('renders the leave type label', () => { + const annual = LEAVE_TYPES.find(t => t.id === 'annual') as LeaveType; + + render(); + + expect(screen.getByText('Annual')).toBeInTheDocument(); + }); + }); + + describe('LeaveTypeLegend', () => { + it('lists every leave type', () => { + render(); + + for (const type of LEAVE_TYPES) { + expect(screen.getByText(type.label)).toBeInTheDocument(); + } + }); + }); +}); diff --git a/plugins/team-leave-tracker/src/components/common.tsx b/plugins/team-leave-tracker/src/components/common.tsx index eac0d31..3f025d2 100644 --- a/plugins/team-leave-tracker/src/components/common.tsx +++ b/plugins/team-leave-tracker/src/components/common.tsx @@ -2,50 +2,53 @@ import { makeStyles } from 'tss-react/mui'; import type { LeaveType, TeamMember } from '../data/model'; import { LEAVE_TYPES } from '../data/model'; -import { fonts, palette } from '../theme'; +import { fonts, themeTokens } from '../theme'; -const useStyles = makeStyles()({ - avatar: { - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - borderRadius: '50%', - color: '#FBFAF7', - fontFamily: fonts.mono, - fontWeight: 600, - letterSpacing: '0.02em', - flexShrink: 0, - }, - typeBadge: { - display: 'inline-flex', - alignItems: 'center', - gap: 6, - padding: '2px 10px', - borderRadius: 999, - fontFamily: fonts.mono, - fontSize: 11, - fontWeight: 600, - letterSpacing: '0.04em', - textTransform: 'uppercase', - lineHeight: 1.6, - whiteSpace: 'nowrap', - }, - dot: { width: 8, height: 8, borderRadius: '50%', flexShrink: 0 }, - legend: { - display: 'flex', - flexWrap: 'wrap', - gap: 16, - alignItems: 'center', - }, - legendItem: { - display: 'inline-flex', - alignItems: 'center', - gap: 8, - fontFamily: fonts.mono, - fontSize: 12, - letterSpacing: '0.03em', - color: palette.muted, - }, +const useStyles = makeStyles()(theme => { + const palette = themeTokens(theme); + return { + avatar: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: '50%', + color: '#FBFAF7', + fontFamily: fonts.mono, + fontWeight: 600, + letterSpacing: '0.02em', + flexShrink: 0, + }, + typeBadge: { + display: 'inline-flex', + alignItems: 'center', + gap: 6, + padding: '2px 10px', + borderRadius: 999, + fontFamily: fonts.mono, + fontSize: 11, + fontWeight: 600, + letterSpacing: '0.04em', + textTransform: 'uppercase', + lineHeight: 1.6, + whiteSpace: 'nowrap', + }, + dot: { width: 8, height: 8, borderRadius: '50%', flexShrink: 0 }, + legend: { + display: 'flex', + flexWrap: 'wrap', + gap: 16, + alignItems: 'center', + }, + legendItem: { + display: 'inline-flex', + alignItems: 'center', + gap: 8, + fontFamily: fonts.mono, + fontSize: 12, + letterSpacing: '0.03em', + color: palette.muted, + }, + }; }); export const MemberAvatar = ({ diff --git a/plugins/team-leave-tracker/src/data/model.ts b/plugins/team-leave-tracker/src/data/model.ts index 612e57c..cc2b8a5 100644 --- a/plugins/team-leave-tracker/src/data/model.ts +++ b/plugins/team-leave-tracker/src/data/model.ts @@ -9,7 +9,7 @@ import type { Entity, UserEntity } from '@backstage/catalog-model'; import { stringifyEntityRef } from '@backstage/catalog-model'; -import { palette } from '../theme'; +import { terracotta } from '../theme'; export type LeaveTypeId = | 'annual' @@ -50,7 +50,7 @@ export interface LeaveEntry { export const LEAVE_TYPES: LeaveType[] = [ { id: 'annual', label: 'Annual', color: '#2F6F5E' }, - { id: 'sick', label: 'Sick', color: palette.terracotta }, + { id: 'sick', label: 'Sick', color: terracotta }, { id: 'personal', label: 'Personal', color: '#CC8B2A' }, { id: 'conference', label: 'Conference', color: '#3A6EA5' }, { id: 'parental', label: 'Parental', color: '#7B5EA7' }, diff --git a/plugins/team-leave-tracker/src/setupTests.ts b/plugins/team-leave-tracker/src/setupTests.ts new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/plugins/team-leave-tracker/src/setupTests.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/plugins/team-leave-tracker/src/theme.ts b/plugins/team-leave-tracker/src/theme.ts index f46c5be..252ff75 100644 --- a/plugins/team-leave-tracker/src/theme.ts +++ b/plugins/team-leave-tracker/src/theme.ts @@ -1,29 +1,61 @@ /** - * Warm archival design tokens for the Team Leave Tracker plugin. + * Design tokens for the Team Leave Tracker plugin. * - * These colours and fonts are intentionally hard-coded here rather than read - * from the Backstage theme so the plugin keeps its distinct "paper" aesthetic - * regardless of the surrounding app theme. + * Colours are derived from the active Backstage theme via {@link themeTokens} + * so the plugin follows the app's light/dark mode, while the typography keeps + * the plugin's distinct archival fonts. */ +import type { Theme } from '@mui/material/styles'; import { useEffect } from 'react'; -export const palette = { - /** Cream / paper page background. */ - paper: '#F2F0EB', - /** Slightly raised surface for cards and panels. */ - surface: '#FBFAF7', - /** Deep navy primary used for text, headers and key accents. */ - navy: '#1A2332', - /** Terracotta accent for actions and "today" highlights. */ - terracotta: '#C84B31', +/** + * Fixed brand accent, used as a fallback and as the data colour for "sick" + * leave. UI accents are driven by the theme's primary colour (see + * {@link themeTokens}) so they adapt to the surrounding app theme. + */ +export const terracotta = '#C84B31'; + +/** Semantic colour tokens used across the plugin's components. */ +export interface ThemeTokens { + /** Page background. */ + paper: string; + /** Slightly raised surface for cards, panels and inputs. */ + surface: string; + /** Primary text / headers. */ + navy: string; + /** Accent for actions, "today" and active highlights. */ + terracotta: string; + /** Readable text/icon colour placed on top of the accent colour. */ + onAccent: string; /** Muted secondary text. */ - muted: '#6B6457', + muted: string; /** Hairline border colour. */ - border: '#DAD5CC', + border: string; /** Stronger hairline used on hover / active states. */ - borderStrong: '#C3BCAE', -} as const; + borderStrong: string; + /** Subtle background used for hover states. */ + hover: string; +} + +/** + * Maps the active MUI/Backstage theme to the plugin's semantic tokens. The + * token names are kept stable so component styles read the same regardless of + * light or dark mode. + */ +export function themeTokens(theme: Theme): ThemeTokens { + return { + paper: theme.palette.background.default, + surface: theme.palette.background.paper, + navy: theme.palette.text.primary, + terracotta: theme.palette.primary.main, + onAccent: theme.palette.primary.contrastText, + muted: theme.palette.text.secondary, + border: theme.palette.divider, + borderStrong: theme.palette.action.disabled, + hover: theme.palette.action.hover, + }; +} export const fonts = { body: `'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`, From ad4cda07b0faf67f4ccf5f02fd090668bbded067 Mon Sep 17 00:00:00 2001 From: Gaurav Pandey Date: Sat, 25 Jul 2026 14:48:43 +0200 Subject: [PATCH 2/2] feat: add CI workflow for pull request checks Signed-off-by: Gaurav Pandey --- .github/workflows/ci.yaml | 123 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 .github/workflows/ci.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..08d608d --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,123 @@ +name: PR Check + +on: + pull_request: + branches: [main] + +permissions: + contents: read + pull-requests: write + +jobs: + checks: + name: PR checks + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dependencies + id: install + run: yarn install --immutable + + - name: Compile TypeScript (yarn tsc) + id: tsc + if: always() && steps.install.outcome == 'success' + run: yarn tsc + + - name: Prettier check (yarn prettier:check) + id: prettier + if: always() && steps.install.outcome == 'success' + run: yarn prettier:check + + - name: Run tests (yarn test) + id: test + if: always() && steps.install.outcome == 'success' + run: yarn test + + - name: Build summary + id: summary + if: always() + run: | + status() { + case "$1" in + success) echo "✅ Passed" ;; + failure) echo "❌ Failed" ;; + skipped) echo "⚠️ Skipped" ;; + *) echo "➖ ${1:-not run}" ;; + esac + } + + overall="✅ All checks passed." + failed=false + if [ "${{ steps.install.outcome }}" != "success" ] \ + || [ "${{ steps.tsc.outcome }}" != "success" ] \ + || [ "${{ steps.prettier.outcome }}" != "success" ] \ + || [ "${{ steps.test.outcome }}" != "success" ]; then + overall="❌ One or more checks failed." + failed=true + fi + + { + echo "## CI check summary" + echo "" + echo "| Check | Command | Result |" + echo "| ----- | ------- | ------ |" + echo "| Install | \`yarn install\` | $(status "${{ steps.install.outcome }}") |" + echo "| TypeScript | \`yarn tsc\` | $(status "${{ steps.tsc.outcome }}") |" + echo "| Prettier | \`yarn prettier:check\` | $(status "${{ steps.prettier.outcome }}") |" + echo "| Tests | \`yarn test\` | $(status "${{ steps.test.outcome }}") |" + echo "" + echo "**Overall: $overall**" + } > summary.md + + cat summary.md >> "$GITHUB_STEP_SUMMARY" + echo "failed=$failed" >> "$GITHUB_OUTPUT" + + - name: Post summary to PR + if: always() + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const marker = ''; + const body = `${marker}\n${fs.readFileSync('summary.md', 'utf8')}`; + const { owner, repo } = context.repo; + const issue_number = context.payload.pull_request.number; + + const { data: comments } = await github.rest.issues.listComments({ + owner, + repo, + issue_number, + }); + const existing = comments.find( + c => c.user.type === 'Bot' && c.body && c.body.includes(marker), + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); + } + + - name: Fail if any check failed + if: always() && steps.summary.outputs.failed == 'true' + run: | + echo "One or more checks failed." + exit 1