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

Replace sign_in.js by webcomponent #4023

Merged
merged 14 commits into from
Oct 5, 2022
42 changes: 28 additions & 14 deletions app/assets/javascripts/components/datalist_input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import { html, TemplateResult } from "lit";
import { ShadowlessLitElement } from "components/shadowless_lit_element";
import { ref, Ref, createRef } from "lit/directives/ref.js";
import { watchMixin } from "components/watch_mixin";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { htmlEncode } from "util.js";

type Option = {label: string, value: string, extra?: string};
export type Option = {label: string, value: string, extra?: string};

/**
* This component represents an input field with a datalist with possible options for the input.
Expand All @@ -16,6 +18,7 @@ type Option = {label: string, value: string, extra?: string};
* If the user input does not match any label, the value sent to the server wil be ""
* The extra string is added in the options and also used to match the input
* @prop {String} value - the initial value for this field
* @prop {String} filter - the initial filter value for this field
* @prop {String} placeholder - placeholder text shown in input
*
* @fires input - on value change, event details contain {label: string, value: string}
Expand All @@ -29,13 +32,12 @@ export class DatalistInput extends watchMixin(ShadowlessLitElement) {
@property({ type: String })
value: string;
@property({ type: String })
filter: string = this.label;
@property({ type: String })
placeholder: string;

inputRef: Ref<HTMLInputElement> = createRef();

@property({ state: true })
filter: string = this.label;

watch = {
filter: () => {
if (!this.value) {
Expand All @@ -60,14 +62,12 @@ export class DatalistInput extends watchMixin(ShadowlessLitElement) {
};

fireEvent(): void {
if (this.value) {
const event = new CustomEvent("input", {
detail: { value: this.value, label: this.filter },
bubbles: true,
composed: true
});
this.dispatchEvent(event);
}
const event = new CustomEvent("input", {
detail: { value: this.value, label: this.filter },
bubbles: true,
composed: true
});
this.dispatchEvent(event);
}

get label(): string {
Expand Down Expand Up @@ -116,6 +116,19 @@ export class DatalistInput extends watchMixin(ShadowlessLitElement) {
e.stopPropagation();
}

keydown(e: KeyboardEvent): void {
if (e.key === "Tab" && this.filtered_options.length > 0) {
this.value = this.filtered_options[0].value;
this.filter = this.filtered_options[0].label;
}
}

mark(s: string): TemplateResult {
return this.filter ?
html`${unsafeHTML(htmlEncode(s).replace(new RegExp(this.filter, "gi"), m => `<b>${m}</b>`))}` :
html`${s}`;
}

render(): TemplateResult {
return html`
<div class="dropdown">
Expand All @@ -125,14 +138,15 @@ export class DatalistInput extends watchMixin(ShadowlessLitElement) {
@input=${e => this.processInput(e)}
.value="${this.filter}"
placeholder="${this.placeholder}"
@keydown=${e => this.keydown(e)}
>
<ul class="dropdown-menu ${this.filter && this.filtered_options.length > 0 ? "show-search-dropdown" : ""}"
style="position: fixed; top: ${this.dropdown_top}px; left: ${this.dropdown_left}px; max-width: ${this.dropdown_width}px; overflow-x: hidden;">
${this.filtered_options.map(option => html`
<li><a class="dropdown-item ${this.value === option.value ? "active" :""} " @click=${ e => this.select(option, e)} style="cursor: pointer;">
${option.label}
${this.mark(option.label)}
${option.extra ? html`
<br/><span class="small">${option.extra}</span>
<br/><span class="small">${this.mark(option.extra)}</span>
`:""}
</a></li>
`)}
Expand Down
73 changes: 73 additions & 0 deletions app/assets/javascripts/components/sign_in_search_bar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { customElement, property } from "lit/decorators.js";
import { html, TemplateResult } from "lit";
import { ShadowlessLitElement } from "components/shadowless_lit_element";
import { Option } from "components/datalist_input";
import { ready } from "util.js";
import "components/datalist_input";

/**
* @element d-sign-in-search-bar
*
* @prop {{name: string, provider: string}[]} Institutions
* @prop {Record<string, {name: string, link: string}>} Providers
*/
@customElement("d-sign-in-search-bar")
export class SignInSearchBar extends ShadowlessLitElement {
@property({ type: Array })
institutions: {name: string, provider: string}[];
@property({ type: Object })
providers: Record<string, {name: string, link: string}>;

@property({ state: true })
selected_provider: string;
@property({ state: true })
filter: string;

get link(): string {
return this.providers[this.selected_provider]?.link || "";
}

get options(): Option[] {
return this.institutions.map(i => ({ label: i.name, value: i.provider, extra: this.providers[i.provider].name }));
}

constructor() {
super();
// Reload when I18n is available
ready.then(() => this.requestUpdate());

const localStorageInstitution = localStorage.getItem("institution");
if (localStorageInstitution !== null) {
const institution = JSON.parse(localStorageInstitution);
this.filter = institution.name;
}
}

handleInput(e: CustomEvent): void {
this.selected_provider = e.detail.value;
this.filter = e.detail.label;
if (e.detail.value) {
localStorage.setItem("institution", JSON.stringify({ name: e.detail.label }));
} else {
localStorage.removeItem("institution");
}
}

render(): TemplateResult {
return html`
<div class="input-group input-group-lg autocomplete">
<d-datalist-input
filter="${this.filter}"
.options=${this.options}
@input=${e => this.handleInput(e)}
placeholder="${I18n.t("js.sign_in_search_bar.institution_search")}"
></d-datalist-input>
<a class="btn btn-primary btn-lg login-button ${this.selected_provider == "" ? "disabled": ""}"
href=${this.link}>
${I18n.t("js.sign_in_search_bar.log_in")}
</a>
</div>

`;
}
}
8 changes: 8 additions & 0 deletions app/assets/javascripts/i18n/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,10 @@
"score_item": {
"error": "Error while updating"
},
"sign_in_search_bar": {
"institution_search": "Type to search for your institution",
"log_in": "Sign in"
},
"stacked_desc": "This graph shows the distribution of submissions statuses.",
"stacked_title": "Distribution of submission statuses",
"status": {
Expand Down Expand Up @@ -734,6 +738,10 @@
"score_item": {
"error": "Fout bij bijwerken"
},
"sign_in_search_bar": {
"institution_search": "Typ om jouw school te vinden",
"log_in": "Aanmelden"
},
"stacked_desc": "Deze grafiek geeft de verdeling van de oplossingsstatussen per oefening weer.",
"stacked_title": "Verdeling van de oplossingsstatus",
"status": {
Expand Down
72 changes: 0 additions & 72 deletions app/assets/javascripts/sign_in.js

This file was deleted.

18 changes: 18 additions & 0 deletions app/assets/javascripts/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,23 @@ const ready = new Promise(resolve => {
}
});

// source https://github.com/janl/mustache.js/blob/master/mustache.js#L73
const entityMap = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#39;",
"/": "&#x2F;",
"`": "&#x60;",
"=": "&#x3D;"
};

function htmlEncode(str) {
return String(str).replace(/[&<>"'`=/]/g, function (s) {
return entityMap[s];
});
}

export {
createDelayer,
Expand All @@ -192,4 +209,5 @@ export {
setDocumentTitle,
initDatePicker,
ready,
htmlEncode,
};
35 changes: 15 additions & 20 deletions app/assets/stylesheets/pages/sign-in.css.scss
Original file line number Diff line number Diff line change
Expand Up @@ -63,30 +63,25 @@ input:focus {
outline: none;
}

#scrollable-dropdown-menu {
.card {
margin-bottom: 0;
d-sign-in-search-bar d-datalist-input {
flex-grow: 1;

.form-control {
font-size: 1rem;
padding: 0.65rem;
border-top-right-radius: initial;
border-bottom-right-radius: initial;
}

margin-bottom: 16px;

.tt-menu {
max-height: 150px;
overflow-y: auto;
background-color: white;
width: 100%;
.dropdown-item {
font-size: 1rem;
}

.tt-selectable:hover {
background-color: lightskyblue;
}
.dropdown-menu {
width: 100%;

.tt-input,
.tt-hint {
border: none;
font-size: 18px;
min-height: 42px;
color: $on-surface;
border-radius: 6px 0 0 6px !important;
a:hover {
font-size: 1rem;
}
}
}
4 changes: 1 addition & 3 deletions app/javascript/packs/sign_in.js
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
import { initInstitutionAutoSelect } from "../../assets/javascripts/sign_in.js";

window.dodona.initInstitutionAutoSelect = initInstitutionAutoSelect;
import "components/sign_in_search_bar";
15 changes: 4 additions & 11 deletions app/views/auth/sign_in.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,10 @@
</h1>
</div>
</div>
<div class="input-group input-group-lg autocomplete" id="scrollable-dropdown-menu">
<div class="card">
<input class="typeahead" type="text" placeholder="<%= t ".institution-search" %>">
</div>
<a class="btn btn-primary btn-lg login-button" id="sign-in" href="" disabled=true><%= t 'layout.menu.log_in' %></a>
</div>
<d-sign-in-search-bar
institutions="<%= @oauth_providers.map{|i| {name: i.institution.name, provider: i.type}}.to_json %>"
providers="<%= @generic_providers.map{|p| [p, {name: p.readable_name, link: omniauth_authorize_path(:user, p.sym) }]}.to_h.to_json %>"
></d-sign-in-search-bar>

<div class="sign-in-divider row">
<div class="sign-in-dialog-footer">
Expand Down Expand Up @@ -96,8 +94,3 @@
</div>
</div>
</div>
<script>
const institutions = <%= raw @oauth_providers.map{|i| {id: i.id, name: i.institution.name, type: i.type}}.to_json %>;
const links = <%= raw @generic_providers.map{|p| [p, {name: p.readable_name, link: omniauth_authorize_path(:user, p.sym) }]}.to_h.to_json %>;
window.dodona.initInstitutionAutoSelect(institutions, links);
</script>
4 changes: 4 additions & 0 deletions config/locales/js/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -244,4 +244,8 @@ en:
title: 'Reuse a saved comment'
help_html: 'Saved annotations are automatically filtered by course and exercise. Click <a href="/saved_annotations" target="_blank" >here</a> to manage all saved comments.'
edited: The current comment differs from the saved comment
sign_in_search_bar:
institution_search: "Type to search for your institution"
log_in: Sign in


3 changes: 3 additions & 0 deletions config/locales/js/nl.yml
Original file line number Diff line number Diff line change
Expand Up @@ -244,4 +244,7 @@ nl:
title: 'Hergebruik een opgeslagen opmerking'
help_html: 'Opgeslagen opmerkingen worden automatisch gefilterd per cursus en per oefening. Klik <a href="/saved_annotations" target="_blank" >hier</a> om alle opgeslagen opmerkingen te beheren.'
edited: De huidige opmerking verschilt van de opgeslagen opmerking
sign_in_search_bar:
institution_search: "Typ om jouw school te vinden"
log_in: Aanmelden

1 change: 0 additions & 1 deletion config/locales/views/auth/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ en:
sign_in: "Sign in"
sign-in-with: "Sign in with %{provider}"
sign-in-help: "Don't know which login you can use? Search for your school and click the sign in button:"
institution-search: "Type to search for your institution"
ilearn-help: "Users of i-Learn can currently only sign in through the i-Learn website."
higher-education: "Higher education"
other-education: "Other educational institutions"
Expand Down
Loading