Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 121 additions & 21 deletions src/pages/datapoints.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Layout from '@theme/Layout';
import BrowserOnly from '@docusaurus/BrowserOnly';
import Head from '@docusaurus/Head';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import { getMe, listDp, getFilterOptions, getCounts } from '@site/src/lib/dp/api';
import { getMe, listDp, getFilterOptions, getCounts, listPrograms } from '@site/src/lib/dp/api';
import SignInButtons from '@site/src/lib/auth/SignInButtons';
import { startOAuth } from '@site/src/lib/auth/oauth';
import styles from './datapoints.module.css';
Expand All @@ -19,7 +19,7 @@ const EMPTY_FILTER_OPTS = { schools: [], tiers: [], years: [], ugCats: [], major
const COPY = {
'zh-Hans': {
pageTitle: 'DataPoints',
pageDesc: 'CS / MSCS 申请历史 DataPoints — 可按学校、Tier、年份、结果、本科背景筛选',
pageDesc: 'CS / MSCS 申请历史 DataPoints — 可按项目、年份、结果、本科背景筛选',
headerTitle: 'CS Application DataPoints',
metaApplicants: '位申请者',
metaPrograms: '个项目',
Expand All @@ -29,8 +29,8 @@ const COPY = {
loadFail: '加载数据失败:',
searchPlaceholder: '搜索学校 / 项目',
searchLabel: '搜索',
filterSchool: '学校',
filterTier: '项目档次',
filterProgram: '项目',
programSearchPlaceholder: '搜索学校 / 项目',
filterYear: '年份',
filterResult: '结果',
filterUgCat: '本科类别',
Expand Down Expand Up @@ -110,7 +110,7 @@ const COPY = {
},
en: {
pageTitle: 'DataPoints',
pageDesc: 'CS / MSCS application DataPoints — filterable by school, tier, year, result, and undergrad background',
pageDesc: 'CS / MSCS application DataPoints — filterable by program, year, result, and undergrad background',
headerTitle: 'CS Application DataPoints',
metaApplicants: 'applicants',
metaPrograms: 'programs',
Expand All @@ -120,8 +120,8 @@ const COPY = {
loadFail: 'Failed to load data: ',
searchPlaceholder: 'Search school / program',
searchLabel: 'Search',
filterSchool: 'School',
filterTier: 'Program tier',
filterProgram: 'Program',
programSearchPlaceholder: 'Search school / program',
filterYear: 'Year',
filterResult: 'Result',
filterUgCat: 'Undergrad type',
Expand Down Expand Up @@ -196,7 +196,7 @@ function pickLocale(loc) {

// ---------- URL <-> filter state helpers ----------

const URL_KEYS = ['school', 'tier', 'year', 'result', 'ugCat', 'major'];
const URL_KEYS = ['programId', 'year', 'result', 'ugCat', 'major'];

function readFiltersFromUrl() {
if (typeof window === 'undefined') return {};
Expand Down Expand Up @@ -332,19 +332,29 @@ function Table({ counts, filterOpts, me, meChecked, t }) {
const [openApplicantId, setOpenApplicantId] = useState(null);

const initial = useMemo(() => readFiltersFromUrl(), []);
const [school, setSchool] = useState(initial.school || '');
const [tier, setTier] = useState(initial.tier || '');
const [programId, setProgramId] = useState(initial.programId || '');
const [programs, setPrograms] = useState([]);
const [year, setYear] = useState(initial.year || '');
const [result, setResult] = useState(initial.result || '');
const [ugCat, setUgCat] = useState(initial.ugCat || '');
const [major, setMajor] = useState(initial.major || '');
const [page, setPage] = useState(0);

// Program list powers the searchable "项目" filter and resolves a programId
// (e.g. one restored from the URL) back to its school·program label.
useEffect(() => {
writeFiltersToUrl({ school, tier, year, result, ugCat, major });
}, [school, tier, year, result, ugCat, major]);
let cancelled = false;
listPrograms({ limit: 500 })
.then((r) => { if (!cancelled) setPrograms(r.rows || []); })
.catch(() => {});
return () => { cancelled = true; };
}, []);

useEffect(() => {
writeFiltersToUrl({ programId, year, result, ugCat, major });
}, [programId, year, result, ugCat, major]);

useEffect(() => setPage(0), [school, tier, year, result, ugCat, major]);
useEffect(() => setPage(0), [programId, year, result, ugCat, major]);

// Fetch rows from API on filter/page change. Keep previous rows visible
// while a new fetch is in flight so the table doesn't blink.
Expand All @@ -366,8 +376,7 @@ function Table({ counts, filterOpts, me, meChecked, t }) {
setApiError(null);
try {
const res = await listDp({
school: school || undefined,
tier: tier || undefined,
programId: programId || undefined,
year: year || undefined,
result: result || undefined,
ugCategory: ugCat || undefined,
Expand All @@ -385,21 +394,31 @@ function Table({ counts, filterOpts, me, meChecked, t }) {
}
}, delay);
return () => { cancelled = true; clearTimeout(handle); };
}, [school, tier, year, result, ugCat, major, page]);
}, [programId, year, result, ugCat, major, page]);

const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
const paged = rows;

const selectedProgram = useMemo(
() => programs.find((p) => p.id === programId) || null,
[programs, programId],
);

const activeFilters = useMemo(() => {
const items = [];
if (school) items.push({ key: 'school', label: t.filterSchool, value: school });
if (tier) items.push({ key: 'tier', label: t.filterTier, value: tier });
if (programId) {
items.push({
key: 'programId',
label: t.filterProgram,
value: selectedProgram ? `${selectedProgram.school} · ${selectedProgram.program}` : programId,
});
}
if (year) items.push({ key: 'year', label: t.filterYear, value: year });
if (result) items.push({ key: 'result', label: t.filterResult, value: result });
if (ugCat) items.push({ key: 'ugCat', label: t.filterUgCat, value: ugCat });
if (major) items.push({ key: 'major', label: t.filterMajor, value: major });
return items;
}, [school, tier, year, result, ugCat, major, t]);
}, [programId, selectedProgram, year, result, ugCat, major, t]);

return (
<div className={styles.wrap}>
Expand Down Expand Up @@ -428,8 +447,15 @@ function Table({ counts, filterOpts, me, meChecked, t }) {
</header>

<section className={styles.filters} aria-label="filters">
<Select label={t.filterSchool} value={school} onChange={setSchool} options={filterOpts.schools} allText={t.filterAll} />
<Select label={t.filterTier} value={tier} onChange={setTier} options={filterOpts.tiers} allText={t.filterAll} />
<ProgramFilter
label={t.filterProgram}
programs={programs}
value={programId}
onChange={setProgramId}
placeholder={t.programSearchPlaceholder}
allText={t.filterAll}
selected={selectedProgram}
/>
<Select label={t.filterYear} value={year} onChange={setYear} options={filterOpts.years} allText={t.filterAll} />
<Select label={t.filterResult} value={result} onChange={setResult} options={RESULT_OPTIONS} allText={t.filterAll} />
<Select label={t.filterUgCat} value={ugCat} onChange={setUgCat} options={filterOpts.ugCats} allText={t.filterAll} />
Expand Down Expand Up @@ -849,6 +875,80 @@ function Select({ label, value, onChange, options, allText }) {
);
}

// Searchable single-program filter. Replaces the old school + tier dropdowns:
// pick one exact program (school + program name) and the table narrows to it
// via the `programId` query param. `selected` is the resolved program object
// for `value` (or null), passed in so the label can render before the list
// finishes loading.
function ProgramFilter({ label, programs, value, onChange, placeholder, allText, selected }) {
const [query, setQuery] = useState('');
const [open, setOpen] = useState(false);
const blurTimer = useRef(null);

const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
const base = q
? programs.filter((p) => `${p.school} ${p.program}`.toLowerCase().includes(q))
: programs;
return base.slice(0, 40);
}, [programs, query]);

// Show the chosen program's label when one is selected and the field isn't
// being actively edited; otherwise show the live search query.
const inputValue = selected && !open ? `${selected.school} · ${selected.program}` : query;

function pick(p) {
onChange(p.id);
setQuery('');
setOpen(false);
}
function clear() {
onChange('');
setQuery('');
setOpen(false);
}

return (
<label className={`${styles.selectWrap} ${styles.programFilter}`}>
<span>{label}</span>
<div className={styles.programFilterControl}>
<input
type="text"
className={styles.programFilterInput}
value={inputValue}
placeholder={selected ? '' : placeholder}
aria-label={label}
onFocus={() => { if (blurTimer.current) clearTimeout(blurTimer.current); setOpen(true); }}
onBlur={() => { blurTimer.current = setTimeout(() => setOpen(false), 150); }}
onChange={(e) => { if (value) onChange(''); setQuery(e.target.value); setOpen(true); }}
/>
{value ? (
<button
type="button"
className={styles.programFilterClear}
aria-label={allText}
title={allText}
onMouseDown={(e) => e.preventDefault()}
onClick={clear}
>
×
</button>
) : null}
{open && filtered.length > 0 ? (
<ul className={styles.programFilterDropdown}>
{filtered.map((p) => (
<li key={p.id} onMouseDown={(e) => e.preventDefault()} onClick={() => pick(p)}>
<span className={styles.programFilterName}><b>{p.school}</b> · {p.program}</span>
{p.tier ? <span className={styles.programFilterTier}>{p.tier}</span> : null}
</li>
))}
</ul>
) : null}
</div>
</label>
);
}

function pillClass(result) {
switch (result) {
case 'Admit': return styles.pillAdmit;
Expand Down
80 changes: 80 additions & 0 deletions src/pages/datapoints.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,86 @@
width: 100%;
}

/* ---------- Searchable program filter ---------- */
.programFilter {
position: relative;
/* Span more grid width than a plain select — program labels are long. */
grid-column: span 2;
}
.programFilterControl {
position: relative;
width: 100%;
}
.programFilterInput {
padding: 6px 26px 6px 10px;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 6px;
font-size: 13px;
background: var(--ifm-background-color);
color: var(--ifm-font-color-base);
width: 100%;
text-overflow: ellipsis;
}
.programFilterClear {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
border: none;
background: transparent;
color: var(--ifm-color-emphasis-600);
font-size: 16px;
line-height: 1;
cursor: pointer;
padding: 0 2px;
}
.programFilterClear:hover {
color: var(--ifm-font-color-base);
}
.programFilterDropdown {
position: absolute;
z-index: 30;
top: calc(100% + 4px);
left: 0;
right: 0;
margin: 0;
padding: 4px;
list-style: none;
max-height: 280px;
overflow-y: auto;
background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.programFilterDropdown li {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.programFilterDropdown li:hover {
background: var(--ifm-color-emphasis-100);
}
.programFilterName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.programFilterTier {
margin-left: auto;
flex-shrink: 0;
font-size: 11px;
font-weight: 600;
padding: 1px 6px;
border-radius: 4px;
background: var(--ifm-color-emphasis-200);
color: var(--ifm-color-emphasis-800);
}

.summary {
font-size: 13px;
color: var(--ifm-color-emphasis-700);
Expand Down