Path: blob/master/src/packages/frontend/course/util.ts
1503 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/4import { SizeType } from "antd/lib/config-provider/SizeContext";5import { Map } from "immutable";6import { IntlShape } from "react-intl";78import {9TypedMap,10useEffect,11useState,12useWindowDimensions,13} from "@cocalc/frontend/app-framework";14import { IconName } from "@cocalc/frontend/components/icon";15import { labels } from "@cocalc/frontend/i18n";16import { KUCALC_COCALC_COM } from "@cocalc/util/db-schema/site-defaults";17import {18cmp,19defaults,20merge,21required,22search_match,23search_split,24separate_file_extension,25} from "@cocalc/util/misc";26import { ProjectsStore } from "../projects/store";27import { UserMap } from "../todo-types";28import { StudentsMap } from "./store";29import { AssignmentCopyStep } from "./types";3031// Pure functions used in the course manager32export function STEPS(peer: boolean): AssignmentCopyStep[] {33if (peer) {34return [35"assignment",36"collect",37"peer_assignment",38"peer_collect",39"return_graded",40];41} else {42return ["assignment", "collect", "return_graded"];43}44}4546export function previous_step(47step: AssignmentCopyStep,48peer: boolean,49): AssignmentCopyStep {50let prev: AssignmentCopyStep | undefined;51for (const s of STEPS(peer)) {52if (step === s) {53if (prev === undefined) break;54return prev;55}56prev = s;57}58throw Error(`BUG! previous_step('${step}, ${peer}')`);59}6061export function step_direction(step: AssignmentCopyStep): "to" | "from" {62switch (step) {63case "assignment":64return "to";65case "collect":66return "from";67case "return_graded":68return "to";69case "peer_assignment":70return "to";71case "peer_collect":72return "from";73default:74throw Error(`BUG! step_direction('${step}')`);75}76}7778export function step_verb(step: AssignmentCopyStep) {79switch (step) {80case "assignment":81return "assign";82case "collect":83return "collect";84case "return_graded":85return "return";86case "peer_assignment":87return "assign";88case "peer_collect":89return "collect";90default:91throw Error(`BUG! step_verb('${step}')`);92}93}9495export function step_ready(step: AssignmentCopyStep, n) {96switch (step) {97case "assignment":98return "";99case "collect":100if (n > 1) {101return " who have already received it";102} else {103return " who has already received it";104}105case "return_graded":106return " whose work you have graded";107case "peer_assignment":108return " for peer grading";109case "peer_collect":110return " who should have peer graded it";111}112}113114// Takes a student immutable.Map with key 'student_id'115// Returns a list of students `x` shaped like:116// {117// first_name : string118// last_name : string119// last_active : integer120// hosting : string121// email_address : string122// }123export function parse_students(124student_map: StudentsMap,125user_map: UserMap,126redux,127intl?: IntlShape,128) {129const v = immutable_to_list(student_map, "student_id");130for (const x of v) {131if (x.account_id != null) {132const user = user_map.get(x.account_id);133if (x.first_name == null) {134x.first_name = user == null ? "" : user.get("first_name", "");135}136if (x.last_name == null) {137x.last_name = user == null ? "" : user.get("last_name", "");138}139if (x.project_id != null) {140const projects_store = redux.getStore("projects");141if (projects_store != null) {142const last_active = projects_store.get_last_active(x.project_id);143if (last_active != null) {144x.last_active = last_active.get(x.account_id);145}146}147}148}149if (intl != null) {150const { description, state } = projectStatus(x.project_id, redux, intl);151x.hosting = description + state;152}153154if (x.first_name == null) {155x.first_name = "";156}157if (x.last_name == null) {158x.last_name = "";159}160if (x.last_active == null) {161x.last_active = 0;162}163if (x.email_address == null) {164x.email_address = "";165}166}167return v;168}169170// Transforms Iterable<K, M<i, m>> to [M<i + primary_key, m + K>] where primary_key maps to K171// Dunno if either of these is readable...172// Turns Map(Keys -> Objects{...}) into [Objects{primary_key : Key, ...}]173// TODO: Type return array better174export function immutable_to_list(x: undefined): undefined;175export function immutable_to_list<T, P>(176x: Map<string, T>,177primary_key: P,178): T extends TypedMap<infer S>179? S[]180: T extends Map<string, infer S>181? S[]182: any;183export function immutable_to_list(x: any, primary_key?): any {184if (x == null || x == undefined) {185return;186}187const v: any[] = [];188x.map((val, key) => v.push(merge(val.toJS(), { [primary_key]: key })));189return v;190}191192// Returns a list of matched objects and the number of objects193// which were in the original list but omitted in the returned list194export function compute_match_list(opts: {195list: any[];196search_key: string;197search: string;198}) {199opts = defaults(opts, {200list: required, // list of objects<M>201search_key: required, // M.search_key property to match over202search: required, // matches to M.search_key203});204let { list, search, search_key } = opts;205if (!search) {206// why are you even calling this..207return { list, num_omitted: 0 };208}209210const words = search_split(search);211const matches = (x) =>212search_match(x[search_key]?.toLowerCase?.() ?? "", words);213const n = list.length;214list = list.filter(matches);215const num_omitted = n - list.length;216return { list, num_omitted };217}218219// Returns220// `list` partitioned into [not deleted, deleted]221// where each partition is sorted based on the given `compare_function`222// deleted is not included by default223export function order_list<T extends { deleted: boolean }>(opts: {224list: T[];225compare_function: (a: T, b: T) => number;226reverse: boolean;227include_deleted: boolean;228}) {229opts = defaults(opts, {230list: required,231compare_function: required,232reverse: false,233include_deleted: false,234});235let { list, compare_function, include_deleted } = opts;236237const x = list.filter((x) => x.deleted);238const sorted_deleted = x.sort(compare_function);239240const y = list.filter((x) => !x.deleted);241list = y.sort(compare_function);242243if (opts.reverse) {244list.reverse();245}246247if (include_deleted) {248list = list.concat(sorted_deleted);249}250251return { list, deleted: x, num_deleted: sorted_deleted.length };252}253254const cmp_strings = (a, b, field) => {255return cmp(a[field]?.toLowerCase() ?? "", b[field]?.toLowerCase() ?? "");256};257258// first sort by domain, then address at that domain... since there will be many students259// at same domain, and '[email protected]' > '[email protected]' > '[email protected]' is true but not helpful260const cmp_email = (a, b) => {261const v = a.split("@");262const w = b.split("@");263const c = cmp(v[1], w[1]);264if (c) {265return c;266}267return cmp(v[0], w[0]);268};269270const sort_on_string_field = (field, field2) => (a, b) => {271const c =272field == "email_address"273? cmp_email(a[field], b[field])274: cmp_strings(a, b, field);275return c != 0 ? c : cmp_strings(a, b, field2);276};277278const sort_on_numerical_field = (field, field2) => (a, b) => {279const c = cmp((a[field] ?? 0) * -1, (b[field] ?? 0) * -1);280return c != 0 ? c : cmp_strings(a, b, field2);281};282283type StudentField =284| "email"285| "first_name"286| "last_name"287| "last_active"288| "hosting";289290export function pick_student_sorter({291column_name,292is_descending,293}: {294column_name: StudentField;295is_descending?: boolean;296}) {297const cmp = getSorter(column_name);298if (is_descending) {299return (a, b) => cmp(b, a);300}301return cmp;302}303304function getSorter(column_name) {305switch (column_name) {306case "email":307return sort_on_string_field("email_address", "last_name");308case "first_name":309return sort_on_string_field("first_name", "last_name");310case "last_active":311return sort_on_numerical_field("last_active", "last_name");312case "hosting":313return sort_on_string_field("hosting", "email_address");314case "last_name":315default:316return sort_on_string_field("last_name", "first_name");317}318}319320export function assignment_identifier(321assignment_id: string,322student_id: string,323): string {324return assignment_id + student_id;325}326327export function autograded_filename(filename: string): string {328const { name, ext } = separate_file_extension(filename);329return name + "_autograded." + ext;330}331332interface ProjectStatus {333description: string;334icon: IconName;335state: string;336tip?: string;337}338339export function projectStatus(340project_id: string | undefined,341redux,342intl: IntlShape,343): ProjectStatus {344if (!project_id) {345return { description: "(not created)", icon: "hourglass-half", state: "" };346}347const store = redux.getStore("projects");348const state = ` (${store.get_state(project_id)})`;349const kucalc = redux.getStore("customize").get("kucalc");350if (kucalc === KUCALC_COCALC_COM) {351return projectStatusCoCalcCom({ project_id, state, store, intl });352} else {353const tip = intl.formatMessage({354id: "course.util.project_status.ready",355defaultMessage: "Project exists and is ready.",356});357return {358icon: "exclamation-triangle",359description: intl.formatMessage(labels.ready),360tip,361state,362};363}364}365366function projectStatusCoCalcCom({367project_id,368state,369store,370intl,371}: {372project_id: string;373state: string;374store: ProjectsStore;375intl: IntlShape;376}): ProjectStatus {377const upgrades = store.get_total_project_quotas(project_id);378if (upgrades == null) {379// user opening the course, but isn't a collaborator on380// this student project for some reason. This will get fixed381// when configure all projects runs.382const description = intl.formatMessage({383id: "course.util.status-cocalc-com.project_not_available",384defaultMessage: "(not available)",385});386return {387description,388icon: "question-circle",389state: "",390};391}392393if (upgrades.member_host) {394return {395icon: "check",396description: "Members-only hosting",397tip: "Projects is on a members-only server, which is much more robust and has priority support.",398state,399};400}401const licenses = store.get_site_license_ids(project_id);402if (licenses.length > 0) {403const description = intl.formatMessage({404id: "course.util.status-cocalc-com.licensed.description",405defaultMessage: "Licensed",406});407const tip = intl.formatMessage({408id: "course.util.status-cocalc-com.licensed.tooltip",409defaultMessage:410"Project is properly licensed and should work well. Thank you!",411});412return { description, icon: "check", state, tip };413} else {414const description = intl.formatMessage({415id: "course.util.status-cocalc-com.free.description",416defaultMessage: "Free Trial",417});418const tip = intl.formatMessage({419id: "course.util.status-cocalc-com.free.tooltip",420defaultMessage: `Project is a trial project hosted on a free server,421so it may be overloaded and will be rebooted frequently.422Please upgrade in course configuration.`,423});424return {425description,426icon: "exclamation-triangle",427state,428tip,429};430}431}432433// the list of assignments, in particular with peer grading, has a large number of buttons434// in a single row. We mitigate this by rendering the buttons smaller if the screen is narrower.435export function useButtonSize(): SizeType {436const [size, setSize] = useState<SizeType>("small");437const { width } = useWindowDimensions();438useEffect(() => {439const next = width < 1024 ? "small" : "middle";440if (next != size) {441setSize(next);442}443});444return size;445}446447448