Skip to content
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
8 changes: 7 additions & 1 deletion src/articles/ArticlePreview.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import "react-toastify/dist/ReactToastify.css";
import { isMobile } from "../utils/misc/browserDetection";
import * as s from "./ArticlePreview.sc";
import { MetaStrip, MetaItem, MetaLink, MetaTag } from "../components/MetaStrip.sc";
import { classColor } from "../utils/misc/classColor";
import RedirectionNotificationModal from "../components/redirect_notification/RedirectionNotificationModal";
import Feature from "../features/Feature";
import strings from "../i18n/definitions";
Expand Down Expand Up @@ -295,9 +296,14 @@ export default function ArticlePreview({
// Which class a text came from. Only for a student in more than one class:
// with a single class it is the same answer on every row, and the tab itself
// already says it. Computed here because both layouts below render a strip.
// The class's own colour, matching its chip in the filter bar above -- the
// pair is what lets you skim the list for one class instead of reading every
// label. Colour supplements the name, never replaces it.
const classTags = showClassNames
? (article.from_classes || []).map((each) => (
<MetaTag key={`class-${each.id}`}>{each.name}</MetaTag>
<MetaItem key={`class-${each.id}`} style={{ color: classColor(each.id) }}>
{each.name}
</MetaItem>
))
: null;

Expand Down
34 changes: 33 additions & 1 deletion src/articles/ClassroomArticles.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,15 @@ import { APIContext } from "../contexts/APIContext";
import ClassroomOtherLanguages, {
otherLanguageOptions,
} from "./ClassroomOtherLanguages";
import { ALL, buildClassFilters, filterTexts } from "../utils/misc/classFilters";
import { classColorTint } from "../utils/misc/classColor";
import * as f from "../teacher/styledComponents/TextFilterBar.sc";

export default function ClassroomArticles() {
const api = useContext(APIContext);
const [articleList, setArticleList] = useState(null);
const [student, setStudent] = useState(null);
const [activeClass, setActiveClass] = useState(ALL);

let originalList = articleList;

Expand Down Expand Up @@ -93,12 +97,40 @@ export default function ClassroomArticles() {

const inMoreThanOneClass = student.cohorts.length > 1;

// With several classes the list is a merge, and a term's reading from each
// adds up: filter by class, the same chips the teacher gets on My Texts.
const classFilters = inMoreThanOneClass
? buildClassFilters(articleList, { allLabel: "All classes" })
: [];
const visible = inMoreThanOneClass ? filterTexts(articleList, activeClass) : articleList;

return (
<>
<br />
<br />
{inMoreThanOneClass && (
<f.FilterBar>
{classFilters.map((filter) => {
const tint = filter.id === ALL ? {} : classColorTint(filter.id);
return (
<f.Chip
key={filter.id}
type="button"
$on={activeClass === filter.id}
$hue={tint.hue}
$wash={tint.wash}
aria-pressed={activeClass === filter.id}
onClick={() => setActiveClass(filter.id)}
>
{filter.name}
<f.ChipCount>{filter.count}</f.ChipCount>
</f.Chip>
);
})}
</f.FilterBar>
)}
<SortingButtons articleList={articleList} originalList={originalList} setArticleList={setArticleList} />
{articleList.map((each) => (
{visible.map((each) => (
<ArticlePreview
key={each.id}
article={each}
Expand Down
14 changes: 8 additions & 6 deletions src/teacher/myTextsPage/AllTexts.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
} from "../styledComponents/TeacherPageHeading.sc";
import SortingButtons from "../../articles/SortingButtons";
import { APIContext } from "../../contexts/APIContext";
import { ALL, buildClassFilters, filterTexts } from "./textFilters";
import { ALL, buildClassFilters, filterTexts } from "../../utils/misc/classFilters";

export default function AllTexts() {
const api = useContext(APIContext);
Expand Down Expand Up @@ -91,11 +91,13 @@ export default function AllTexts() {
<h.ChipCount>{filter.count}</h.ChipCount>
</h.Chip>
))}
<SortingButtons
articleList={articleList}
originalList={originalList}
setArticleList={setArticleList}
/>
<div className="trailing">
<SortingButtons
articleList={articleList}
originalList={originalList}
setArticleList={setArticleList}
/>
</div>
</h.FilterBar>
)}

Expand Down
16 changes: 14 additions & 2 deletions src/teacher/styledComponents/TextFilterBar.sc.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ export const FilterBar = styled(FilterRow).attrs({ $wrap: true })`
row-gap: 0.4rem;
margin-bottom: 1.75rem;

/* The sort control sits at the far end; no spacer element needed. */
> *:last-child {
/* A trailing control (My Texts' sort) sits at the far end. Marked explicitly
rather than by :last-child, which flung the last chip across the row on
any bar that has only chips. */
> .trailing {
margin-left: auto;
}
`;
Expand All @@ -23,6 +25,16 @@ export const Chip = styled(Tag).attrs({ className: "tiny" })`
white-space: nowrap;
border-style: ${({ $dashed }) => ($dashed ? "dashed" : "solid")};

/* $hue/$wash give a class its own colour, so a chip and the tags on the rows
below it read as the same thing. Selected fills with the hue; unselected
is a wash of it, which is alpha over the ground and so works in both
themes. Chips without a hue (All, Not shared) keep the neutral look. */
${({ $hue, $wash, $on }) =>
$hue &&
($on
? `border-color: ${$hue}; background-color: ${$hue}; color: white;`
: `border-color: ${$hue}; background-color: ${$wash}; color: ${$hue};`)}

&,
&.small {
${({ $on }) =>
Expand Down
45 changes: 45 additions & 0 deletions src/utils/misc/classColor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {
tableau_1, tableau_3, tableau_5, tableau_6,
} from "../../components/colors";

// From the repo's categorical palette (matplotlib's tab10), the hues that stay
// legible as small text on both the cream and the dark ground. Dropped: grey
// (a class is never "the other one"), red (reads as an error), olive, pink and
// cyan (too pale at this size), orange (the app's own accent).
//
// Six classes is more than anyone here has; beyond that they repeat, which is
// honest -- the colour is a hint on top of the name, never the only signal.
const PALETTE = [
tableau_1, // blue
tableau_3, // green
tableau_5, // purple
tableau_6, // brown
"#0f766e", // teal
"#9a3412", // burnt orange
];

/**
* A stable colour for a class.
*
* Keyed off the class's id rather than its position in a list, so a class keeps
* its colour when the list is filtered, sorted, or a class is added — a colour
* that reshuffles is worse than no colour, because you stop trusting it.
*/
export function classColor(cohortId) {
const key = String(cohortId);
let hash = 0;
for (let i = 0; i < key.length; i++) {
hash = (hash * 31 + key.charCodeAt(i)) % 1000003;
}
return PALETTE[hash % PALETTE.length];
}

/**
* The pair a tinted chip needs: the hue for text and border, and a wash of it
* for the fill. The wash is alpha over whatever is behind it, so one value
* works on both the light and the dark ground.
*/
export function classColorTint(cohortId) {
const hue = classColor(cohortId);
return { hue, wash: `color-mix(in srgb, ${hue} 14%, transparent)` };
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
export const ALL = "all";
export const NOT_SHARED = "not-shared";

/** Classes a text is shared with, as {id, name}. */
export function sharedClassesOf(article) {
return article.shared_with || [];
/**
* Which classes a text belongs to, as {id, name}. The teacher's list carries
* `shared_with` (the classes they gave it to); a student's classroom carries
* `from_classes` (the classes it reached them through). Same shape, two names,
* because they answer different questions about the same relationship.
*/
export function classesOf(article) {
return article.shared_with || article.from_classes || [];
}

/**
Expand All @@ -12,12 +17,12 @@ export function sharedClassesOf(article) {
* texts themselves rather than from the class list, so a chip never promises
* texts that are not in the list below it.
*/
export function buildClassFilters(articles) {
export function buildClassFilters(articles, { allLabel = "All" } = {}) {
const counts = new Map();
let notShared = 0;

for (const article of articles) {
const classes = sharedClassesOf(article);
const classes = classesOf(article);
if (classes.length === 0) notShared += 1;
for (const each of classes) {
const existing = counts.get(each.id);
Expand All @@ -28,14 +33,14 @@ export function buildClassFilters(articles) {
const classFilters = [...counts.values()].sort((a, b) => a.name.localeCompare(b.name));

return [
{ id: ALL, name: "All", count: articles.length },
{ id: ALL, name: allLabel, count: articles.length },
...classFilters,
...(notShared > 0 ? [{ id: NOT_SHARED, name: "Not shared", count: notShared, dashed: true }] : []),
];
}

export function filterTexts(articles, filterId) {
if (filterId === ALL) return articles;
if (filterId === NOT_SHARED) return articles.filter((a) => sharedClassesOf(a).length === 0);
return articles.filter((a) => sharedClassesOf(a).some((each) => each.id === filterId));
if (filterId === NOT_SHARED) return articles.filter((a) => classesOf(a).length === 0);
return articles.filter((a) => classesOf(a).some((each) => each.id === filterId));
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import {
NOT_SHARED,
buildClassFilters,
filterTexts,
sharedClassesOf,
} from "../../src/teacher/myTextsPage/textFilters";
classesOf,
} from "../../src/utils/misc/classFilters";

const text = (id, ...classes) => ({ id, shared_with: classes });
const CUT = { id: 1, name: "CUT Language Centre" };
Expand Down Expand Up @@ -57,15 +57,15 @@ describe("filterTexts", () => {
});
});

describe("sharedClassesOf", () => {
describe("classesOf", () => {
test("is empty for a text shared with nobody", () => {
expect(sharedClassesOf({})).toEqual([]);
expect(classesOf({})).toEqual([]);
});

// The names-only `cohorts` field cannot drive the x on a pill: it carries no
// id, so an unshare would post a class name as cohort_id and 401.
test("ignores the older names-only field", () => {
expect(sharedClassesOf({ cohorts: ["nana"] })).toEqual([]);
expect(classesOf({ cohorts: ["nana"] })).toEqual([]);
});
});

Expand All @@ -84,3 +84,20 @@ describe("cohort ids arriving from two endpoints", () => {
expect(filters.find((each) => each.name === "nana").count).toBe(2);
});
});

describe("a student's classroom uses the same filters", () => {
// The teacher's list says which classes a text was shared *with*; the
// student's says which classes it reached them *through*. Same relationship.
const fromClass = (id, name) => ({ id: 1, from_classes: [{ id, name }] });

test("chips are built from from_classes too", () => {
const filters = buildClassFilters([fromClass(82, "Danish Class"), fromClass(109, "Aiki")]);

expect(filters.map((f) => f.name)).toEqual(["All", "Aiki", "Danish Class"]);
});

test("no Not-shared chip when every text has a class", () => {
const filters = buildClassFilters([fromClass(82, "Danish Class")]);
expect(filters.some((f) => f.id === NOT_SHARED)).toBe(false);
});
});