Path: blob/master/src/packages/frontend/course/util.ts
5716 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import type { IconName } from "@cocalc/frontend/components/icon";6import type { SizeType } from "antd/lib/config-provider/SizeContext";78import { Map } from "immutable";9import { IntlShape } from "react-intl";1011import {12TypedMap,13useEffect,14useState,15useWindowDimensions,16} from "@cocalc/frontend/app-framework";17import { labels } from "@cocalc/frontend/i18n";18import { KUCALC_COCALC_COM } from "@cocalc/util/db-schema/site-defaults";19import {20cmp,21defaults,22merge,23required,24search_match,25search_split,26separate_file_extension,27} from "@cocalc/util/misc";28import { ProjectsStore } from "../projects/store";29import { UserMap } from "../todo-types";30import { StudentsMap } from "./store";31import { AssignmentCopyStep } from "./types";3233// Pure functions used in the course manager34export function STEPS(peer: boolean): AssignmentCopyStep[] {35if (peer) {36return [37"assignment",38"collect",39"peer_assignment",40"peer_collect",41"return_graded",42];43} else {44return ["assignment", "collect", "return_graded"];45}46}4748export function previous_step(49step: AssignmentCopyStep,50peer: boolean,51): AssignmentCopyStep {52let prev: AssignmentCopyStep | undefined;53for (const s of STEPS(peer)) {54if (step === s) {55if (prev === undefined) break;56return prev;57}58prev = s;59}60throw Error(`BUG! previous_step('${step}, ${peer}')`);61}6263export function step_direction(step: AssignmentCopyStep): "to" | "from" {64switch (step) {65case "assignment":66return "to";67case "collect":68return "from";69case "return_graded":70return "to";71case "peer_assignment":72return "to";73case "peer_collect":74return "from";75default:76throw Error(`BUG! step_direction('${step}')`);77}78}7980export function step_verb(step: AssignmentCopyStep) {81switch (step) {82case "assignment":83return "assign";84case "collect":85return "collect";86case "return_graded":87return "return";88case "peer_assignment":89return "assign";90case "peer_collect":91return "collect";92default:93throw Error(`BUG! step_verb('${step}')`);94}95}9697export function step_ready(step: AssignmentCopyStep, n) {98switch (step) {99case "assignment":100return "";101case "collect":102if (n > 1) {103return " who have already received it";104} else {105return " who has already received it";106}107case "return_graded":108return " whose work you have graded";109case "peer_assignment":110return " for peer grading";111case "peer_collect":112return " who should have peer graded it";113}114}115116// Takes a student immutable.Map with key 'student_id'117// Returns a list of students `x` shaped like:118// {119// first_name : string120// last_name : string121// last_active : integer122// hosting : string123// email_address : string124// }125export function parse_students(126student_map: StudentsMap,127user_map: UserMap,128redux,129intl?: IntlShape,130) {131const v = immutable_to_list(student_map, "student_id");132for (const x of v) {133if (x.account_id != null) {134const user = user_map.get(x.account_id);135if (x.first_name == null) {136x.first_name = user == null ? "" : user.get("first_name", "");137}138if (x.last_name == null) {139x.last_name = user == null ? "" : user.get("last_name", "");140}141if (x.project_id != null) {142const projects_store = redux.getStore("projects");143if (projects_store != null) {144const last_active = projects_store.get_last_active(x.project_id);145if (last_active != null) {146x.last_active = last_active.get(x.account_id);147}148}149}150}151if (intl != null) {152const { description, state } = projectStatus(x.project_id, redux, intl);153x.hosting = description + state;154}155156if (x.first_name == null) {157x.first_name = "";158}159if (x.last_name == null) {160x.last_name = "";161}162if (x.last_active == null) {163x.last_active = 0;164}165if (x.email_address == null) {166x.email_address = "";167}168}169return v;170}171172// Transforms Iterable<K, M<i, m>> to [M<i + primary_key, m + K>] where primary_key maps to K173// Dunno if either of these is readable...174// Turns Map(Keys -> Objects{...}) into [Objects{primary_key : Key, ...}]175// TODO: Type return array better176export function immutable_to_list(x: undefined): undefined;177export function immutable_to_list<T, P>(178x: Map<string, T>,179primary_key: P,180): T extends TypedMap<infer S>181? S[]182: T extends Map<string, infer S>183? S[]184: any;185export function immutable_to_list(x: any, primary_key?): any {186if (x == null || x == undefined) {187return;188}189const v: any[] = [];190x.map((val, key) => v.push(merge(val.toJS(), { [primary_key]: key })));191return v;192}193194// Returns a list of matched objects and the number of objects195// which were in the original list but omitted in the returned list196export function compute_match_list(opts: {197list: any[];198search_key: string;199search: string;200}) {201opts = defaults(opts, {202list: required, // list of objects<M>203search_key: required, // M.search_key property to match over204search: required, // matches to M.search_key205});206let { list, search, search_key } = opts;207if (!search) {208// why are you even calling this..209return { list, num_omitted: 0 };210}211212const words = search_split(search);213const matches = (x) =>214search_match(x[search_key]?.toLowerCase?.() ?? "", words);215const n = list.length;216list = list.filter(matches);217const num_omitted = n - list.length;218return { list, num_omitted };219}220221// Returns222// `list` partitioned into [not deleted, deleted]223// where each partition is sorted based on the given `compare_function`224// deleted is not included by default225export function order_list<T extends { deleted: boolean }>(opts: {226list: T[];227compare_function: (a: T, b: T) => number;228reverse: boolean;229include_deleted: boolean;230}) {231opts = defaults(opts, {232list: required,233compare_function: required,234reverse: false,235include_deleted: false,236});237let { list, compare_function, include_deleted } = opts;238239const x = list.filter((x) => x.deleted);240const sorted_deleted = x.sort(compare_function);241242const y = list.filter((x) => !x.deleted);243list = y.sort(compare_function);244245if (opts.reverse) {246list.reverse();247}248249if (include_deleted) {250list = list.concat(sorted_deleted);251}252253return { list, deleted: x, num_deleted: sorted_deleted.length };254}255256const cmp_strings = (a, b, field) => {257return cmp(a[field]?.toLowerCase() ?? "", b[field]?.toLowerCase() ?? "");258};259260// first sort by domain, then address at that domain... since there will be many students261// at same domain, and '[email protected]' > '[email protected]' > '[email protected]' is true but not helpful262const cmp_email = (a, b) => {263const v = a.split("@");264const w = b.split("@");265const c = cmp(v[1], w[1]);266if (c) {267return c;268}269return cmp(v[0], w[0]);270};271272const sort_on_string_field = (field, field2) => (a, b) => {273const c =274field == "email_address"275? cmp_email(a[field], b[field])276: cmp_strings(a, b, field);277return c != 0 ? c : cmp_strings(a, b, field2);278};279280const sort_on_numerical_field = (field, field2) => (a, b) => {281const c = cmp((a[field] ?? 0) * -1, (b[field] ?? 0) * -1);282return c != 0 ? c : cmp_strings(a, b, field2);283};284285type StudentField =286| "email"287| "first_name"288| "last_name"289| "last_active"290| "hosting";291292export function pick_student_sorter({293column_name,294is_descending,295}: {296column_name: StudentField;297is_descending?: boolean;298}) {299const cmp = getSorter(column_name);300if (is_descending) {301return (a, b) => cmp(b, a);302}303return cmp;304}305306function getSorter(column_name) {307switch (column_name) {308case "email":309return sort_on_string_field("email_address", "last_name");310case "first_name":311return sort_on_string_field("first_name", "last_name");312case "last_active":313return sort_on_numerical_field("last_active", "last_name");314case "hosting":315return sort_on_string_field("hosting", "email_address");316case "last_name":317default:318return sort_on_string_field("last_name", "first_name");319}320}321322export function assignment_identifier(323assignment_id: string,324student_id: string,325): string {326return assignment_id + student_id;327}328329export function autograded_filename(filename: string): string {330const { name, ext } = separate_file_extension(filename);331return name + "_autograded." + ext;332}333334interface ProjectStatus {335description: string;336icon: IconName;337state: string;338tip?: string;339}340341export function projectStatus(342project_id: string | undefined,343redux,344intl: IntlShape,345): ProjectStatus {346if (!project_id) {347return { description: "(not created)", icon: "hourglass-half", state: "" };348}349const store = redux.getStore("projects");350const state = ` (${store.get_state(project_id)})`;351const kucalc = redux.getStore("customize").get("kucalc");352if (kucalc === KUCALC_COCALC_COM) {353return projectStatusCoCalcCom({ project_id, state, store, intl });354} else {355const tip = intl.formatMessage({356id: "course.util.project_status.ready",357defaultMessage: "Project exists and is ready.",358});359return {360icon: "exclamation-triangle",361description: intl.formatMessage(labels.ready),362tip,363state,364};365}366}367368function projectStatusCoCalcCom({369project_id,370state,371store,372intl,373}: {374project_id: string;375state: string;376store: ProjectsStore;377intl: IntlShape;378}): ProjectStatus {379const upgrades = store.get_total_project_quotas(project_id);380if (upgrades == null) {381// user opening the course, but isn't a collaborator on382// this student project for some reason. This will get fixed383// when configure all projects runs.384const description = intl.formatMessage({385id: "course.util.status-cocalc-com.project_not_available",386defaultMessage: "(not available)",387});388return {389description,390icon: "question-circle",391state: "",392};393}394395if (upgrades.member_host) {396return {397icon: "check",398description: "Members-only hosting",399tip: "Projects is on a members-only server, which is much more robust and has priority support.",400state,401};402}403const licenses = store.get_site_license_ids(project_id);404if (licenses.length > 0) {405const description = intl.formatMessage({406id: "course.util.status-cocalc-com.licensed.description",407defaultMessage: "Licensed",408});409const tip = intl.formatMessage({410id: "course.util.status-cocalc-com.licensed.tooltip",411defaultMessage:412"Project is properly licensed and should work well. Thank you!",413});414return { description, icon: "check", state, tip };415} else {416const description = intl.formatMessage({417id: "course.util.status-cocalc-com.free.description",418defaultMessage: "Free Trial",419});420const tip = intl.formatMessage({421id: "course.util.status-cocalc-com.free.tooltip",422defaultMessage: `Project is a trial project hosted on a free server,423so it may be overloaded and will be rebooted frequently.424Please upgrade in course configuration.`,425});426return {427description,428icon: "exclamation-triangle",429state,430tip,431};432}433}434435// the list of assignments, in particular with peer grading, has a large number of buttons436// in a single row. We mitigate this by rendering the buttons smaller if the screen is narrower.437export function useButtonSize(): SizeType {438const [size, setSize] = useState<SizeType>("small");439const { width } = useWindowDimensions();440useEffect(() => {441const next = width < 1024 ? "small" : "middle";442if (next != size) {443setSize(next);444}445});446return size;447}448449450