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
149 changes: 149 additions & 0 deletions apps/platform/src/pages/ProjectsPage/ProjectCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { useState } from "react";
import { Box, Button, Card, CardContent, CardActions, Chip, Typography } from "@mui/material";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowUpRightFromSquare, faCircleCheck, faCircleNotch } from "@fortawesome/free-solid-svg-icons";
import { DetailPopover, Link, OtAsyncTooltip } from "ui";

type Disease = { label?: string; disease_id: string };

type ProjectData = {
otar_code: string;
project_name: string;
project_lead: string;
generates_data: string;
integrated_into_PPP: "Y" | "N";
integrated_into_Public: "Y" | "N";
project_status: string;
open_targets_therapeutic_area: string;
disease_mapping: Disease[];
};

const INTEGRATION_ICON = {
Y: faCircleCheck,
N: faCircleNotch,
};

function IntegrationBadge({ label, value }: { label: string; value: "Y" | "N" }) {
return (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<Box
component="span"
sx={{ color: value === "Y" ? "primary.main" : "grey.400", display: "flex" }}
>
<FontAwesomeIcon icon={INTEGRATION_ICON[value]} size="sm" />
</Box>
<Typography variant="caption" color={value === "Y" ? "text.primary" : "text.disabled"}>
{label}
</Typography>
</Box>
);
}

function ProjectCard({ data }: { data: ProjectData }) {
const diseases = data.disease_mapping?.filter(d => d?.disease_id) ?? [];

return (
<Card
sx={{
width: "350px",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
boxShadow: "none",
border: theme => `1px solid ${theme.palette.grey[300]}`,
"&:hover": {
boxShadow: theme => theme.shadows[3],
},
}}
>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{/* OTAR code overline */}
{data.otar_code && (
<Typography variant="overline" color="text.secondary" sx={{ lineHeight: 1, mb: 0.5 }}>
{data.otar_code}
</Typography>
)}

{/* Project name */}
<Typography
variant="h6"
component="div"
sx={{
fontWeight: "bold",
lineHeight: 1.3,
mb: 0.5,
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{data.project_name}
</Typography>

{/* Lead + Status */}
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Typography variant="body2" color="text.secondary">
{data.project_lead}
</Typography>
<Chip
size="small"
label={data.project_status}
color={data.project_status === "Active" ? "success" : "default"}
variant="outlined"
/>
</Box>

{/* Therapeutic area */}
<Typography variant="body2" color="text.secondary">
{data.open_targets_therapeutic_area}
</Typography>

{/* Integration status */}
<Box sx={{ display: "flex", gap: 2 }}>
<IntegrationBadge label="PPP" value={data.integrated_into_PPP} />
<IntegrationBadge label="Public" value={data.integrated_into_Public} />
</Box>

{/* Diseases */}
{diseases.length > 0 && (
<Box sx={{ mt: 0.5 }}>
<DetailPopover
title={`${diseases.length} disease${diseases.length !== 1 ? "s" : ""}`}
popoverId={`diseases-${data.otar_code}`}
>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5, maxWidth: 280 }}>
{diseases.map(d => (
<OtAsyncTooltip key={d.disease_id} entity="disease" id={d.disease_id}>
<Link to={`/disease/${d.disease_id}`}>
<Chip
size="small"
label={d.label || d.disease_id}
clickable
variant="outlined"
sx={{ color: "text.secondary", borderColor: "grey.300" }}
/>
</Link>
</OtAsyncTooltip>
))}
</Box>
</DetailPopover>
</Box>
)}
</CardContent>

<CardActions sx={{ px: 2, pb: 2 }}>
{data.otar_code && (
<Link to={`http://home.opentargets.org/${data.otar_code}`} external newTab>
<Button variant="outlined" color="primary" sx={{ gap: 1 }}>
View Project
<FontAwesomeIcon icon={faArrowUpRightFromSquare} size="sm" />
</Button>
</Link>
)}
</CardActions>
</Card>
);
}

export default ProjectCard;
181 changes: 181 additions & 0 deletions apps/platform/src/pages/ProjectsPage/ProjectsFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import {
Box,
Checkbox,
Chip,
Divider,
FormControlLabel,
FormGroup,
InputAdornment,
Paper,
Radio,
RadioGroup,
TextField,
Typography,
} from "@mui/material";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSearch, faTrash } from "@fortawesome/free-solid-svg-icons";

export const SORT_OPTIONS = [
{ value: "integrated_into_PPP", label: "Integrated into PPP" },
{ value: "project_name", label: "Project Name" },
{ value: "project_status", label: "Project Status" },
{ value: "open_targets_therapeutic_area", label: "Therapeutic Area" },
] as const;

export type SortValue = (typeof SORT_OPTIONS)[number]["value"];

export type ProjectFilters = {
search: string;
sortBy: SortValue;
project_status: string[];
open_targets_therapeutic_area: string[];
integration: string[];
};

export const EMPTY_FILTERS: ProjectFilters = {
search: "",
sortBy: "integrated_into_PPP",
project_status: [],
open_targets_therapeutic_area: [],
integration: [],
};

type FilterKey = keyof Omit<ProjectFilters, "search" | "sortBy">;

type FilterGroup = {
key: FilterKey;
label: string;
options: string[];
};

type Props = {
filters: ProjectFilters;
filterGroups: FilterGroup[];
onChange: (filters: ProjectFilters) => void;
};

function ProjectsFilter({ filters, filterGroups, onChange }: Props) {
function handleSearch(value: string) {
onChange({ ...filters, search: value });
}

function handleCheckbox(key: FilterKey, value: string) {
const current = filters[key];
const next = current.includes(value)
? current.filter(v => v !== value)
: [...current, value];
onChange({ ...filters, [key]: next });
}

function handleClear() {
onChange(EMPTY_FILTERS);
}

const hasActive =
filters.search ||
Object.entries(filters)
.filter(([k]) => k !== "sortBy")
.some(([, v]) => Array.isArray(v) && v.length > 0);

return (
<Paper
variant="outlined"
elevation={0}
sx={{ mb: 2, maxWidth: "350px", width: "100%", height: "fit-content" }}
>
<Box sx={{ p: 3 }}>
<Typography
variant="h6"
component="div"
sx={{
fontWeight: "bold",
mb: 2,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
Filters
{hasActive && (
<Chip
label={
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<FontAwesomeIcon icon={faTrash} />
clear
</Box>
}
size="small"
clickable
sx={{ fontWeight: "normal", typography: "caption" }}
onClick={handleClear}
/>
)}
</Typography>

<Box sx={{ mb: 3 }}>
<TextField
value={filters.search}
onChange={e => handleSearch(e.target.value)}
size="small"
fullWidth
placeholder="Search..."
InputProps={{
startAdornment: (
<InputAdornment position="start">
<FontAwesomeIcon icon={faSearch} />
</InputAdornment>
),
}}
sx={{ "& .MuiInputBase-input": { fontSize: "0.875rem" } }}
/>
</Box>

<Box sx={{ mb: 2 }}>
<Typography variant="subtitle1" component="div" sx={{ fontWeight: "bold" }}>
Sort by
</Typography>
<RadioGroup
value={filters.sortBy}
onChange={e => onChange({ ...filters, sortBy: e.target.value as SortValue })}
>
{SORT_OPTIONS.map(opt => (
<FormControlLabel
key={opt.value}
value={opt.value}
control={<Radio size="small" />}
label={<Typography variant="body2">{opt.label}</Typography>}
/>
))}
</RadioGroup>
</Box>

<Divider sx={{ mb: 2 }} />

{filterGroups.map(group => (
<Box key={group.key}>
<Typography variant="subtitle1" component="div" sx={{ fontWeight: "bold" }}>
{group.label}
</Typography>
<FormGroup sx={{ mb: 1 }}>
{group.options.map(option => (
<FormControlLabel
key={option}
control={
<Checkbox
size="small"
checked={filters[group.key].includes(option)}
onChange={() => handleCheckbox(group.key, option)}
/>
}
label={<Typography variant="body2">{option}</Typography>}
/>
))}
</FormGroup>
</Box>
))}
</Box>
</Paper>
);
}

export default ProjectsFilter;
Loading
Loading