Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add knowledge insights dashboard #595

Merged
merged 3 commits into from
Dec 20, 2024
Merged
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
2 changes: 1 addition & 1 deletion backend/src/database/libraries.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ type LibraryResponse struct {
IsFavorited bool `json:"is_favorited"`
}

func (db *DB) GetAllLibraries(page, perPage int, userId, facilityId uint, visibility, orderBy, search string) (int64, []LibraryResponse, error) {
func (db *DB) GetAllLibraries(page, perPage int, userId, facilityId uint, visibility, orderBy, search string, days int) (int64, []LibraryResponse, error) {
var total int64
libraries := make([]LibraryResponse, 0, perPage)

Expand Down
18 changes: 18 additions & 0 deletions backend/src/database/open_content.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package database

import (
"UnlockEdv2/src/models"
"time"

log "github.com/sirupsen/logrus"
)
Expand Down Expand Up @@ -182,3 +183,20 @@ func (db *DB) GetTopUserOpenContent(id int) ([]models.OpenContentItem, error) {
}
return content, nil
}

func (db *DB) GetTopFacilityLibraries(id int, perPage int, days int) ([]models.OpenContentItem, error) {
libraries := make([]models.OpenContentItem, 0, perPage)
daysAgo := time.Now().AddDate(0, 0, -days)
if err := db.Table("open_content_activities oca").
Select("l.title, l.url, l.thumbnail_url as thumbnail_url, l.open_content_provider_id, l.id as content_id, 'library' as type, count(l.id) as visits").
Joins("LEFT JOIN libraries l on l.id = oca.content_id AND l.open_content_provider_id = oca.open_content_provider_id").
Where("oca.facility_id = ? AND oca.request_ts >= ?", id, daysAgo).
Group("l.title, l.url, l.thumbnail_url, l.open_content_provider_id, l.id").
Order("visits DESC").
Limit(perPage).
Find(&libraries).
Error; err != nil {
return nil, newGetRecordsDBError(err, "libraries")
}
return libraries, nil
}
6 changes: 5 additions & 1 deletion backend/src/handlers/libraries_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ func (srv *Server) handleIndexLibraries(w http.ResponseWriter, r *http.Request,
page, perPage := srv.getPaginationInfo(r)
search := r.URL.Query().Get("search")
orderBy := r.URL.Query().Get("order_by")
days, err := strconv.Atoi(r.URL.Query().Get("days"))
if err != nil {
days = -1
}
showHidden := "visible"
if !userIsAdmin(r) && r.URL.Query().Get("visibility") == "hidden" {
return newUnauthorizedServiceError()
Expand All @@ -29,7 +33,7 @@ func (srv *Server) handleIndexLibraries(w http.ResponseWriter, r *http.Request,
showHidden = r.URL.Query().Get("visibility")
}
claims := r.Context().Value(ClaimsKey).(*Claims)
total, libraries, err := srv.Db.GetAllLibraries(page, perPage, claims.UserID, claims.FacilityID, showHidden, orderBy, search)
total, libraries, err := srv.Db.GetAllLibraries(page, perPage, claims.UserID, claims.FacilityID, showHidden, orderBy, search, days)
if err != nil {
return newDatabaseServiceError(err)
}
Expand Down
15 changes: 15 additions & 0 deletions backend/src/handlers/open_content_activity_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ func (srv *Server) registerOpenContentActivityRoutes() []routeDef {
return []routeDef{
{"GET /api/open-content/activity", srv.handleGetTopFacilityOpenContent, false, axx},
{"GET /api/open-content/activity/{id}", srv.handleGetTopUserOpenContent, false, axx},
{"GET /api/libraries/activity", srv.handleGetTopFacilityLibraries, false, axx},
}
}

Expand All @@ -34,3 +35,17 @@ func (srv *Server) handleGetTopUserOpenContent(w http.ResponseWriter, r *http.Re
}
return writeJsonResponse(w, http.StatusOK, topOpenContent)
}

func (srv *Server) handleGetTopFacilityLibraries(w http.ResponseWriter, r *http.Request, log sLog) error {
facilityId := srv.getFacilityID(r)
_, perPage := srv.getPaginationInfo(r)
days, err := strconv.Atoi(r.URL.Query().Get("days"))
if err != nil {
days = 7
}
topLibraries, err := srv.Db.GetTopFacilityLibraries(int(facilityId), perPage, days)
if err != nil {
return newDatabaseServiceError(err)
}
return writeJsonResponse(w, http.StatusOK, topLibraries)
}
70 changes: 32 additions & 38 deletions frontend/src/Components/LibraryCard.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { useState, MouseEvent } from 'react';
import VisibleHiddenToggle from './VisibleHiddenToggle';
import { Library, ServerResponseMany, ToastState, UserRole } from '@/common';
import { Library, ToastState, UserRole } from '@/common';
import API from '@/api/api';
import { KeyedMutator } from 'swr';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { useToast } from '@/Context/ToastCtx';
import { AdminRoles } from '@/useAuth';
import ULIComponent from '@/Components/ULIComponent';
Expand All @@ -15,17 +14,16 @@ import { FlagIcon as FlagIconOutline } from '@heroicons/react/24/outline';
export default function LibraryCard({
library,
mutate,
role,
onFavoriteToggle
role
}: {
library: Library;
mutate?: KeyedMutator<ServerResponseMany<Library>>;
mutate?: () => void;
role: UserRole;
onFavoriteToggle?: (libraryId: number, isFavorited: boolean) => void;
}) {
const { toaster } = useToast();
const [visible, setVisible] = useState<boolean>(library.visibility_status);
const navigate = useNavigate();
const route = useLocation();

function changeVisibility(visibilityStatus: boolean) {
if (visibilityStatus == !visible) {
Expand All @@ -41,7 +39,7 @@ export default function LibraryCard({
{}
);
if (resp.success) {
await mutate();
mutate();
}
toaster(
resp.message,
Expand All @@ -57,24 +55,19 @@ export default function LibraryCard({
{}
);
if (resp.success) {
const isFavorited = library.is_favorited;
onFavoriteToggle?.(library.id, isFavorited);
await mutate();
mutate();
}
toaster(
resp.message,
resp.success ? ToastState.success : ToastState.error
);
await mutate();
}

const handleCardClick = (e: MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
if (target.closest('.favorite-toggle')) return;
navigate(`/viewer/libraries/${library.id}`);
};
return (
<div className="card cursor-pointer" onClick={handleCardClick}>
<div
className="card cursor-pointer"
onClick={() => navigate(`/viewer/libraries/${library.id}`)}
>
<div className="flex p-4 gap-2 border-b-2">
<figure className="w-[48px] h-[48px] bg-cover">
<img
Expand All @@ -86,28 +79,29 @@ export default function LibraryCard({
</div>

<div
className="absolute right-2 top-2 z-100 favorite-toggle"
className="absolute right-2 top-2 z-100"
onClick={(e) => void toggleLibraryFavorite(e)}
>
{/* don't display the favorite toggle when admin is viewing in student view*/}
<ULIComponent
tooltipClassName="absolute right-2 top-2 z-100"
iconClassName={`w-6 h-6 ${library.is_favorited && 'text-primary-yellow'}`}
icon={
AdminRoles.includes(role)
? library.is_favorited
? FlagIcon
: FlagIconOutline
: library.is_favorited
? StarIcon
: StarIconOutline
}
dataTip={
AdminRoles.includes(role)
? 'Feature Library'
: 'Favorite Library'
}
/>
{!route.pathname.includes('knowledge-insights') && (
<ULIComponent
tooltipClassName="absolute right-2 top-2 z-100"
iconClassName={`w-6 h-6 ${library.is_favorited && 'text-primary-yellow'}`}
icon={
AdminRoles.includes(role)
? library.is_favorited
? FlagIcon
: FlagIconOutline
: library.is_favorited
? StarIcon
: StarIconOutline
}
dataTip={
AdminRoles.includes(role)
? 'Feature Library'
: 'Favorite Library'
}
/>
)}
</div>

<div className="p-4 space-y-2">
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/Components/LibraryLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ export default function LibaryLayout({
setPageQuery(1);
}, [filterLibrariesAdmin, filterLibraries, searchTerm]);

function updateLibrary() {
void mutateLibraries();
}

return (
<>
<div className="flex flex-row gap-4">
Expand Down Expand Up @@ -111,7 +115,7 @@ export default function LibaryLayout({
<LibraryCard
key={library.id}
library={library}
mutate={mutateLibraries}
mutate={updateLibrary}
role={adminWithStudentView() ? UserRole.Student : role}
/>
))}
Expand Down
21 changes: 10 additions & 11 deletions frontend/src/Components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import {
handleLogout,
hasFeature,
isAdministrator,
studentAccessLinks,
useAuth
} from '@/useAuth';
import Modal from '@/Components/Modal';
Expand Down Expand Up @@ -51,11 +50,16 @@ export default function Navbar({
const { toaster } = useToast();
const confirmSeedModal = useRef<HTMLDialogElement | null>(null);
const [seedInProgress, setSeedInProgress] = useState<boolean>(false);
const dashboardTitle = new Map([
const dashboardTitleAdmin = new Map([
['/learning-insights', 'Learning'],
['/knowledge-insights', 'Knowledge'],
['/operational-insights', 'Operational']
]);
const dashboardTitleStudent = new Map([
['/trending-content', 'Trending Content'],
['/learning-path', 'Learning Path'],
['/program-tracker', 'Program Tracker']
]);
const handleSeedDemoData = async () => {
setSeedInProgress(true);
const resp = await API.post<null, object>(`auth/demo-seed`, {});
Expand Down Expand Up @@ -112,7 +116,7 @@ export default function Navbar({
<li className="mt-16">
<Link to={getDashboardLink(user)}>
<ULIComponent icon={HomeIcon} />
{dashboardTitle.get(
{dashboardTitleAdmin.get(
getDashboardLink(user)
) ?? 'Operational'}{' '}
Insights
Expand Down Expand Up @@ -218,14 +222,9 @@ export default function Navbar({
<li className="mt-16">
<Link to={getDashboardLink(user)}>
<ULIComponent icon={HomeIcon} />
{getDashboardLink(user) ===
studentAccessLinks[0] && 'Home'}
{getDashboardLink(user) ===
studentAccessLinks[1] &&
'Trending Content'}
{getDashboardLink(user) ===
studentAccessLinks[2] &&
'Learning Path'}
{dashboardTitleStudent.get(
getDashboardLink(user)
) ?? 'Home'}
</Link>
</li>
{hasFeature(
Expand Down
1 change: 0 additions & 1 deletion frontend/src/Components/cards/OpenContentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ export default function OpenContentCardRow({
content.content_type === 'video'
? `/viewer/videos/${content.content_id}`
: `/viewer/libraries/${content.content_id}`;

navigate(basePath);
}

Expand Down
42 changes: 42 additions & 0 deletions frontend/src/Components/dashboard/FeaturedContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Library, UserRole } from '@/common';
import LibraryCard from '../LibraryCard';
import { useAuth } from '@/useAuth';
import { useState } from 'react';

export default function FeaturedContent({
featured,
mutate
}: {
featured: Library[];
mutate?: () => void;
}) {
const { user } = useAuth();
const [expanded, setExpanded] = useState<boolean>(false);
const cols = user?.role == UserRole.Student ? 3 : 4;
const slice = expanded ? featured.length : cols;
return (
<>
<h2>Featured Content</h2>
<div className="card card-row-padding flex flex-col gap-3">
<div className={`grid grid-cols-${cols} gap-3`}>
{featured.slice(0, slice).map((item: Library) => {
return (
<LibraryCard
key={item.id}
library={item}
role={UserRole.Student}
mutate={mutate}
/>
);
})}
</div>
<button
className="flex justify-end text-teal-3 hover:text-teal-4 body"
onClick={() => setExpanded(!expanded)}
>
{expanded ? 'See less' : 'See more featured content'}
</button>
</div>
</>
);
}
42 changes: 42 additions & 0 deletions frontend/src/Components/dashboard/TopContentList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { OpenContentItem } from '@/common';
import OpenContentCard from '../cards/OpenContentCard';
import ULIComponent from '../ULIComponent';
import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline';

export default function TopContentList({
heading,
items,
navigateToOpenContent
}: {
heading: string;
items: OpenContentItem[];
navigateToOpenContent: () => void;
}) {
return (
<div className="card card-row-padding flex flex-col gap-3">
<h2>{heading}</h2>
{items.map((item: OpenContentItem) => {
return (
<OpenContentCard
key={item.content_id + item.url}
content={item}
/>
);
})}
{items.length < 5 && (
<div
className="card cursor-pointer px-4 py-2 flex flex-row gap-2 items-center"
onClick={navigateToOpenContent}
>
<ULIComponent
tooltipClassName="h-12 flex items-center"
icon={ArrowTopRightOnSquareIcon}
/>
<h3 className="body font-normal">
Explore other content offered
</h3>
</div>
)}
</div>
);
}
1 change: 1 addition & 0 deletions frontend/src/Components/dashboard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export { default as ExploreCourseCatalogCard } from './ExploreCourseCatalogCard'
export { default as ResidentWeeklyActivityTable } from './ResidentWeeklyActivityTable';
export { default as CurrentlyEnrolledCourses } from './CurrentlyEnrolledCourses';
export { default as ResidentRecentCourses } from './ResidentRecentCourses';
export { default as FeaturedContent } from './FeaturedContent';
Loading
Loading