diff --git a/docs/superpowers/plans/2026-08-21-student-visibility-preview-hotfix.md b/docs/superpowers/plans/2026-08-21-student-visibility-preview-hotfix.md new file mode 100644 index 0000000..799084a --- /dev/null +++ b/docs/superpowers/plans/2026-08-21-student-visibility-preview-hotfix.md @@ -0,0 +1,343 @@ +# Student Visibility Preview Hotfix Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an authenticated staff-only student preview that enforces logged-out resource and link visibility without changing production authentication, backend behavior, or workflow exposure. + +**Architecture:** Keep the real session intact and derive a separate `canBypassVisibility` boolean from authentication plus the `preview=student` query parameter. `CourseRecords` owns preview state and passes visibility authority explicitly to `RecordCard` and `RecordTable`; pure URL and policy helpers provide unit-test coverage without adding dependencies. + +**Tech Stack:** React 18, React Router, Reactstrap, Vitest 3, ESLint 9, Vite 5 + +**Spec:** `docs/superpowers/specs/2026-08-21-student-visibility-preview-hotfix-design.md` + +## Global Constraints + +- Base all work on production commit `eacfc14d8fdff51030b989f6fd640c8357d44973`. +- Do not change `AuthContext`, cookies, tokens, permissions, backend APIs, or persisted visibility data. +- Do not modify `src/pages/Admin.jsx`, `src/components/layout/AppRoutes.jsx`, `src/components/layout/Header.jsx`, `src/config/api.config.js`, `.env.production`, `.env.staging`, or `server/workflow-admin/`. +- `preview=student` may only make a view more restrictive; it must never grant visibility. +- Preserve all unrelated URL query parameters when entering or leaving preview. +- Do not add runtime or development dependencies. + +--- + +### Task 1: Add tested student-preview policy helpers + +**Files:** +- Create: `src/util/studentPreview.js` +- Create: `src/util/studentPreview.test.js` +- Modify: `package.json` + +**Interfaces:** +- Produces: `isStudentPreview(search: string): boolean` +- Produces: `withStudentPreview(search: string, enabled: boolean): string` +- Produces: `canBypassVisibility(isAuthenticated: boolean, studentPreview: boolean): boolean` + +- [ ] **Step 1: Add the test command and failing helper tests** + +Add `"test": "vitest run"` to `package.json` scripts and create: + +```js +import { describe, expect, it } from 'vitest'; +import { + canBypassVisibility, + isStudentPreview, + withStudentPreview, +} from './studentPreview'; + +describe('student preview policy', () => { + it('recognizes only the explicit student preview value', () => { + expect(isStudentPreview('?preview=student')).toBe(true); + expect(isStudentPreview('?preview=staff')).toBe(false); + expect(isStudentPreview('')).toBe(false); + }); + + it('allows only non-preview authenticated sessions to bypass visibility', () => { + expect(canBypassVisibility(true, false)).toBe(true); + expect(canBypassVisibility(true, true)).toBe(false); + expect(canBypassVisibility(false, false)).toBe(false); + expect(canBypassVisibility(false, true)).toBe(false); + }); + + it('adds preview without losing existing parameters', () => { + expect(withStudentPreview('?college=smith§ion=01', true)) + .toBe('?college=smith§ion=01&preview=student'); + }); + + it('removes only preview when exiting', () => { + expect(withStudentPreview('?college=smith&preview=student§ion=01', false)) + .toBe('?college=smith§ion=01'); + }); +}); +``` + +- [ ] **Step 2: Run the test and verify it fails** + +Run: + +```bash +npm test -- src/util/studentPreview.test.js +``` + +Expected: FAIL because `src/util/studentPreview.js` does not exist. + +- [ ] **Step 3: Implement the pure helpers** + +Create `src/util/studentPreview.js`: + +```js +const PREVIEW_PARAM = 'preview'; +const STUDENT_PREVIEW_VALUE = 'student'; + +export const isStudentPreview = (search = '') => { + return new URLSearchParams(search).get(PREVIEW_PARAM) === STUDENT_PREVIEW_VALUE; +}; + +export const withStudentPreview = (search = '', enabled) => { + const params = new URLSearchParams(search); + + if (enabled) { + params.set(PREVIEW_PARAM, STUDENT_PREVIEW_VALUE); + } else { + params.delete(PREVIEW_PARAM); + } + + const query = params.toString(); + return query ? `?${query}` : ''; +}; + +export const canBypassVisibility = (isAuthenticated, studentPreview) => { + return Boolean(isAuthenticated && !studentPreview); +}; +``` + +- [ ] **Step 4: Run the helper tests** + +Run: + +```bash +npm test -- src/util/studentPreview.test.js +``` + +Expected: 4 tests PASS. + +- [ ] **Step 5: Commit the policy helper** + +```bash +git add package.json src/util/studentPreview.js src/util/studentPreview.test.js +git commit -m "test: define student preview visibility policy" +``` + +--- + +### Task 2: Add preview state and controls to the course page + +**Files:** +- Modify: `src/pages/CourseRecords.jsx:1-50, 517-543, 756-843, 1045-1130, 1177-1338` + +**Interfaces:** +- Consumes: `isStudentPreview(location.search)` +- Consumes: `withStudentPreview(location.search, enabled)` +- Consumes: `canBypassVisibility(isAuthenticated, studentPreview)` +- Produces: `canBypassResourceVisibility: boolean` passed to record views + +- [ ] **Step 1: Import the policy helpers and derive page state** + +Import the three helpers from `../util/studentPreview`. Immediately after reading authentication state, derive: + +```js +const studentPreview = isStudentPreview(location.search); +const canBypassResourceVisibility = canBypassVisibility( + isAuthenticated, + studentPreview +); + +const setStudentPreview = useCallback((enabled) => { + navigate({ + pathname: location.pathname, + search: withStudentPreview(location.search, enabled), + hash: location.hash, + }, { replace: true }); +}, [location.hash, location.pathname, location.search, navigate]); +``` + +- [ ] **Step 2: Replace page-level visibility authority** + +Within resource filtering, hidden-resource counts, and the “not currently available” summary, replace visibility uses of `isAuthenticated` with `canBypassResourceVisibility`. Keep `isAuthenticated` only for deciding whether to show staff preview controls. + +Update hook dependency arrays accordingly. Do not replace authentication used for unrelated session behavior. + +- [ ] **Step 3: Add the preview action and active banner** + +Immediately after the course-information block and before the display-mode control (currently between lines 1086 and 1088), render **Preview as student** only when `isAuthenticated && !studentPreview`. When `studentPreview` is true, render: + +```jsx + + + Student preview is active.{' '} + Resources and links outside their visibility dates are hidden as they are for students. + + + +``` + +The enter action calls `setStudentPreview(true)`. Reuse Reactstrap components already imported by the page; add no new package. + +- [ ] **Step 4: Pass visibility authority to every record view** + +Pass the same prop through every render path: + +```jsx + + +``` + +Cover grouped cards, ungrouped cards, split-view physical cards, split-view electronic cards, and the table view. Remove the currently ineffective `isAuthenticated={isAuthenticated}` props passed to `RecordCard`. + +- [ ] **Step 5: Run targeted lint** + +Run: + +```bash +npx eslint src/pages/CourseRecords.jsx src/util/studentPreview.js src/util/studentPreview.test.js +``` + +Expected: exit 0. + +- [ ] **Step 6: Commit the course-page controls** + +```bash +git add src/pages/CourseRecords.jsx +git commit -m "feat: add student preview controls" +``` + +--- + +### Task 3: Make card and table views honor explicit visibility authority + +**Files:** +- Modify: `src/components/page-sections/course-record/RecordCard.jsx:18, 40-56, 171-214, 250, 522-625, 758-899` +- Modify: `src/components/page-sections/course-record/RecordTable.jsx:14, 40-56, 96-224, 355-373, 585-632, 746, 1061-1082` + +**Interfaces:** +- Consumes: `canBypassVisibility: boolean` from `CourseRecords` +- Produces: matching resource and link visibility behavior in card and table views + +- [ ] **Step 1: Update `RecordCard`** + +Add `canBypassVisibility = false` to its props, remove the direct `useAuth` import and hook, and use the prop everywhere the component currently uses `isAuthenticated` for: + +- whole-resource visibility; +- primary-link visibility; +- additional-link visibility; +- staff-only visibility date text and popovers; +- decisions to render unavailable resources. + +Add `canBypassVisibility: PropTypes.bool` and its default value. Do not change tracking behavior or URLs. + +- [ ] **Step 2: Update `RecordTable`** + +Add `canBypassVisibility = false` to its props, remove the direct `useAuth` import and hook, and use the prop everywhere the table currently uses `isAuthenticated` for filtering, primary links, additional links, and staff visibility information. + +Add `canBypassVisibility: PropTypes.bool` and its default value. Ensure both combined and split table paths use the same authority. + +- [ ] **Step 3: Run tests and targeted lint** + +Run: + +```bash +npm test -- src/util/studentPreview.test.js +npx eslint src/pages/CourseRecords.jsx src/components/page-sections/course-record/RecordCard.jsx src/components/page-sections/course-record/RecordTable.jsx src/util/studentPreview.js src/util/studentPreview.test.js +``` + +Expected: 4 tests PASS and ESLint exits 0. + +- [ ] **Step 4: Build the production artifact** + +Run: + +```bash +npm run build +``` + +Expected: Vite completes a production build and writes assets under `/course-reserves/`. Existing bundle-size or Browserslist warnings are acceptable; compilation errors are not. + +- [ ] **Step 5: Commit the record-view integration** + +```bash +git add src/components/page-sections/course-record/RecordCard.jsx src/components/page-sections/course-record/RecordTable.jsx +git commit -m "fix: enforce student visibility during preview" +``` + +--- + +### Task 4: Verify production isolation and the original bug scenario + +**Files:** +- No source changes expected +- Record results in the pull-request description + +**Interfaces:** +- Consumes: completed hotfix branch +- Produces: release evidence and a go/no-go decision + +- [ ] **Step 1: Confirm protected production files are unchanged** + +Run: + +```bash +git diff --exit-code eacfc14d8fdff51030b989f6fd640c8357d44973 -- \ + .env.production \ + .env.staging \ + src/pages/Admin.jsx \ + src/components/layout/AppRoutes.jsx \ + src/components/layout/Header.jsx \ + src/config/api.config.js \ + server/workflow-admin +``` + +Expected: no diff and exit 0. + +- [ ] **Step 2: Run the complete automated verification** + +Run: + +```bash +npm test +npm run build +``` + +Expected: all tests PASS and production build exits 0. + +- [ ] **Step 3: Smoke-test visibility in staging or a production-equivalent environment** + +Use one course containing: + +- a whole electronic resource outside its visibility window; +- a primary link outside its visibility window; +- an additional link outside its visibility window; +- a currently visible resource and link as controls. + +Verify this matrix: + +| Session/view | Out-of-window content | In-window content | Staff date annotations | +| --- | --- | --- | --- | +| Fully logged out | Hidden | Visible | Hidden | +| Staff, default view | Visible | Visible | Visible | +| Staff, `preview=student` | Hidden | Visible | Hidden | + +Repeat in card/table and combined/split views. Enter and exit preview and confirm the course, college, and section remain unchanged. + +- [ ] **Step 4: Verify workflow behavior has not regressed** + +Using the production build, confirm the Workflow dropdown, workflow-template tab, mention badge, and workflow notification bell remain absent from normal production navigation. Do not treat the already-known direct-URL exposure as part of this hotfix; document it for the separate workflow-hardening issue. + +- [ ] **Step 5: Make the release decision** + +Proceed only if the logged-out and staff-preview rows match exactly. If the logged-out row exposes an out-of-window resource or link, stop the release and open a separate persistence/normalization hotfix before deploying student preview. + +- [ ] **Step 6: Prepare rollback information** + +Record production baseline `eacfc14d8fdff51030b989f6fd640c8357d44973` and its previously deployed artifact in the release notes. Because this branch has no schema or API changes, rollback is redeployment of that artifact. diff --git a/docs/superpowers/specs/2026-08-21-student-visibility-preview-hotfix-design.md b/docs/superpowers/specs/2026-08-21-student-visibility-preview-hotfix-design.md new file mode 100644 index 0000000..26baf13 --- /dev/null +++ b/docs/superpowers/specs/2026-08-21-student-visibility-preview-hotfix-design.md @@ -0,0 +1,84 @@ +# Student Visibility Preview Hotfix Design + +**Status:** Approved in conversation on 2026-08-21 + +**Production baseline:** `eacfc14d8fdff51030b989f6fd640c8357d44973` (`main`) + +## Problem + +Authenticated staff always bypass resource and link visibility windows on the public course-records page. That behavior lets staff manage and verify future or expired resources, but it also makes the visibility toggles appear ineffective during training. + +The hotfix must give staff an accurate student-facing preview without logging them out, changing authentication, changing stored visibility settings, or exposing workflow features that production intentionally keeps out of navigation. + +## Goals + +- Add a clearly labeled **Preview as student** action for authenticated users on the public course-records page. +- In preview mode, apply the same resource, primary-link, and additional-link visibility decisions used for logged-out visitors. +- Make preview mode unmistakable with a persistent banner and an **Exit student preview** action. +- Preserve the current URL and all unrelated query parameters when entering or leaving preview mode. +- Preserve the current production build mode, authentication, API endpoints, workflow navigation behavior, and backend behavior. + +## Non-goals + +- Do not change `AuthContext`, authentication cookies, tokens, roles, or permissions. +- Do not change resource visibility values in the database. +- Do not change `VisibilityDates.jsx`, `UnifiedVisibilityControl.jsx`, resource persistence, or backend APIs in this hotfix. +- Do not modify workflow components, routes, polling, environment files, or production feature gates. +- Do not attempt to fix the existing direct-URL workflow exposure in the same branch; track that as a separate production-hardening change. + +## Design + +### Preview state + +The URL query parameter `preview=student` is the source of truth. It survives a refresh, can be exited without losing course-identifying parameters, and is safe if shared: it can only make the view more restrictive. + +Only authenticated users see the preview control. If a logged-out user opens a URL containing `preview=student`, normal student restrictions still apply. + +### Visibility authority + +Authentication and visibility authority must be separate concepts: + +```js +const canBypassVisibility = isAuthenticated && !isStudentPreview; +``` + +`isAuthenticated` continues to describe the real session. `canBypassVisibility` is passed to every visibility decision in `CourseRecords`, `RecordCard`, and `RecordTable`. Preview mode must also suppress staff-only visibility dates and unavailable-link annotations so that the result matches a logged-out view. + +The helper that interprets and updates the preview query parameter will live in `src/util/studentPreview.js`. Keeping URL manipulation pure makes it testable without adding a component-testing dependency to this production hotfix. + +### User interface + +Authenticated users see **Preview as student** near the course-page controls. When enabled, the page displays a warning banner: + +> Student preview is active. Resources and links outside their visibility dates are hidden as they are for students. + +The banner includes **Exit student preview**. Both actions preserve `college`, `courseListingId`, `id`, `section`, and any future query parameters. + +### Failure behavior + +Preview has no API dependency. Invalid preview parameter values are ignored. If visibility data is absent, existing visibility behavior remains unchanged. + +If a logged-out production smoke test shows that a saved toggle is not honored, release is blocked. That would confirm a separate persistence or normalization defect and must not be hidden by the preview UX. + +## Files + +- Create `src/util/studentPreview.js` — preview query parsing, query updates, and visibility-bypass calculation. +- Create `src/util/studentPreview.test.js` — pure unit tests for preview semantics and parameter preservation. +- Modify `package.json` — expose the existing Vitest dependency through `npm test`. +- Modify `src/pages/CourseRecords.jsx` — render the control/banner and supply visibility authority. +- Modify `src/components/page-sections/course-record/RecordCard.jsx` — consume explicit visibility authority. +- Modify `src/components/page-sections/course-record/RecordTable.jsx` — consume explicit visibility authority. + +## Acceptance criteria + +- Logged-in staff default view remains unchanged. +- Logged-in staff student preview matches a logged-out view for whole resources, primary links, and additional links. +- Preview works in card, table, combined, and split views. +- Entering and exiting preview preserves all unrelated query parameters. +- No authentication, backend, workflow, route, environment, or deployment files change. +- Unit tests, targeted lint, and the production Vite build pass. +- A manual smoke test confirms saved visibility settings work while fully logged out before release. + +## Rollout and rollback + +Build from this hotfix branch using the existing production command, deploy through the current production process, and smoke-test both staff and logged-out sessions. Rollback consists of redeploying the artifact from production baseline `eacfc14d` because this hotfix has no schema, API, or persistent-data changes. diff --git a/package.json b/package.json index 9605a25..74868d1 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "version": "1.1.1", "type": "module", "scripts": { + "test": "vitest run --workspace vitest.unit.workspace.js", "dev": "vite", "build": "vite build", "build:staging": "vite build --mode staging", diff --git a/src/components/page-sections/course-record/RecordCard.jsx b/src/components/page-sections/course-record/RecordCard.jsx index 564a24f..c20ba74 100644 --- a/src/components/page-sections/course-record/RecordCard.jsx +++ b/src/components/page-sections/course-record/RecordCard.jsx @@ -18,9 +18,17 @@ import { import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faExternalLinkAlt, faBook, faClock, faInfoCircle} from '@fortawesome/free-solid-svg-icons'; import { trackingService } from '../../../services/trackingService'; -import { useAuth } from '../../../contexts/AuthContext'; import { sanitizeHtml, containsHtml } from '../../../util/htmlUtils'; -import { isPrimaryLinkVisible, isLinkVisible, getVisibilityInfo } from '../../../util/resourceVisibility'; +import { + getVisibilityInfo, + isLinkVisible, + isPrimaryLinkVisible, + parseVisibilityDate, +} from '../../../util/resourceVisibility'; + +const formatVisibilityDate = (value, boundary = 'start') => ( + parseVisibilityDate(value, boundary)?.toLocaleDateString() +); /** * RecordCard component @@ -39,6 +47,7 @@ import { isPrimaryLinkVisible, isLinkVisible, getVisibilityInfo } from '../../.. * @param {Object} props.courseInfo - Information about the associated course * @param {string} props.collegeParam - College parameter for tracking * @param {boolean} props.showVisibilityMessages - Whether to show visibility messages instead of hiding items + * @param {boolean} props.canBypassVisibility - Whether visibility restrictions can be bypassed * @returns {JSX.Element|null} The record card or null if visibility conditions aren't met */ const RecordCard = ({ @@ -51,10 +60,8 @@ const RecordCard = ({ courseInfo, collegeParam, showVisibilityMessages = true, + canBypassVisibility = false, }) => { - // Get authentication state directly from context - const { isAuthenticated } = useAuth(); - // State for managing popover visibility const [activePopover, setActivePopover] = useState(null); @@ -170,8 +177,8 @@ const RecordCard = ({ */ const checkVisibility = () => { if (isElectronic && resource) { - // Authenticated users can see all resources regardless of visibility window - if (isAuthenticated) { + // Users authorized to bypass visibility can see all resources regardless of visibility window + if (canBypassVisibility) { return { isVisible: true }; } @@ -179,12 +186,8 @@ const RecordCard = ({ // Use resource-level visibility dates - const startVisibility = resource.start_visibility - ? new Date(resource.start_visibility) - : null; - const endVisibility = resource.end_visibility - ? new Date(resource.end_visibility) - : null; + const startVisibility = parseVisibilityDate(resource.start_visibility); + const endVisibility = parseVisibilityDate(resource.end_visibility, 'end'); // If current time is before the start of the visibility window if (startVisibility && now < startVisibility) { @@ -210,8 +213,8 @@ const RecordCard = ({ // Check visibility const { isVisible, message } = checkVisibility(); - // For non-authenticated users, completely skip rendering resources that aren't visible - if (!isVisible && !isAuthenticated && !showVisibilityMessages) { + // For users without visibility-bypass authority, skip resources that aren't visible + if (!isVisible && !canBypassVisibility && !showVisibilityMessages) { return null; // Simply return null for invisible resources, let parent component show the message } @@ -247,7 +250,7 @@ const RecordCard = ({ const reserveMaterialTypes = getReserveMaterialTypes(); // Get visibility information for electronic resources - const visibilityInfo = isElectronic && resource ? getVisibilityInfo(resource, isAuthenticated) : { showVisibilityDates: false }; + const visibilityInfo = isElectronic && resource ? getVisibilityInfo(resource, canBypassVisibility) : { showVisibilityDates: false }; const showVisibilityDates = visibilityInfo.showVisibilityDates; /** @@ -515,11 +518,13 @@ const RecordCard = ({ }); }; - // Check if resource has additional links - const hasAdditionalLinks = resource.links && resource.links.length > 0; + const visibleAdditionalLinks = Array.isArray(resource.links) + ? resource.links.filter(link => isLinkVisible(link, canBypassVisibility)) + : []; + const hasAdditionalLinks = visibleAdditionalLinks.length > 0; // Check if the primary resource link is visible based on visibility settings - const isPrimaryLinkVisibleCheck = isPrimaryLinkVisible(resource, isAuthenticated); + const isPrimaryLinkVisibleCheck = isPrimaryLinkVisible(resource, canBypassVisibility); return (
@@ -540,25 +545,18 @@ const RecordCard = ({ )} - {/* Show message when primary link is not available */} - {resource.item_url && !isPrimaryLinkVisibleCheck && ( -
- Access Resource: The link to this resource is not currently available due to visibility restrictions. -
- )} - {showVisibilityDates && (
Visibility Window:{' '} {visibilityInfo.usePrimaryLinkVisibility ? ( <> - {visibilityInfo.startDate ? `From ${new Date(visibilityInfo.startDate).toLocaleDateString()}` : 'No start date'}{' '} - {visibilityInfo.endDate ? `until ${new Date(visibilityInfo.endDate).toLocaleDateString()}` : 'No end date'} + {visibilityInfo.startDate ? `From ${formatVisibilityDate(visibilityInfo.startDate)}` : 'No start date'}{' '} + {visibilityInfo.endDate ? `until ${formatVisibilityDate(visibilityInfo.endDate, 'end')}` : 'No end date'} ) : ( <> - {visibilityInfo.startDate ? `From ${new Date(visibilityInfo.startDate).toLocaleDateString()}` : 'No start date'}{' '} - {visibilityInfo.endDate ? `until ${new Date(visibilityInfo.endDate).toLocaleDateString()}` : 'No end date'} + {visibilityInfo.startDate ? `From ${formatVisibilityDate(visibilityInfo.startDate)}` : 'No start date'}{' '} + {visibilityInfo.endDate ? `until ${formatVisibilityDate(visibilityInfo.endDate, 'end')}` : 'No end date'} )}
@@ -598,22 +596,14 @@ const RecordCard = ({

Additional Resources

- {resource.links.map((link, index) => { - // Check if this individual link is visible - const isCurrentLinkVisible = isLinkVisible(link, isAuthenticated); - - // Always render the link item, but conditionally show the actual link - return ( -
+ {visibleAdditionalLinks.map((link, index) => ( +

{link.title || 'Additional Resource'}

{link.use_proxy === "1" && ( Proxy Enabled )} - {!isCurrentLinkVisible && ( - Not Currently Available - )}
@@ -621,41 +611,30 @@ const RecordCard = ({

{link.description}

)} - {/* Show link visibility dates for authenticated users */} - {isAuthenticated && link.use_link_visibility && (link.start_visibility || link.end_visibility) && ( + {/* Show link visibility dates for users authorized to bypass visibility */} + {canBypassVisibility && link.use_link_visibility && (link.start_visibility || link.end_visibility) && (
Link Visibility:{' '} - {link.start_visibility ? `From ${new Date(link.start_visibility).toLocaleDateString()}` : 'No start date'}{' '} - {link.end_visibility ? `until ${new Date(link.end_visibility).toLocaleDateString()}` : 'No end date'} + {link.start_visibility ? `From ${formatVisibilityDate(link.start_visibility)}` : 'No start date'}{' '} + {link.end_visibility ? `until ${formatVisibilityDate(link.end_visibility, 'end')}` : 'No end date'}
)} - {/* Only show the actual link if it's visible */} - {isCurrentLinkVisible && ( - handleAdditionalLinkClick(e, link)} - className="text-primary d-block mb-1" - target="_blank" - rel="noreferrer noopener" - aria-label={`Access ${link.title || 'additional resource'} (opens in new tab)`} - > - - {link.url} - - - )} - - {/* Show a message when link is not available */} - {!isCurrentLinkVisible && ( -

- Link not currently available due to visibility restrictions. -

- )} -
- ); - })} + handleAdditionalLinkClick(e, link)} + className="text-primary d-block mb-1" + target="_blank" + rel="noreferrer noopener" + aria-label={`Access ${link.title || 'additional resource'} (opens in new tab)`} + > + + {link.url} + + +
+ ))}
)} @@ -754,8 +733,8 @@ const RecordCard = ({ {message}
)} - {/* Display visibility information for authenticated users */} - {isAuthenticated && isElectronic && resource && showVisibilityDates && ( + {/* Display visibility information for users authorized to bypass visibility */} + {canBypassVisibility && isElectronic && resource && showVisibilityDates && ( - {/* Popover for visibility dates (authenticated users only) */} - {isAuthenticated && isElectronic && resource && showVisibilityDates && ( + {/* Popover for visibility dates (users authorized to bypass visibility only) */} + {canBypassVisibility && isElectronic && resource && showVisibilityDates && ( Visibility Window
{visibilityInfo.startDate && ( -
From: {new Date(visibilityInfo.startDate).toLocaleDateString()}
+
From: {formatVisibilityDate(visibilityInfo.startDate)}
)} {visibilityInfo.endDate && ( -
Until: {new Date(visibilityInfo.endDate).toLocaleDateString()}
+
Until: {formatVisibilityDate(visibilityInfo.endDate, 'end')}
)}
@@ -832,6 +811,10 @@ RecordCard.propTypes = { * Whether the record is displayed as part of a group */ isGrouped: PropTypes.bool, + /** + * Whether visibility restrictions can be bypassed + */ + canBypassVisibility: PropTypes.bool, /** * Availability information keyed by instance ID */ @@ -904,7 +887,8 @@ RecordCard.defaultProps = { isGrouped: false, courseInfo: {}, collegeParam: 'Unknown', - showVisibilityMessages: true + showVisibilityMessages: true, + canBypassVisibility: false, }; export default RecordCard; diff --git a/src/components/page-sections/course-record/RecordCard.test.jsx b/src/components/page-sections/course-record/RecordCard.test.jsx new file mode 100644 index 0000000..0540717 --- /dev/null +++ b/src/components/page-sections/course-record/RecordCard.test.jsx @@ -0,0 +1,83 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import RecordCard from './RecordCard'; + +const futureDate = '2099-01-01T00:00:00.000Z'; + +const renderCard = (resource, canBypassVisibility = false, options = {}) => ( + renderToStaticMarkup(createElement(RecordCard, { + recordItem: { + id: 'card-resource', + isElectronic: true, + copiedItem: { instanceId: 'card-instance', title: 'Card resource' }, + resource, + }, + availability: {}, + openAccordions: {}, + toggleAccordion: () => {}, + customization: {}, + courseInfo: {}, + collegeParam: 'test-college', + canBypassVisibility, + ...options, + })) +); + +describe('RecordCard student visibility', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('removes an out-of-window additional link entry for students but retains it for staff', () => { + const resource = { + links: [{ + link_id: 'future-card-link', + title: 'Hidden card link title', + description: 'Hidden card link description', + url: 'https://example.test/hidden-card-link', + use_proxy: '1', + use_link_visibility: '1', + start_visibility: futureDate, + }], + }; + + const studentMarkup = renderCard(resource); + const staffMarkup = renderCard(resource, true); + + expect(studentMarkup).not.toContain('Hidden card link title'); + expect(studentMarkup).not.toContain('Hidden card link description'); + expect(studentMarkup).not.toContain('https://example.test/hidden-card-link'); + expect(studentMarkup).not.toContain('Proxy Enabled'); + expect(studentMarkup).not.toContain('Not Currently Available'); + expect(studentMarkup).not.toContain('Link not currently available'); + + expect(staffMarkup).toContain('Hidden card link title'); + expect(staffMarkup).toContain('Hidden card link description'); + expect(staffMarkup).toContain('https://example.test/hidden-card-link'); + expect(staffMarkup).toContain('Proxy Enabled'); + expect(staffMarkup).toContain('Link Visibility'); + }); + + it('does not annotate a hidden primary link in student output', () => { + const markup = renderCard({ + item_url: 'https://example.test/future-primary', + use_primary_link_visibility: '1', + primary_link_start_visibility: futureDate, + }); + + expect(markup).not.toContain('https://example.test/future-primary'); + expect(markup).not.toContain('The link to this resource is not currently available'); + }); + + it('keeps a resource with a date-only end visible for the entire local end date', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 7, 22, 12, 0, 0)); + + const markup = renderCard({ end_visibility: '2026-08-22' }, false, { + showVisibilityMessages: false, + }); + + expect(markup).toContain('Card resource'); + }); +}); diff --git a/src/components/page-sections/course-record/RecordTable.jsx b/src/components/page-sections/course-record/RecordTable.jsx index b4fccf8..dc842c8 100644 --- a/src/components/page-sections/course-record/RecordTable.jsx +++ b/src/components/page-sections/course-record/RecordTable.jsx @@ -12,11 +12,56 @@ import { } from '@fortawesome/free-solid-svg-icons'; // Import tracking service import { trackingService } from '../../../services/trackingService'; -// Import Auth Context -import { useAuth } from '../../../contexts/AuthContext'; import { sanitizeHtml, containsHtml } from '../../../util/htmlUtils'; -import { isPrimaryLinkVisible, isLinkVisible, getVisibilityInfo } from '../../../util/resourceVisibility'; -import { useRecordsTextStore, selectRecordTableText, selectVisibilityText, selectAccessibilityText, selectCourseRecordsText, selectSplitViewText } from '../../../stores/recordsTextStore'; +import { + getVisibilityInfo, + isLinkVisible, + isPrimaryLinkVisible, + parseVisibilityDate, +} from '../../../util/resourceVisibility'; +import { useRecordsTextStore, selectRecordTableText, selectAccessibilityText, selectCourseRecordsText, selectSplitViewText } from '../../../stores/recordsTextStore'; + +const formatVisibilityDate = (value, boundary = 'start') => ( + parseVisibilityDate(value, boundary)?.toLocaleDateString() +); + +const getRecordVisibility = (item, canBypassVisibility) => { + if (item.isElectronic && item.resource) { + // Users authorized to bypass visibility can see all resources regardless of visibility window + if (canBypassVisibility) { + return { isVisible: true }; + } + + const now = new Date(); + const startVisibility = parseVisibilityDate(item.resource.start_visibility); + const endVisibility = parseVisibilityDate(item.resource.end_visibility, 'end'); + + // If current time is before the start of the visibility window + if (startVisibility && now < startVisibility) { + return { + isVisible: false, + message: `Available from ${startVisibility.toLocaleDateString()}`, + startDate: startVisibility + }; + } + + // If current time is after the end of the visibility window + if (endVisibility && now > endVisibility) { + return { + isVisible: false, + message: `Available until ${endVisibility.toLocaleDateString()}`, + endDate: endVisibility + }; + } + } + return { isVisible: true }; +}; + +const getVisibleLinks = (links, canBypassVisibility) => ( + Array.isArray(links) + ? links.filter(link => isLinkVisible(link, canBypassVisibility)) + : [] +); /** * RecordTable component @@ -35,6 +80,7 @@ import { useRecordsTextStore, selectRecordTableText, selectVisibilityText, selec * @param {boolean} props.showVisibilityMessages - Whether to show visibility messages * @param {string} props.viewMode - View mode for the table ('combined' or 'split') * @param {Array} props.records - Array of individual record items + * @param {boolean} props.canBypassVisibility - Whether visibility restrictions can be bypassed * @returns {JSX.Element} A table of course records with interactive elements */ const RecordTable = ({ @@ -46,18 +92,15 @@ const RecordTable = ({ collegeParam, showVisibilityMessages = true, viewMode = 'combined', - records = [] + records = [], + canBypassVisibility = false, }) => { const [activePopover, setActivePopover] = useState(null); const [expandedLinkItems, setExpandedLinkItems] = useState({}); const [showHiddenItems] = useState(false); - // Get authentication state from context - const { isAuthenticated } = useAuth(); - // Get text from the store const recordTableText = useRecordsTextStore(selectRecordTableText); - const visibilityText = useRecordsTextStore(selectVisibilityText); const accessibilityText = useRecordsTextStore(selectAccessibilityText); const courseRecordsText = useRecordsTextStore(selectCourseRecordsText); const splitViewText = useRecordsTextStore(selectSplitViewText); @@ -94,40 +137,8 @@ const RecordTable = ({ * @returns {Object} Object containing visibility status and message */ const checkVisibility = useCallback((item) => { - if (item.isElectronic && item.resource) { - // Authenticated users can see all resources regardless of visibility window - if (isAuthenticated) { - return { isVisible: true }; - } - - const now = new Date(); - const startVisibility = item.resource.start_visibility - ? new Date(item.resource.start_visibility) - : null; - const endVisibility = item.resource.end_visibility - ? new Date(item.resource.end_visibility) - : null; - - // If current time is before the start of the visibility window - if (startVisibility && now < startVisibility) { - return { - isVisible: false, - message: `Available from ${startVisibility.toLocaleDateString()}`, - startDate: startVisibility - }; - } - - // If current time is after the end of the visibility window - if (endVisibility && now > endVisibility) { - return { - isVisible: false, - message: `Available until ${endVisibility.toLocaleDateString()}`, - endDate: endVisibility - }; - } - } - return { isVisible: true }; - }, [isAuthenticated]); + return getRecordVisibility(item, canBypassVisibility); + }, [canBypassVisibility]); // Process visibility for all items const processedResults = useMemo(() => { @@ -146,13 +157,13 @@ const RecordTable = ({ if (item.resource?.start_visibility) { scheduleInfo.push({ title: item.copiedItem?.title, - date: new Date(item.resource.start_visibility), + date: parseVisibilityDate(item.resource.start_visibility), type: 'upcoming' }); } else if (item.resource?.end_visibility) { scheduleInfo.push({ title: item.copiedItem?.title, - date: new Date(item.resource.end_visibility), + date: parseVisibilityDate(item.resource.end_visibility, 'end'), type: 'past' }); } @@ -164,7 +175,7 @@ const RecordTable = ({ // Only include visible items in the processed group const visibleItems = processedGroupItems.filter(item => - item.visibility.isVisible || isAuthenticated + item.visibility.isVisible || canBypassVisibility ); return { @@ -181,13 +192,13 @@ const RecordTable = ({ if (result.resource?.start_visibility) { scheduleInfo.push({ title: result.copiedItem?.title, - date: new Date(result.resource.start_visibility), + date: parseVisibilityDate(result.resource.start_visibility), type: 'upcoming' }); } else if (result.resource?.end_visibility) { scheduleInfo.push({ title: result.copiedItem?.title, - date: new Date(result.resource.end_visibility), + date: parseVisibilityDate(result.resource.end_visibility, 'end'), type: 'past' }); } @@ -198,14 +209,14 @@ const RecordTable = ({ } }); - // Filter out items that aren't visible (unless user is authenticated) + // Filter out items that aren't visible unless visibility can be bypassed const filteredItems = processedItems.filter(item => { if ('items' in item) { // Group items return item.items.length > 0; // Only keep groups with visible items } // Individual items - return item.visibility.isVisible || isAuthenticated; + return item.visibility.isVisible || canBypassVisibility; }); // Sort upcoming items by date @@ -221,7 +232,7 @@ const RecordTable = ({ upcomingItems, nextAvailableDate: upcomingItems.length > 0 ? upcomingItems[0].date : null }; - }, [combinedResults, isAuthenticated, checkVisibility]); + }, [combinedResults, canBypassVisibility, checkVisibility]); const { items: processedItems, @@ -354,23 +365,23 @@ const RecordTable = ({ // Check primary link visibility for electronic resources const isPrimaryLinkVisibleCheck = item.isElectronic && item.resource - ? isPrimaryLinkVisible(item.resource, isAuthenticated) + ? isPrimaryLinkVisible(item.resource, canBypassVisibility) : true; const resourceUrl = item.isElectronic && item.resource && isPrimaryLinkVisibleCheck ? item.resource.item_url : (!item.isElectronic ? (item.copiedItem?.uri || item.copiedItem?.url) : null); - const hasAdditionalLinks = item.isElectronic && - item.resource && - Array.isArray(item.resource.links) && - item.resource.links.length > 0; + const visibleAdditionalLinks = item.isElectronic + ? getVisibleLinks(item.resource?.links, canBypassVisibility) + : []; + const hasAdditionalLinks = visibleAdditionalLinks.length > 0; const isExpanded = expandedLinkItems[item.id] || false; // Get visibility information for electronic resources const visibilityInfo = item.isElectronic && item.resource - ? getVisibilityInfo(item.resource, isAuthenticated) + ? getVisibilityInfo(item.resource, canBypassVisibility) : { showVisibilityDates: false }; const showVisibilityDates = visibilityInfo.showVisibilityDates; @@ -411,10 +422,10 @@ const RecordTable = ({
Visibility Window
{visibilityInfo.startDate && ( -
From: {new Date(visibilityInfo.startDate).toLocaleDateString()}
+
From: {formatVisibilityDate(visibilityInfo.startDate)}
)} {visibilityInfo.endDate && ( -
Until: {new Date(visibilityInfo.endDate).toLocaleDateString()}
+
Until: {formatVisibilityDate(visibilityInfo.endDate, 'end')}
)}
@@ -515,34 +526,7 @@ const RecordTable = ({ style={{ color: customization.buttonPrimaryColor }} > - {item.resource.links.length} additional {item.resource.links.length === 1 ? 'link' : 'links'} - - - - - )} - - ) : item.isElectronic && item.resource && item.resource.item_url && !isPrimaryLinkVisibleCheck ? ( -
- - - {visibilityText.notCurrentlyAvailable} - - {hasAdditionalLinks && ( -
-
@@ -658,8 +629,9 @@ const RecordTable = ({ * Render the table in split view mode */ const renderSplitView = () => { - const printRecords = records.filter(item => !item.isElectronic); - const electronicRecords = records.filter(item => item.isElectronic); + const visibleRecords = records.filter(item => checkVisibility(item).isVisible); + const printRecords = visibleRecords.filter(item => !item.isElectronic); + const electronicRecords = visibleRecords.filter(item => item.isElectronic); return ( @@ -743,9 +715,10 @@ const RecordTable = ({ {electronicRecords .sort((a, b) => a.copiedItem.title.localeCompare(b.copiedItem.title)) .map(item => { - const isPrimaryLinkVisibleCheck = isPrimaryLinkVisible(item.resource, isAuthenticated); + const isPrimaryLinkVisibleCheck = isPrimaryLinkVisible(item.resource, canBypassVisibility); const resourceUrl = item.resource?.item_url && isPrimaryLinkVisibleCheck ? item.resource.item_url : null; - const hasAdditionalLinks = item.resource?.links?.length > 0; + const visibleAdditionalLinks = getVisibleLinks(item.resource?.links, canBypassVisibility); + const hasAdditionalLinks = visibleAdditionalLinks.length > 0; const isExpanded = expandedLinkItems[item.id] || false; return ( @@ -787,30 +760,7 @@ const RecordTable = ({ aria-expanded={isExpanded} style={{ color: customization.buttonPrimaryColor }} > - {item.resource.links.length} more - - - )} -
- ) : item.resource?.item_url && !isPrimaryLinkVisibleCheck ? ( -
- - - {visibilityText.notCurrentlyAvailable} - - {hasAdditionalLinks && ( -
)} + {isAuthenticated && !studentPreview && ( +
+ +
+ )} + + {studentPreview && ( + + + Student preview is active.{' '} + Resources and links outside their visibility dates are hidden as they are for students. + + + + )} +