Adding paged loading of links.

This commit is contained in:
Dietrich 2021-07-17 14:26:19 +02:00 committed by Franz Dietrich
parent 2b276a5130
commit b11177a943
12 changed files with 156 additions and 21 deletions

1
Cargo.lock generated
View File

@ -454,6 +454,7 @@ dependencies = [
"strum", "strum",
"strum_macros", "strum_macros",
"unic-langid", "unic-langid",
"web-sys",
] ]
[[package]] [[package]]

View File

@ -27,3 +27,11 @@ qrcode = "0.12"
image = "0.23" image = "0.23"
shared = { path = "../shared" } shared = { path = "../shared" }
[dependencies.web-sys]
version = "0.3"
features = [
"IntersectionObserver",
"IntersectionObserverInit",
"IntersectionObserverEntry",
]

View File

@ -42,7 +42,7 @@ impl I18n {
/// Get a localized string. Optionally with parameters provided in `args`. /// Get a localized string. Optionally with parameters provided in `args`.
pub fn translate(&self, key: impl AsRef<str>, args: Option<&FluentArgs>) -> String { pub fn translate(&self, key: impl AsRef<str>, args: Option<&FluentArgs>) -> String {
log!(key.as_ref()); // log!(key.as_ref());
let msg = self let msg = self
.ftl_bundle .ftl_bundle
.get_message(key.as_ref()) .get_message(key.as_ref())

View File

@ -26,8 +26,8 @@ fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model {
Model { Model {
index: 0, index: 0,
location: Location::new(url.clone()), location: Location::new(url),
page: Page::init(url, orders, lang.clone()), page: Page::NotFound,
i18n: lang, i18n: lang,
user: Loadable::Data(None), user: Loadable::Data(None),
login_form: LoginForm::default(), login_form: LoginForm::default(),

View File

@ -9,6 +9,7 @@ use seed::{
a, attrs, div, h1, img, input, log, nodes, prelude::*, raw, section, span, table, td, th, tr, a, attrs, div, h1, img, input, log, nodes, prelude::*, raw, section, span, table, td, th, tr,
Url, C, IF, Url, C, IF,
}; };
use web_sys::{IntersectionObserver, IntersectionObserverEntry, IntersectionObserverInit};
use shared::{ use shared::{
apirequests::general::Ordering, apirequests::general::Ordering,
@ -25,6 +26,7 @@ use crate::{get_host, i18n::I18n, unwrap_or_return};
pub fn init(mut url: Url, orders: &mut impl Orders<Msg>, i18n: I18n) -> Model { pub fn init(mut url: Url, orders: &mut impl Orders<Msg>, i18n: I18n) -> Model {
// fetch the links to fill the list. // fetch the links to fill the list.
orders.send_msg(Msg::Query(QueryMsg::Fetch)); orders.send_msg(Msg::Query(QueryMsg::Fetch));
orders.perform_cmd(cmds::timeout(50, || Msg::SetupObserver));
// if the url contains create_link set the edit_link variable. // if the url contains create_link set the edit_link variable.
// This variable then opens the create link dialog. // This variable then opens the create link dialog.
let dialog = match url.next_path_part() { let dialog = match url.next_path_part() {
@ -37,24 +39,35 @@ pub fn init(mut url: Url, orders: &mut impl Orders<Msg>, i18n: I18n) -> Model {
Model { Model {
links: Vec::new(), // will contain the links to display links: Vec::new(), // will contain the links to display
load_more: ElRef::new(), // will contain a reference to the load more button to be able to load more links after scrolling to the bottom.
i18n, // to translate i18n, // to translate
formconfig: LinkRequestForm::default(), // when requesting links the form is stored here formconfig: LinkRequestForm::default(), // when requesting links the form is stored here
inputs: EnumMap::default(), // the input fields for the searches inputs: EnumMap::default(), // the input fields for the searches
dialog, dialog,
handle_render: None, handle_render: None,
handle_timeout: None, handle_timeout: None,
observer: None, // load more on scroll - this is used to see if the load-more button is completely visible
observer_callback: None,
observer_entries: None,
everything_loaded: false,
} }
} }
#[derive(Debug)] #[derive(Debug)]
pub struct Model { pub struct Model {
links: Vec<Cached<FullLink>>, // will contain the links to display links: Vec<Cached<FullLink>>, // will contain the links to display
load_more: ElRef<web_sys::Element>,
i18n: I18n, // to translate i18n: I18n, // to translate
formconfig: LinkRequestForm, // when requesting links the form is stored here formconfig: LinkRequestForm, // when requesting links the form is stored here
inputs: EnumMap<LinkOverviewColumns, FilterInput>, // the input fields for the searches inputs: EnumMap<LinkOverviewColumns, FilterInput>, // the input fields for the searches
dialog: Dialog, // User interaction - there can only ever be one dialog open. dialog: Dialog, // User interaction - there can only ever be one dialog open.
handle_render: Option<CmdHandle>, // Rendering qr-codes takes time... it is aborted when this handle is dropped and replaced. handle_render: Option<CmdHandle>, // Rendering qr-codes takes time... it is aborted when this handle is dropped and replaced.
handle_timeout: Option<CmdHandle>, // Rendering qr-codes takes time... it is aborted when this handle is dropped and replaced. handle_timeout: Option<CmdHandle>, // Rendering qr-codes takes time... it is aborted when this handle is dropped and replaced.
observer: Option<IntersectionObserver>,
observer_callback: Option<Closure<dyn Fn(Vec<JsValue>)>>,
observer_entries: Option<Vec<IntersectionObserverEntry>>,
everything_loaded: bool,
} }
impl Model { impl Model {
@ -136,6 +149,8 @@ pub enum Msg {
Query(QueryMsg), // Messages related to querying links Query(QueryMsg), // Messages related to querying links
Edit(EditMsg), // Messages related to editing links Edit(EditMsg), // Messages related to editing links
ClearAll, // Clear all messages ClearAll, // Clear all messages
SetupObserver, // Make an observer for endles scroll
Observed(Vec<IntersectionObserverEntry>),
SetMessage(String), // Set a message to the user SetMessage(String), // Set a message to the user
} }
@ -143,8 +158,10 @@ pub enum Msg {
#[derive(Clone)] #[derive(Clone)]
pub enum QueryMsg { pub enum QueryMsg {
Fetch, Fetch,
FetchAdditional,
OrderBy(LinkOverviewColumns), OrderBy(LinkOverviewColumns),
Received(Vec<FullLink>), Received(Vec<FullLink>),
ReceivedAdditional(Vec<FullLink>),
CodeFilterChanged(String), CodeFilterChanged(String),
DescriptionFilterChanged(String), DescriptionFilterChanged(String),
TargetFilterChanged(String), TargetFilterChanged(String),
@ -181,10 +198,54 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
Msg::Query(msg) => process_query_messages(msg, model, orders), Msg::Query(msg) => process_query_messages(msg, model, orders),
Msg::Edit(msg) => process_edit_messages(msg, model, orders), Msg::Edit(msg) => process_edit_messages(msg, model, orders),
Msg::ClearAll => clear_all(model), Msg::ClearAll => clear_all(model),
Msg::Observed(entries) => {
if let Some(entry) = entries.first() {
log!(entry);
if entry.is_intersecting() {
orders.send_msg(Msg::Query(QueryMsg::FetchAdditional));
}
}
}
Msg::SetMessage(msg) => { Msg::SetMessage(msg) => {
clear_all(model); clear_all(model);
model.dialog = Dialog::Message(Status::Error(Message { message: msg })); model.dialog = Dialog::Message(Status::Error(Message { message: msg }));
} }
Msg::SetupObserver => {
orders.skip();
// ---- observer callback ----
let sender = orders.msg_sender();
let callback = move |entries: Vec<JsValue>| {
let entries = entries
.into_iter()
.map(IntersectionObserverEntry::from)
.collect();
sender(Some(Msg::Observed(entries)));
};
let callback = Closure::wrap(Box::new(callback) as Box<dyn Fn(Vec<JsValue>)>);
// ---- observer options ----
let mut options = IntersectionObserverInit::new();
options.threshold(&JsValue::from(1));
// ---- observer ----
log!("Trying to register observer");
if let Ok(observer) =
IntersectionObserver::new_with_options(callback.as_ref().unchecked_ref(), &options)
{
if let Some(element) = model.load_more.get() {
log!("element registered! ", element);
observer.observe(&element);
// Note: Drop `observer` is not enough. We have to call `observer.disconnect()`.
model.observer = Some(observer);
model.observer_callback = Some(callback);
} else {
log!("element not yet registered! ");
};
} else {
log!("Failed to get observer!")
};
}
} }
} }
@ -193,7 +254,11 @@ pub fn process_query_messages(msg: QueryMsg, model: &mut Model, orders: &mut imp
match msg { match msg {
QueryMsg::Fetch => { QueryMsg::Fetch => {
orders.skip(); // No need to rerender orders.skip(); // No need to rerender
load_links(model, orders) initial_load(model, orders)
}
QueryMsg::FetchAdditional => {
orders.skip(); // No need to rerender
consecutive_load(model, orders)
} }
// Default to ascending ordering but if the links are already sorted according to this collumn toggle between ascending and descending ordering. // Default to ascending ordering but if the links are already sorted according to this collumn toggle between ascending and descending ordering.
QueryMsg::OrderBy(column) => { QueryMsg::OrderBy(column) => {
@ -247,6 +312,20 @@ pub fn process_query_messages(msg: QueryMsg, model: &mut Model, orders: &mut imp
}) })
.collect(); .collect();
} }
QueryMsg::ReceivedAdditional(response) => {
if response.len() < model.formconfig.amount {
log!("There are no more links! ");
model.everything_loaded = true
};
let mut new_links = response
.into_iter()
.map(|l| {
let cache = generate_qr_from_code(&l.link.code);
Cached { data: l, cache }
})
.collect();
model.links.append(&mut new_links);
}
QueryMsg::CodeFilterChanged(s) => { QueryMsg::CodeFilterChanged(s) => {
log!("Filter is: ", &s); log!("Filter is: ", &s);
let sanit = s.chars().filter(|x| x.is_alphanumeric()).collect(); let sanit = s.chars().filter(|x| x.is_alphanumeric()).collect();
@ -274,9 +353,19 @@ pub fn process_query_messages(msg: QueryMsg, model: &mut Model, orders: &mut imp
} }
} }
fn initial_load(model: &Model, orders: &mut impl Orders<Msg>) {
let mut data = model.formconfig.clone();
data.offset = 0;
load_links(orders, data);
}
fn consecutive_load(model: &Model, orders: &mut impl Orders<Msg>) {
let mut data = model.formconfig.clone();
data.offset = model.links.len();
load_links(orders, data);
}
/// Perform a request to the server to load the links from the server. /// Perform a request to the server to load the links from the server.
fn load_links(model: &Model, orders: &mut impl Orders<Msg>) { fn load_links(orders: &mut impl Orders<Msg>, data: LinkRequestForm) {
let data = model.formconfig.clone();
orders.perform_cmd(async { orders.perform_cmd(async {
let data = data; let data = data;
// create a request // create a request
@ -302,7 +391,11 @@ fn load_links(model: &Model, orders: &mut impl Orders<Msg>) {
Msg::SetMessage("Invalid response".to_string()) Msg::SetMessage("Invalid response".to_string())
); );
// The message that is sent by perform_cmd after this async block is completed // The message that is sent by perform_cmd after this async block is completed
Msg::Query(QueryMsg::Received(links)) match data.offset.cmp(&0) {
std::cmp::Ordering::Less => unreachable!(),
std::cmp::Ordering::Equal => Msg::Query(QueryMsg::Received(links)),
std::cmp::Ordering::Greater => Msg::Query(QueryMsg::ReceivedAdditional(links)),
}
}); });
} }
@ -530,6 +623,17 @@ pub fn view(model: &Model, logged_in_user: &User) -> Node<Msg> {
// Add all the content lines // Add all the content lines
model.links.iter().map(|l| { view_link(l, logged_in_user) }) model.links.iter().map(|l| { view_link(l, logged_in_user) })
], ],
if not(model.everything_loaded) {
a![
C!["loadmore", "button"],
el_ref(&model.load_more),
ev(Ev::Click, move |_| Msg::Query(QueryMsg::FetchAdditional)),
img![C!["reloadicon"], attrs!(At::Src => "/static/reload.svg")],
t("load-more-links")
]
} else {
a![C!["loadmore", "button"], t("no-more-links")]
}
] ]
} }

View File

@ -12,6 +12,8 @@ language = Sprache:
not-found = Dieser Link existiert nicht, oder wurde gelöscht. not-found = Dieser Link existiert nicht, oder wurde gelöscht.
load-more-links = Lade mehr Links
no-more-links = Es gibt keine weiteren Links
edit-link-headline = Zu editierender Link: {$linktitle} edit-link-headline = Zu editierender Link: {$linktitle}
edit-link = Link Editieren edit-link = Link Editieren
create-link = Link Erstellen create-link = Link Erstellen

View File

@ -12,6 +12,8 @@ language = Language:
not-found = This Link has not been found or has been deleted not-found = This Link has not been found or has been deleted
load-more-links = load more links
no-more-links = there are no additional links available
edit-link-headline = Edit link: {$linktitle} edit-link-headline = Edit link: {$linktitle}
edit-link = Edit link edit-link = Edit link
create-link = Create link create-link = Create link

View File

@ -131,7 +131,7 @@ impl UserDbOperations<Self> for User {
#[instrument()] #[instrument()]
async fn toggle_admin(self, server_config: &ServerConfig) -> Result<(), ServerError> { async fn toggle_admin(self, server_config: &ServerConfig) -> Result<(), ServerError> {
let new_role = match self.role { let new_role = match self.role {
r @ Role::NotAuthenticated | r @ Role::Disabled => r, r @ (Role::NotAuthenticated | Role::Disabled) => r,
Role::Regular => Role::Admin, Role::Regular => Role::Admin,
Role::Admin => Role::Regular, Role::Admin => Role::Regular,
}; };

View File

@ -113,6 +113,7 @@ pub async fn list_all_allowed(
querystring.push_str(&generate_order_sql(&order)); querystring.push_str(&generate_order_sql(&order));
} }
querystring.push_str(&format!("\n LIMIT {}", parameters.amount)); querystring.push_str(&format!("\n LIMIT {}", parameters.amount));
querystring.push_str(&format!("\n OFFSET {}", parameters.offset));
info!("{}", querystring); info!("{}", querystring);
let links = sqlx::query(&querystring) let links = sqlx::query(&querystring)

View File

@ -8,21 +8,23 @@ form {
} }
.center { .center {
position: absolute; position: fixed;
width: 600px; width: 600px;
max-width: 100%; max-width: 100%;
height: 400px; height: 500px;
max-height: 100%; max-height: 100%;
top: 50%; top: 50%;
left: 50%; left: 50%;
margin-left: -300px; margin-left: -300px;
margin-top: -200px; margin-top: -250px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding: 30px; padding: 30px;
color: #333; color: #333;
overflow-y: scroll;
scrollbar-width: none;
} }
.center input { .center input {
@ -230,3 +232,13 @@ img.trashicon {
margin: 3px; margin: 3px;
border-radius: 50%; border-radius: 50%;
} }
a.loadmore {
display: flex;
color: darkgray;
margin: auto;
justify-content: space-evenly;
align-items: center;
border: none;
border-radius: 15px;
}

3
pslink/static/reload.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 12.7 12.7" xmlns="http://www.w3.org/2000/svg">
<path transform="rotate(220.03)" d="m-6.5691-4.5466c-1.7227-1.0829-3.96-0.86298-5.4388 0.53473-1.4788 1.3977-1.8246 3.619-0.84044 5.4 0.98412 1.781 3.0487 2.6705 5.019 2.1624 1.9704-0.50816 3.3472-2.2852 3.3472-4.32l-1.902 1.7128 1.902-1.7128 0.72362 2.5702" style="fill:none;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.1607;stroke:#a8a8a8"/>
</svg>

After

Width:  |  Height:  |  Size: 467 B

View File

@ -12,6 +12,7 @@ use super::general::{EditMode, Filter, Operation, Ordering};
pub struct LinkRequestForm { pub struct LinkRequestForm {
pub filter: EnumMap<LinkOverviewColumns, Filter>, pub filter: EnumMap<LinkOverviewColumns, Filter>,
pub order: Option<Operation<LinkOverviewColumns, Ordering>>, pub order: Option<Operation<LinkOverviewColumns, Ordering>>,
pub offset: usize,
pub amount: usize, pub amount: usize,
} }
@ -20,7 +21,8 @@ impl Default for LinkRequestForm {
Self { Self {
filter: EnumMap::default(), filter: EnumMap::default(),
order: None, order: None,
amount: 20, offset: 0,
amount: 60,
} }
} }
} }