From 99c78966372017cb3b7bf021f08520a040004b0c Mon Sep 17 00:00:00 2001 From: Vincent Ambo Date: Thu, 20 Apr 2023 13:40:40 +0300 Subject: feat(corp/rih): implement initial frontend application This doesn't actually submit anything to the (not-yet-existing) backend, but will help the designers figure out what we're actually looking for here. Change-Id: I680d88151fb0706953f18eb6256da6f205da7ffb Reviewed-on: https://cl.tvl.fyi/c/depot/+/8489 Reviewed-by: tazjin Tested-by: BuildkiteCI --- corp/rih/src/main.rs | 369 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 369 insertions(+) create mode 100644 corp/rih/src/main.rs (limited to 'corp/rih/src/main.rs') diff --git a/corp/rih/src/main.rs b/corp/rih/src/main.rs new file mode 100644 index 000000000000..e2f3480c37a8 --- /dev/null +++ b/corp/rih/src/main.rs @@ -0,0 +1,369 @@ +use fuzzy_matcher::skim::SkimMatcherV2; +use fuzzy_matcher::FuzzyMatcher; +use gloo::console; +use gloo::storage::{LocalStorage, Storage}; +use rand::seq::IteratorRandom; +use rand::thread_rng; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use wasm_bindgen::closure::Closure; +use wasm_bindgen::JsCast; +use web_sys::{HtmlInputElement, HtmlTextAreaElement, KeyboardEvent}; +use yew::html::Scope; +use yew::prelude::*; + +/// This code ends up being compiled for the native and for the +/// webassembly architectures during the build & test process. +/// However, the `rust_iso3166` crate exposes a different API (!) +/// based on the platform. +/// +/// This trait acts as a platform-independent wrapper for the crate. +/// +/// Upstream issue: https://github.com/rust-iso/rust_iso3166/issues/7 +trait CountryCodeAccess { + fn country_alpha2(&self) -> String; + fn country_alpha3(&self) -> String; + fn country_name(&self) -> String; +} + +#[cfg(target_arch = "wasm32")] +impl CountryCodeAccess for rust_iso3166::CountryCode { + fn country_alpha2(&self) -> String { + self.alpha2() + } + + fn country_alpha3(&self) -> String { + self.alpha3() + } + + fn country_name(&self) -> String { + self.name() + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl CountryCodeAccess for rust_iso3166::CountryCode { + fn country_alpha2(&self) -> String { + self.alpha2.to_string() + } + + fn country_alpha3(&self) -> String { + self.alpha3.to_string() + } + + fn country_name(&self) -> String { + self.name.to_string() + } +} + +const VISTA_URL: &'static str = "https://vista-immigration.ru/"; + +/// Represents a single record as filled in by a user. This is the +/// primary data structure we want to populate and persist somewhere. +#[derive(Default, Debug, Deserialize, Serialize)] +struct Record { + // Personal information + name: String, + email: String, + citizenship: String, // TODO + personal_details: String, + + // Job information + position: String, + technologies: BTreeSet, + job_details: String, + work_background: String, +} + +#[derive(Default)] +struct App { + // The record being populated. + record: Record, + + // Is the citizenship input focused? + citizenship_focus: bool, + + // Current query in the citizenship field. + citizenship_query: String, +} + +#[derive(Clone, Debug)] +enum Msg { + NoOp, + AddTechnology(String), + RemoveTechnology(String), + + FocusCitizenship, + BlurCitizenship, + QueryCitizenship(String), + SetCitizenship(String), + + SetName(String), + SetEmail(String), + SetPersonalDetails(String), + SetPosition(String), + SetJobDetails(String), + SetWorkBackground(String), +} + +/// Callback handler for adding a technology. +fn add_tech(e: KeyboardEvent) -> Msg { + if e.key_code() != 13 { + return Msg::NoOp; + } + + let input = e.target_unchecked_into::(); + let tech = input.value(); + input.set_value(""); + Msg::AddTechnology(tech) +} + +fn select_country_enter(event: KeyboardEvent) -> Msg { + if event.key_code() != 13 { + return Msg::NoOp; + } + + let input = event.target_unchecked_into::(); + if let Some(country) = fuzzy_country_matches(&input.value()).next() { + input.set_value(&country.country_name()); + return Msg::SetCitizenship(country.country_name()); + } + + Msg::NoOp +} + +fn fuzzy_country_matches(query: &str) -> Box + '_> { + if query.is_empty() { + let rng = &mut thread_rng(); + return Box::new( + rust_iso3166::ALL + .iter() + .choose_multiple(rng, 5) + .into_iter() + .map(Clone::clone), + ); + } + + let matcher = SkimMatcherV2::default(); + let query = query.to_lowercase(); + let query_len = query.len(); + + let mut results: Vec<_> = rust_iso3166::ALL + .iter() + .filter_map(|code| { + let mut score = None; + // Prioritize exact matches for country codes if query length <= 3 + if query_len <= 3 { + if code.country_alpha2().eq_ignore_ascii_case(&query) + || code.country_alpha3().eq_ignore_ascii_case(&query) + { + score = Some(100); + } + } + // If no exact match, do a fuzzy match + if score.is_none() { + score = matcher.fuzzy_match(&code.country_name().to_lowercase(), &query); + } + + score.map(|score| (score, code)) + }) + .collect(); + + // Sort by score in descending order + results.sort_by(|a, b| b.0.cmp(&a.0)); + + // Get iterator over the best matches + Box::new(results.into_iter().map(|(_score, code)| *code)) +} + +// Callback for an input element's value being mapped straight into a +// message. +fn input_message(e: InputEvent, msg: fn(String) -> Msg) -> Msg { + let input = e.target_unchecked_into::(); + msg(input.value()) +} + +// Callback for a text area's value being mapped straight into a +// message. +fn textarea_message(e: InputEvent, msg: fn(String) -> Msg) -> Msg { + let textarea = e.target_unchecked_into::(); + msg(textarea.value()) +} + +fn schedule_blur(event: FocusEvent, link: Scope) -> Msg { + let input = event.target_unchecked_into::(); + let closure = Closure::once_into_js(Box::new(move || { + if let Some(app) = link.get_component() { + input.set_value(&app.record.citizenship); + } + + link.send_message(Msg::BlurCitizenship); + }) as Box); + + let window = web_sys::window().expect("no global `window` exists"); + let _ = + window.set_timeout_with_callback_and_timeout_and_arguments_0(closure.unchecked_ref(), 100); + + Msg::NoOp +} + +/// Creates an input field for citizenship selection with suggestions. +fn citizenship_input(app: &App, link: &Scope) -> Html { + let dropdown_classes = if app.citizenship_focus { + "dropdown-menu show" + } else { + "dropdown-menu" + }; + + let choices = fuzzy_country_matches(&app.citizenship_query).map(|country| { + let msg = Msg::SetCitizenship(country.country_name()); + html! { +
  • {country.country_name()}
  • + } + }); + + let blur_link = link.clone(); + html! { + + } +} + +/// Creates a list of technologies which can be deleted again by clicking them. +fn render_technologies(link: &Scope, technologies: &BTreeSet) -> Html { + if technologies.is_empty() { + return html! {}; + } + + let items = technologies.iter().map(|tech| { + let msg: Msg = Msg::RemoveTechnology(tech.to_string()); + html! { + <> + + {tech} + {" x"} + {" "} + + } + }); + html! { +
    + { items.collect::() } +
    + } +} + +impl Component for App { + type Message = Msg; + type Properties = (); + + fn create(_ctx: &Context) -> Self { + let mut new = Self::default(); + + if let Ok(record) = LocalStorage::get("record") { + new.record = record; + } + + new + } + + fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { + console::log!("handling ", format!("{:?}", msg)); + let (state_change, view_change) = match msg { + Msg::NoOp => (false, false), + + Msg::AddTechnology(tech) => { + console::log!("adding technology", &tech); + self.record.technologies.insert(tech); + (true, true) + } + + Msg::RemoveTechnology(tech) => { + console::log!("removing technology ", &tech); + self.record.technologies.remove(&tech); + (true, true) + } + + Msg::QueryCitizenship(query) => { + self.citizenship_query = query; + (false, true) + } + + Msg::FocusCitizenship => { + self.citizenship_focus = true; + (false, true) + } + + Msg::BlurCitizenship => { + self.citizenship_focus = false; + (false, true) + } + + Msg::SetCitizenship(country) => { + self.record.citizenship = country; + (true, false) + } + + Msg::SetName(name) => { + self.record.name = name; + (true, false) + } + + Msg::SetEmail(email) => { + self.record.email = email; + (true, false) + } + + Msg::SetPersonalDetails(details) => { + self.record.personal_details = details; + (true, false) + } + + Msg::SetPosition(position) => { + self.record.position = position; + (true, false) + } + + Msg::SetJobDetails(details) => { + self.record.job_details = details; + (true, false) + } + + Msg::SetWorkBackground(background) => { + self.record.work_background = background; + (true, false) + } + }; + + if state_change { + if let Err(err) = LocalStorage::set("record", &self.record) { + console::warn!( + "failed to persist record in local storage: ", + err.to_string() + ); + } + } + + view_change + } + + fn view(&self, ctx: &Context) -> Html { + let link = ctx.link(); + + include!("home.html") + } +} + +fn main() { + yew::Renderer::::new().render(); +} -- cgit 1.4.1