Skip to content

Commit

Permalink
💻 adventure tabs and editor in public adventures page (#4990)
Browse files Browse the repository at this point in the history
Fixes #4954 
# Features
Initially the page shows all available public adventures.
![image](https://github.com/hedyorg/hedy/assets/20051470/66d39d18-4d5d-4346-9468-d09bebe45342)

There are four filters that teachers can utilize in order to find adventures quickly. These filters are:
1. search input: finds an adventure whose name matches the search input
2. level (default=1): gets adventures of a specific level. Only one item can be selected.
3. language: gets adventures of a specific language. Only one item can be selected.
4. tags: gets adventures that have at least one of the selected tags. Multiple items can be selected.

Another interesting and important feature is that the URL always updates wrt the filters. For instance, if you filter by English, the URL will be something like: public-adventures?level=1&**lang=en**&tag=&search=. The same applied to all available filters. Additionally, this means that you can share a url with someone and they would see the exact same adventures you've filtered. So, if you share `/public-adventures?level=1&lang=&tag=print&search=parr` with another teacher, they will see exactly what you see:
![image](https://github.com/hedyorg/hedy/assets/20051470/e1707e39-d041-4ceb-a64f-9e7fdc127d9c)

# **How to test?**
Go to `/public-adventures` and start applying some filters, run some code of any adventure you like, and perhaps clone one that's created by some other user. 

# **Technicality (for devs)**

Whenever a filter is applied, we send a request to python and expect two things:
- `html`: the template of the new filtered adventures, to replace the current adventure tabs with. 
- `js`: some properties that are needed for js; e.g., to initialize the editor.

The html is simply a string that we replace the innerHTML of the target element that includes the editor and tabs. And since this is a string, we need to manually initialize the JS code; e.g., run the `initializeHighlightedCodeBlocks` or initialize the editor with the selected level.

The reason why I didn't use htmx here is due to the fact that the created template that has the editor and tabs (i.e., in `public-adventures-body.html`) does not issue a rerender on the js side. Most probably, this behavior is because htmx only replaces a target element by whatever the server returns and does not mind the `js` property that we pass to the template. As a result, the editor and tabs become just views with no interactivity.

Another matter is the usage of Tailwind Elements. I used this for two reasons: it uses Tailwind (which we also do) and it has a plenty of already styled elements. The problem with TE is that they don't support RTL currently, [however, they did mention that they're working on it](mdbootstrap/TW-Elements#2264 (comment)). So, let's just keep using our custom select that I created, until they fix that issue or we need some of the beautiful components they provide.
  • Loading branch information
hasan-sh committed Jan 24, 2024
1 parent 8226e50 commit f99c3ea
Show file tree
Hide file tree
Showing 21 changed files with 703 additions and 287 deletions.
20 changes: 19 additions & 1 deletion data-for-testing.json
Original file line number Diff line number Diff line change
Expand Up @@ -1460,8 +1460,26 @@
"creator": "teacher1",
"name": "adventure1",
"level": "1",
"levels": ["1", "2"],
"content": "This is the explanation of my adventure!\n\nThis way I can show a command: <code>print</code>\n\nBut sometimes I might want to show a piece of code, like this:\n<pre class=\"no-copy-button\">ask What's your name?\necho so your name is \n</pre>",
"public": true
"public": true,
"tags": [
"test"
]
}
],
"tags": [
{
"id": "01f34a6453ff420a8fb83a3fc0f18d8d",
"name": "test",
"tagged_in": [
{
"id": "3f8aea42eb324f08a16776671498dd1b",
"public": true,
"language": "en"
}
],
"popularity": 1
}
]
}
19 changes: 19 additions & 0 deletions static/css/additional.css
Original file line number Diff line number Diff line change
Expand Up @@ -225,3 +225,22 @@ div[data-te-input-notch-ref] {
font-weight: bold;
background-color: #edf2f7;
}

.option {
padding: 8px;
cursor: pointer;

&.selected {
background-color: rgb(0 0 0 / 0.05);
}

&:not(.default).selected::after {
content: "✓";
margin: 1em;
}

&:hover {
background-color: rgb(0 0 0 / 0.05);
}

}
4 changes: 4 additions & 0 deletions static/css/generated.full.css
Original file line number Diff line number Diff line change
Expand Up @@ -346427,6 +346427,10 @@ div[class^="ace_incorrect_hedy_code"] {
width: 41.666667%;
}

.sm\:basis-1\/2 {
flex-basis: 50%;
}

.sm\:flex-row {
flex-direction: row;
}
Expand Down
2 changes: 1 addition & 1 deletion static/js/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2088,7 +2088,7 @@ async function saveIfNecessary() {
const saveName = saveNameFromInput();


if (theUserIsLoggedIn) {
if (theUserIsLoggedIn && saveName) {
const saveInfo = isServerSaveInfo(adventure.save_info) ? adventure.save_info : undefined;
const response = await postJsonWithAchievements('/programs', {
level: theLevel,
Expand Down
218 changes: 130 additions & 88 deletions static/js/appbundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -4079,12 +4079,10 @@ var hedyApp = (() => {
InitLineChart: () => InitLineChart,
add_account_placeholder: () => add_account_placeholder,
append_classname: () => append_classname,
applyFilter: () => applyFilter,
changeUserEmail: () => changeUserEmail,
change_language: () => change_language,
change_password_student: () => change_password_student,
clearUnsavedChanges: () => clearUnsavedChanges,
cloned: () => cloned,
closeAchievement: () => closeAchievement,
closeContainingModal: () => closeContainingModal,
comeBackHereAfterLogin: () => comeBackHereAfterLogin,
Expand Down Expand Up @@ -45815,9 +45813,9 @@ notes_mapping = {
function styleTags(spec) {
let byName = Object.create(null);
for (let prop in spec) {
let tags3 = spec[prop];
if (!Array.isArray(tags3))
tags3 = [tags3];
let tags2 = spec[prop];
if (!Array.isArray(tags2))
tags2 = [tags2];
for (let part of prop.split(" "))
if (part) {
let pieces = [], mode = 2, rest = part;
Expand Down Expand Up @@ -45845,16 +45843,16 @@ notes_mapping = {
let last = pieces.length - 1, inner = pieces[last];
if (!inner)
throw new RangeError("Invalid path: " + part);
let rule = new Rule(tags3, mode, last > 0 ? pieces.slice(0, last) : null);
let rule = new Rule(tags2, mode, last > 0 ? pieces.slice(0, last) : null);
byName[inner] = rule.sort(byName[inner]);
}
}
return ruleNodeProp.add(byName);
}
var ruleNodeProp = new NodeProp();
var Rule = class {
constructor(tags3, mode, context2, next) {
this.tags = tags3;
constructor(tags2, mode, context2, next) {
this.tags = tags2;
this.mode = mode;
this.context = context2;
this.next = next;
Expand All @@ -45878,9 +45876,9 @@ notes_mapping = {
}
};
Rule.empty = new Rule([], 2, null);
function tagHighlighter(tags3, options) {
function tagHighlighter(tags2, options) {
let map3 = Object.create(null);
for (let style of tags3) {
for (let style of tags2) {
if (!Array.isArray(style.tag))
map3[style.tag.id] = style.class;
else
Expand All @@ -45889,9 +45887,9 @@ notes_mapping = {
}
let { scope, all = null } = options || {};
return {
style: (tags4) => {
style: (tags3) => {
let cls = all;
for (let tag of tags4) {
for (let tag of tags3) {
for (let sub of tag.set) {
let tagClass = map3[sub.id];
if (tagClass) {
Expand All @@ -45905,10 +45903,10 @@ notes_mapping = {
scope
};
}
function highlightTags(highlighters, tags3) {
function highlightTags(highlighters, tags2) {
let result = null;
for (let highlighter of highlighters) {
let value = highlighter.style(tags3);
let value = highlighter.style(tags2);
if (value)
result = result ? result + " " + value : value;
}
Expand Down Expand Up @@ -48967,13 +48965,13 @@ notes_mapping = {
var openSearchPanel = (view) => {
let state = view.state.field(searchState, false);
if (state && state.panel) {
let searchInput = getSearchInput(view);
if (searchInput && searchInput != view.root.activeElement) {
let searchInput2 = getSearchInput(view);
if (searchInput2 && searchInput2 != view.root.activeElement) {
let query = defaultQuery(view.state, state.query.spec);
if (query.valid)
view.dispatch({ effects: setSearchQuery.of(query) });
searchInput.focus();
searchInput.select();
searchInput2.focus();
searchInput2.select();
}
} else {
view.dispatch({ effects: [
Expand Down Expand Up @@ -58322,7 +58320,7 @@ pygame.quit()
console.info("Saving program automatically...");
const code = theGlobalEditor.contents;
const saveName = saveNameFromInput();
if (theUserIsLoggedIn) {
if (theUserIsLoggedIn && saveName) {
const saveInfo = isServerSaveInfo(adventure.save_info) ? adventure.save_info : void 0;
const response = await postJsonWithAchievements("/programs", {
level: theLevel,
Expand Down Expand Up @@ -76848,82 +76846,126 @@ pygame.quit()
ZC({ Validation: Ch, Select: _r });

// static/js/public-adventures.ts
function cloned(message, success2 = true) {
if (success2) {
modal.notifySuccess(message);
} else {
modal.notifyError(message);
}
}
function applyFilter(term, type, filtered) {
var _a3, _b;
term = term.trim();
filtered[type] = filtered[type] || { exclude: [] };
filtered[type]["term"] = term;
const filterExist = document.querySelector("#search_adventure").value || document.querySelector("#language").value || document.querySelector("#tag").value;
if (!term) {
filtered[type] = { term, exclude: [] };
}
const adventures = document.querySelectorAll(".adventure");
if (!filterExist) {
for (const adv of adventures) {
adv.classList.remove("hidden");
}
filtered = {};
return;
}
for (const adv of adventures) {
let toValidate;
let skip2 = false;
if (type === "search") {
toValidate = (_a3 = adv.querySelector(".name")) == null ? void 0 : _a3.innerHTML;
} else if (type === "lang") {
toValidate = adv.getAttribute("data-lang");
} else {
const advTags = ((_b = adv.querySelector("#tags-list")) == null ? void 0 : _b.children) || [];
for (const t2 of advTags) {
const value = t2.innerHTML.trim();
if (term.includes(value)) {
if (filtered[type].exclude.some((a) => a === adv)) {
filtered[type].exclude = filtered[type].exclude.filter((a) => a !== adv);
}
skip2 = true;
break;
}
var levelSelect = document.getElementById("level-select");
var languageSelect = document.getElementById("language-select");
var tagsSelect = document.getElementById("tag-select");
var searchInput = document.getElementById("search_adventure");
var searchTimeout;
searchInput == null ? void 0 : searchInput.addEventListener("input", handleSearchInput);
document.addEventListener("DOMContentLoaded", () => {
const options = document.querySelectorAll(".option");
options.forEach(function(option2) {
option2.addEventListener("click", function() {
const dropdown = option2.closest(".dropdown");
if (!dropdown) {
return;
}
}
if (skip2)
continue;
if (term && (toValidate == null ? void 0 : toValidate.includes(term))) {
if (filtered[type].exclude.some((a) => a === adv)) {
filtered[type].exclude = filtered[type].exclude.filter((a) => a !== adv);
const isSingleSelect = (dropdown == null ? void 0 : dropdown.getAttribute("data-type")) === "single";
if (isSingleSelect && !option2.classList.contains("selected")) {
const otherOptions = dropdown.querySelectorAll(".option.selected");
otherOptions.forEach((otherOption) => otherOption.classList.remove("selected"));
}
} else if (term) {
if (filtered.term !== term && !filtered[type].exclude.some((a) => a === adv)) {
filtered[type].exclude.push(adv);
let nextValue = dropdown.getAttribute("data-value");
if (option2.classList.contains("selected")) {
nextValue = nextValue == null ? void 0 : nextValue.replace(option2.getAttribute("data-value"), "");
if (!isSingleSelect) {
nextValue = nextValue.split(",").filter((v) => v).join(",");
} else {
return;
}
} else if (!isSingleSelect) {
const currentValue = dropdown.getAttribute("data-value") || "";
nextValue = [currentValue, option2.getAttribute("data-value") || ""].filter((v) => v).join(",");
} else {
nextValue = option2.getAttribute("data-value") || "";
}
}
dropdown.setAttribute("data-value", nextValue);
option2.classList.toggle("selected");
updateLabelText(dropdown);
updateDOM();
});
});
updateDOM();
setTimeout(() => {
if (!levelSelect)
return;
const level3 = levelSelect.getAttribute("data-value") || "";
const cloneBtn = document.getElementById(`clone_adventure_btn_${level3}`);
cloneBtn == null ? void 0 : cloneBtn.addEventListener("click", handleCloning);
}, 500);
});
function getSelectedOptions(_options) {
return Array.from(_options).filter((option2) => option2.classList.contains("selected")).map((option2) => {
var _a3;
return (_a3 = option2.textContent) == null ? void 0 : _a3.trim();
});
}
function updateLabelText(dropdown) {
const toggleButton = dropdown.querySelector(".toggle-button");
const relativeOptions = dropdown.querySelectorAll(".option");
const label = toggleButton.querySelector(".label");
const selectedOptions = getSelectedOptions(relativeOptions);
label.textContent = selectedOptions.length === 0 ? label.getAttribute("data-value") : selectedOptions.join(", ");
}
async function handleCloning(e) {
const target = e.target;
const adventureId = target.getAttribute("data-id");
try {
const data = await postJson(`public-adventures/clone/${adventureId}`);
modal.notifySuccess(data.message);
await updateDOM();
} catch (error2) {
modal.notifyError(error2.responseText);
}
for (const adv of adventures) {
let allFiltersPassed = true;
for (const t2 in filtered) {
if (filtered[t2].exclude.some((a) => a === adv)) {
allFiltersPassed = false;
}
}
if (allFiltersPassed) {
adv.classList.remove("hidden");
} else {
adv.classList.add("hidden");
}
function handleSearchInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(updateDOM, 500);
}
function updateURL() {
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
const level3 = levelSelect.getAttribute("data-value") || "";
const lanugage = languageSelect.getAttribute("data-value") || "";
const tags2 = tagsSelect.getAttribute("data-value") || "";
urlParams.set("level", level3);
urlParams.set("lang", lanugage);
urlParams.set("tag", tags2);
if (searchInput) {
urlParams.set("search", searchInput.value);
}
window.history.pushState({}, "", `${window.location.pathname}?${urlParams.toString()}`);
}
async function updateDOM() {
if (!levelSelect || !languageSelect || !tagsSelect)
return;
const level3 = levelSelect.getAttribute("data-value") || "";
const lanugage = languageSelect.getAttribute("data-value") || "";
const tags2 = tagsSelect.getAttribute("data-value") || "";
const response = await fetch(`public-adventures/filter?tag=${tags2}&lang=${lanugage}&level=${level3}&search=${searchInput == null ? void 0 : searchInput.value}`, {
method: "GET",
keepalive: true,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Accept": "application/json"
}
});
const { html, js } = await response.json();
updateURL();
const publicAdventuresBody = document.getElementById("public-adventures-body");
if (publicAdventuresBody) {
publicAdventuresBody.innerHTML = html;
initialize({
lang: js.lang,
level: js.level,
keyword_language: js.lang,
javascriptPageOptions: js
});
initializeHighlightedCodeBlocks(publicAdventuresBody);
const cloneBtn = document.getElementById(`clone_adventure_btn_${level3}`);
cloneBtn == null ? void 0 : cloneBtn.addEventListener("click", handleCloning);
}
}
var tags2 = document.getElementById("tag");
var tagsInstance = _r.getInstance(tags2);
tags2 == null ? void 0 : tags2.addEventListener("valueChange.te.select", () => {
const value = tagsInstance.value.join(",");
applyFilter(value.replaceAll(",", " "), "tags", window.$filtered || {});
});
return js_exports;
})();
/*!
Expand Down
4 changes: 2 additions & 2 deletions static/js/appbundle.js.map

Large diffs are not rendered by default.

0 comments on commit f99c3ea

Please sign in to comment.