Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
use super::{CloudObject, Space};
|
||||
use crate::{
|
||||
drive::{folders::CloudFolder, items::WarpDriveItemId, CloudObjectTypeAndId},
|
||||
ui_components::breadcrumb::Breadcrumb,
|
||||
};
|
||||
use warpui::AppContext;
|
||||
|
||||
// Encapsulates an object that can contain other objects, and keeps
|
||||
// information necessary to show breadcrumbs.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ContainingObject {
|
||||
pub name: String,
|
||||
pub kind: ContainingObjectKind,
|
||||
}
|
||||
|
||||
impl Breadcrumb for ContainingObject {
|
||||
fn label(&self) -> String {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn enabled(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&CloudFolder> for ContainingObject {
|
||||
fn from(folder: &CloudFolder) -> Self {
|
||||
Self {
|
||||
name: folder.display_name().clone(),
|
||||
kind: ContainingObjectKind::Object(CloudObjectTypeAndId::Folder(folder.id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Space {
|
||||
pub fn into_containing_object(self, app: &AppContext) -> ContainingObject {
|
||||
ContainingObject {
|
||||
name: self.name(app).clone(),
|
||||
kind: ContainingObjectKind::Space(self),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum ContainingObjectKind {
|
||||
Space(Space),
|
||||
Object(CloudObjectTypeAndId),
|
||||
}
|
||||
|
||||
impl ContainingObjectKind {
|
||||
pub fn into_item_id(self) -> WarpDriveItemId {
|
||||
match self {
|
||||
ContainingObjectKind::Space(space) => WarpDriveItemId::Space(space),
|
||||
ContainingObjectKind::Object(object) => WarpDriveItemId::Object(object),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
use warpui::fonts::{Properties, Style, Weight};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::ui_components::button::ButtonVariant;
|
||||
use warpui::{elements::Element, AppContext, View};
|
||||
use warpui::{Entity, SingletonEntity, TypedActionView, ViewContext};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::ui_components::buttons::close_button;
|
||||
use crate::ui_components::dialog::{dialog_styles, Dialog};
|
||||
use warpui::elements::{Container, MouseStateHandle, Text};
|
||||
use warpui::ui_components::components::UiComponent;
|
||||
|
||||
const EDIT_ANYWAY_CTA_LABEL: &str = "Edit anyway";
|
||||
const CANCEL_CTA_LABEL: &str = "Cancel";
|
||||
const EDIT_ANYWAY_TEXT: &str =
|
||||
"If you take edit controls, the current editor will be forced into view mode";
|
||||
const CURRENTLY_EDITED_LABEL: &str = "This notebook is currently being edited";
|
||||
|
||||
#[derive(Default)]
|
||||
struct MouseStateHandles {
|
||||
close_button: MouseStateHandle,
|
||||
edit_anyway_button: MouseStateHandle,
|
||||
cancel_button: MouseStateHandle,
|
||||
}
|
||||
|
||||
pub struct GrabEditAccessModal {
|
||||
mouse_state_handles: MouseStateHandles,
|
||||
}
|
||||
|
||||
impl Default for GrabEditAccessModal {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl GrabEditAccessModal {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
mouse_state_handles: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close(&self, ctx: &mut ViewContext<Self>) {
|
||||
ctx.emit(GrabEditAccessModalEvent::Close);
|
||||
}
|
||||
|
||||
pub fn grab_edit_access(&self, ctx: &mut ViewContext<Self>) {
|
||||
// TODO @ianhodge actually make the call to grab access on the server
|
||||
ctx.emit(GrabEditAccessModalEvent::GrabEditAccess);
|
||||
}
|
||||
|
||||
pub fn render_modal(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let ui_builder = appearance.ui_builder();
|
||||
|
||||
let description = Text::new(EDIT_ANYWAY_TEXT, appearance.ui_font_family(), 13.)
|
||||
.with_style(Properties {
|
||||
style: Style::Normal,
|
||||
weight: Weight::Bold,
|
||||
})
|
||||
.with_color(theme.active_ui_text_color().into())
|
||||
.finish();
|
||||
|
||||
let close_button = close_button(appearance, self.mouse_state_handles.close_button.clone())
|
||||
.build()
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(GrabEditAccessModalAction::Close))
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish();
|
||||
|
||||
Dialog::new(
|
||||
CURRENTLY_EDITED_LABEL.to_string(),
|
||||
None,
|
||||
dialog_styles(appearance),
|
||||
)
|
||||
.with_close_button(close_button)
|
||||
.with_child(description)
|
||||
.with_bottom_row_child(
|
||||
Container::new(
|
||||
ui_builder
|
||||
.button(
|
||||
ButtonVariant::Basic,
|
||||
self.mouse_state_handles.cancel_button.clone(),
|
||||
)
|
||||
.with_text_label(CANCEL_CTA_LABEL.to_string())
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(GrabEditAccessModalAction::Close)
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_right(5.)
|
||||
.finish(),
|
||||
)
|
||||
.with_bottom_row_child(
|
||||
ui_builder
|
||||
.button(
|
||||
ButtonVariant::Warn,
|
||||
self.mouse_state_handles.edit_anyway_button.clone(),
|
||||
)
|
||||
.with_text_label(EDIT_ANYWAY_CTA_LABEL.to_string())
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(GrabEditAccessModalAction::GrabEditAccess)
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish(),
|
||||
)
|
||||
.build()
|
||||
.on_dismiss(|ctx, _app| ctx.dispatch_typed_action(GrabEditAccessModalAction::Close))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for GrabEditAccessModal {
|
||||
type Event = GrabEditAccessModalEvent;
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub enum GrabEditAccessModalEvent {
|
||||
Close,
|
||||
GrabEditAccess,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum GrabEditAccessModalAction {
|
||||
Close,
|
||||
GrabEditAccess,
|
||||
}
|
||||
|
||||
impl TypedActionView for GrabEditAccessModal {
|
||||
type Action = GrabEditAccessModalAction;
|
||||
|
||||
fn handle_action(&mut self, action: &GrabEditAccessModalAction, ctx: &mut ViewContext<Self>) {
|
||||
use GrabEditAccessModalAction::*;
|
||||
|
||||
match action {
|
||||
Close => self.close(ctx),
|
||||
GrabEditAccess => self.grab_edit_access(ctx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl View for GrabEditAccessModal {
|
||||
fn ui_name() -> &'static str {
|
||||
"GrabEditAccessModal"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
self.render_modal(appearance)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,478 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use warpui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
persistence::model::PersistedObjectAction,
|
||||
server::ids::{parse_sqlite_id_to_uid, HashedSqliteId, ObjectUid},
|
||||
};
|
||||
|
||||
pub enum ObjectActionsEvent {}
|
||||
|
||||
/// The type of action that occurred on an object, such as an execution, selection, so on
|
||||
/// and so forth.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ObjectActionType {
|
||||
Execute,
|
||||
}
|
||||
|
||||
// In order to convert from a graphql type and from a SQLite read, the action type
|
||||
// implements to_string().
|
||||
//
|
||||
// Temporarily suppress clippy warnings about the `ToString` impl until we
|
||||
// move `ObjectType` away from using `std::fmt::Display` for serialization.
|
||||
#[allow(clippy::to_string_trait_impl)]
|
||||
impl ToString for ObjectActionType {
|
||||
fn to_string(&self) -> String {
|
||||
match self {
|
||||
ObjectActionType::Execute => String::from("EXECUTE"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectActionType {
|
||||
fn singular(&self) -> String {
|
||||
match self {
|
||||
ObjectActionType::Execute => "run".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn plural(&self) -> String {
|
||||
match self {
|
||||
ObjectActionType::Execute => "runs".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// We track object actions, both those that have been sent to the server and not, through this
|
||||
/// type. A single ObjectAction represents an object_id, action pair and a subtype that contains data
|
||||
/// about the action(s). Each ObjectAction either represents one action or a summary of identical actions
|
||||
/// that occurred at different times. We summarize old actions in order to save memory footprint on the client.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ObjectAction {
|
||||
pub action_type: ObjectActionType,
|
||||
pub uid: ObjectUid,
|
||||
pub hashed_sqlite_id: HashedSqliteId,
|
||||
// This action either represents one action or a consolidation of multiple actions.
|
||||
pub action_subtype: ObjectActionSubtype,
|
||||
}
|
||||
|
||||
impl ObjectAction {
|
||||
pub fn is_pending(&self) -> bool {
|
||||
match self.action_subtype {
|
||||
ObjectActionSubtype::SingleAction { pending, .. } => pending,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<PersistedObjectAction> for ObjectAction {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(other: PersistedObjectAction) -> Result<Self, Self::Error> {
|
||||
// Each persisted object action is either a single action or a bundled action.
|
||||
// If there's any inconsistencies from the SQL row, we return an error.
|
||||
let action_subtype = if let Some(count) = other.count {
|
||||
let oldest_timestamp = other
|
||||
.oldest_timestamp
|
||||
.as_ref()
|
||||
.map(|time| time.and_utc())
|
||||
.ok_or(())?;
|
||||
let latest_timestamp = other
|
||||
.latest_timestamp
|
||||
.as_ref()
|
||||
.map(|time| time.and_utc())
|
||||
.ok_or(())?;
|
||||
|
||||
// When the db row is a bundled action, the processed_at_timestamp field refers
|
||||
// to the latest processed_at_timestamp in the bundle. Because bundled actions come
|
||||
// from the server, this is a value, not an option.
|
||||
let latest_processed_at_timestamp = other
|
||||
.processed_at_timestamp
|
||||
.as_ref()
|
||||
.map(|time| time.and_utc())
|
||||
.ok_or(())?;
|
||||
ObjectActionSubtype::BundledActions {
|
||||
count,
|
||||
oldest_timestamp,
|
||||
latest_timestamp,
|
||||
latest_processed_at_timestamp,
|
||||
}
|
||||
} else {
|
||||
let timestamp = other
|
||||
.timestamp
|
||||
.as_ref()
|
||||
.map(|time| time.and_utc())
|
||||
.ok_or(())?;
|
||||
let pending = other.pending.ok_or(())?;
|
||||
|
||||
// The processed_at_timestamp is still None when the action hasn't been synced.
|
||||
let processed_at_timestamp = other
|
||||
.processed_at_timestamp
|
||||
.as_ref()
|
||||
.map(|time| time.and_utc());
|
||||
ObjectActionSubtype::SingleAction {
|
||||
timestamp,
|
||||
data: other.data,
|
||||
pending,
|
||||
processed_at_timestamp,
|
||||
}
|
||||
};
|
||||
|
||||
// The object_sync_id stored in SQLite is the hashed id that's used to index into the ObjectActions
|
||||
// model.
|
||||
let hashed_object_id = other.hashed_object_id;
|
||||
let action_type = match other.action.as_str() {
|
||||
s if s == ObjectActionType::Execute.to_string() => ObjectActionType::Execute,
|
||||
_ => return Err(()),
|
||||
};
|
||||
|
||||
// NOTE: This is needed since we only store the sqlite hash, but we need the uid (the second part of the hash)
|
||||
// to index into CloudModel and store the object actions in memory.
|
||||
let uid = parse_sqlite_id_to_uid(hashed_object_id.clone())?;
|
||||
|
||||
Ok(ObjectAction {
|
||||
uid: uid.to_string(),
|
||||
hashed_sqlite_id: hashed_object_id,
|
||||
action_type,
|
||||
action_subtype,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The server communicates the action history of an object via an "ObjectActionHistory" type that
|
||||
/// contains the uid, a list of actions (single or bundled), and the timestamp of the most recent action
|
||||
/// (which is redundant from the list of actions). We use this type to convert from the graphql layer into
|
||||
/// an identical type the sync_queue and update_manager can pass around.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ObjectActionHistory {
|
||||
pub uid: ObjectUid,
|
||||
pub hashed_sqlite_id: HashedSqliteId,
|
||||
pub latest_processed_at_timestamp: DateTime<Utc>,
|
||||
pub actions: Vec<ObjectAction>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ObjectActionSubtype {
|
||||
SingleAction {
|
||||
// When the action occurred.
|
||||
timestamp: DateTime<Utc>,
|
||||
|
||||
// When the action was processed by the server (used to order actions against eachother).
|
||||
// None if the action has not been synced.
|
||||
processed_at_timestamp: Option<DateTime<Utc>>,
|
||||
|
||||
// A JSON representation of anything else we might want to track about the action.
|
||||
// For example, the exit code of a workflow execution.
|
||||
data: Option<String>,
|
||||
|
||||
// Whether or not this action has been successfully synced to the server.
|
||||
pending: bool,
|
||||
},
|
||||
BundledActions {
|
||||
// The number of distinct actions that are coalesced into one entry here.
|
||||
count: i32,
|
||||
|
||||
// The timestamp of the oldest action within this bundle.
|
||||
oldest_timestamp: DateTime<Utc>,
|
||||
|
||||
// The timestamp of the most recent action within the bundle.
|
||||
latest_timestamp: DateTime<Utc>,
|
||||
|
||||
// The most recent processed_at timestamp contained in the bundle (used to order actions and determine
|
||||
// how up-to-date the client's actions are.)
|
||||
latest_processed_at_timestamp: DateTime<Utc>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A singleton model representing the actions that have occurred on a per-object basis. These
|
||||
/// represent actions taken by the user or by teammates. The actions have a pending status that is
|
||||
/// true when the server doesn't know about it and is false anytime after the action is successfully
|
||||
/// synced.
|
||||
pub struct ObjectActions {
|
||||
#[allow(dead_code)]
|
||||
object_actions_by_id: HashMap<ObjectUid, Vec<ObjectAction>>,
|
||||
}
|
||||
|
||||
impl ObjectActions {
|
||||
/// Accepts a vector of object actions read out of SQLite.
|
||||
pub fn new(persisted_actions: Vec<ObjectAction>) -> Self {
|
||||
// Partitions the actions by object id and plops them into the map.
|
||||
let object_actions_by_id = persisted_actions.into_iter().fold(
|
||||
HashMap::new(),
|
||||
|mut map: HashMap<ObjectUid, Vec<ObjectAction>>, object_action| {
|
||||
map.entry(object_action.uid.clone())
|
||||
.or_default()
|
||||
.push(object_action);
|
||||
map
|
||||
},
|
||||
);
|
||||
|
||||
Self {
|
||||
object_actions_by_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a single action into the model. Returns the created action.
|
||||
pub fn insert_action(
|
||||
&mut self,
|
||||
uid: ObjectUid,
|
||||
hashed_sqlite_id: HashedSqliteId,
|
||||
action_type: ObjectActionType,
|
||||
data: Option<String>,
|
||||
timestamp: DateTime<Utc>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> ObjectAction {
|
||||
// Create an action with pending=true.
|
||||
let action = ObjectAction {
|
||||
action_type,
|
||||
uid: uid.clone(),
|
||||
hashed_sqlite_id,
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp,
|
||||
data,
|
||||
pending: true,
|
||||
processed_at_timestamp: None,
|
||||
},
|
||||
};
|
||||
|
||||
// Insert the action into the model.
|
||||
self.object_actions_by_id
|
||||
.entry(uid)
|
||||
.or_default()
|
||||
.push(action.clone());
|
||||
|
||||
ctx.notify();
|
||||
|
||||
action
|
||||
}
|
||||
|
||||
/// Remove the action from the model with the corresponding object_id, timestamp, and pending=true.
|
||||
pub fn remove_pending_action(
|
||||
&mut self,
|
||||
uid: &ObjectUid,
|
||||
timestamp_of_action: &DateTime<Utc>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if let Some(actions) = self.object_actions_by_id.get_mut(uid) {
|
||||
// Remove the action that has a matching timestamp and pending=true
|
||||
if let Some(index) = actions.iter().position(|a| {
|
||||
matches!(
|
||||
&a.action_subtype,
|
||||
ObjectActionSubtype::SingleAction {
|
||||
timestamp,
|
||||
pending: true,
|
||||
..
|
||||
} if timestamp == timestamp_of_action
|
||||
)
|
||||
}) {
|
||||
actions.remove(index);
|
||||
} else {
|
||||
log::warn!(
|
||||
"Could not find the pending action to remove from the ObjectActions model"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
log::warn!("Could not find the object id in the ObjectActions model")
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Get the processed_at_timestamp of the most recent server-synced action we have for a given object. This determines
|
||||
/// whether or not we should accept some update from the server.
|
||||
pub fn get_latest_processed_at_ts(&self, uid: &ObjectUid) -> Option<DateTime<Utc>> {
|
||||
if let Some(actions) = self.object_actions_by_id.get(uid) {
|
||||
actions
|
||||
.iter()
|
||||
.filter_map(|a| match a.action_subtype {
|
||||
ObjectActionSubtype::SingleAction {
|
||||
processed_at_timestamp,
|
||||
pending: false,
|
||||
..
|
||||
} => processed_at_timestamp,
|
||||
ObjectActionSubtype::BundledActions {
|
||||
latest_processed_at_timestamp,
|
||||
..
|
||||
} => Some(latest_processed_at_timestamp),
|
||||
_ => None,
|
||||
})
|
||||
.max()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes a list of ObjectActions for a single object from the server and replaces the existing actions
|
||||
/// for this object with the new ones. Any pending actions are persisted so we make sure we don't delete actions
|
||||
/// that are currently in the process of syncing.
|
||||
pub fn overwrite_action_history_for_object(
|
||||
&mut self,
|
||||
uid: &ObjectUid,
|
||||
mut actions: Vec<ObjectAction>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// Get the pending actions out of the old set.
|
||||
let old_pending_actions: Vec<ObjectAction> = self
|
||||
.object_actions_by_id
|
||||
.get(uid)
|
||||
.map(|actions| {
|
||||
actions
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
matches!(
|
||||
a.action_subtype,
|
||||
ObjectActionSubtype::SingleAction { pending: true, .. }
|
||||
)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
actions.extend(old_pending_actions);
|
||||
|
||||
self.object_actions_by_id
|
||||
.insert(uid.to_string(), actions.clone());
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Returns a time-boxed summary of the number of times this action type has occurred on this object.
|
||||
/// This summary prioritizes smaller units of time where possible, starting from Day and going to Year.
|
||||
/// If the action type has occurred on the object in the last day, we return "X actions in the last day".
|
||||
/// If not, we increase the time unit from Day to Week to Month. If no actions have occurred in the last month,
|
||||
/// we return however many actions have occurred in the last year, possibly 0.
|
||||
///
|
||||
/// This function operates by cloning a filtered Iterator<Item=&ObjectAction>, saving some performance overhead
|
||||
/// by cloning references instead of objects.
|
||||
pub fn get_action_history_summary_for_action_type(
|
||||
&self,
|
||||
uid: &ObjectUid,
|
||||
action_type: ObjectActionType,
|
||||
) -> Option<String> {
|
||||
// If the object is not in the model, return 0.
|
||||
let all_actions_on_this_object = self.object_actions_by_id.get(uid);
|
||||
if all_actions_on_this_object.is_none() {
|
||||
return Some("0 runs in the last year".to_string());
|
||||
}
|
||||
|
||||
// If the object doesn't have any of these action types recorded, return 0.
|
||||
let all_relevant_actions = all_actions_on_this_object?
|
||||
.iter()
|
||||
.filter(|a| a.action_type == action_type);
|
||||
if all_relevant_actions.clone().count() == 0 {
|
||||
return Some("0 runs in the last year".to_string());
|
||||
}
|
||||
|
||||
// If the action has occurred in the last day, return Day as the time unit.
|
||||
let one_day_ago = Utc::now() - Duration::days(1);
|
||||
let in_the_last_day = all_relevant_actions.clone().filter(|a| matches!(a.action_subtype, ObjectActionSubtype::SingleAction { timestamp, .. } if timestamp > one_day_ago)).count();
|
||||
if in_the_last_day > 0 {
|
||||
return Some(format!(
|
||||
"{} {} in the last day",
|
||||
in_the_last_day,
|
||||
if in_the_last_day == 1 {
|
||||
action_type.singular()
|
||||
} else {
|
||||
action_type.plural()
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
// If the action has occurred in the last week, return Week as the time unit.
|
||||
let one_week_ago = Utc::now() - Duration::days(7);
|
||||
let in_the_last_week = all_relevant_actions.clone().filter(|a| matches!(a.action_subtype, ObjectActionSubtype::SingleAction { timestamp, .. } if timestamp > one_week_ago)).count();
|
||||
if in_the_last_week > 0 {
|
||||
return Some(format!(
|
||||
"{} {} in the last week",
|
||||
in_the_last_week,
|
||||
if in_the_last_week == 1 {
|
||||
action_type.singular()
|
||||
} else {
|
||||
action_type.plural()
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
// If the action has occurred in the last month, return Month as the time unit.
|
||||
let one_month_ago = Utc::now() - Duration::days(30);
|
||||
let in_the_last_month = all_relevant_actions.clone().filter(|a| matches!(a.action_subtype, ObjectActionSubtype::SingleAction { timestamp, .. } if timestamp > one_month_ago)).count();
|
||||
if in_the_last_month > 0 {
|
||||
return Some(format!(
|
||||
"{} {} in the last month",
|
||||
in_the_last_month,
|
||||
if in_the_last_month == 1 {
|
||||
action_type.singular()
|
||||
} else {
|
||||
action_type.plural()
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
// Finally, if all else turned up fruitless, return the yearly count.
|
||||
let one_year_ago = Utc::now() - Duration::days(365);
|
||||
let in_the_last_year: i32 = all_relevant_actions
|
||||
.clone()
|
||||
.filter_map(|a| match a.action_subtype {
|
||||
ObjectActionSubtype::SingleAction { timestamp, .. } if timestamp > one_year_ago => {
|
||||
Some(1)
|
||||
}
|
||||
ObjectActionSubtype::BundledActions {
|
||||
count,
|
||||
oldest_timestamp,
|
||||
..
|
||||
} if oldest_timestamp > one_year_ago => Some(count),
|
||||
_ => None,
|
||||
})
|
||||
.sum();
|
||||
|
||||
Some(format!(
|
||||
"{} {} in the last year",
|
||||
in_the_last_year,
|
||||
if in_the_last_year == 1 {
|
||||
action_type.singular()
|
||||
} else {
|
||||
action_type.plural()
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
/// Returns all the actions on the objects specified by the parameter hashed_object_ids.
|
||||
/// The return value is a HashMap, which represents a subset of the model, filtered to just the actions
|
||||
/// that occurred on the requested objects.
|
||||
pub fn get_actions_for_objects(
|
||||
&self,
|
||||
uids: Vec<&ObjectUid>,
|
||||
) -> HashMap<ObjectUid, Vec<ObjectAction>> {
|
||||
uids.iter()
|
||||
.map(|&uid| {
|
||||
let actions_on_this_object = self
|
||||
.object_actions_by_id
|
||||
.get(uid)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
(uid.clone(), actions_on_this_object)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn delete_actions_for_object(&mut self, uid: &ObjectUid, ctx: &mut ModelContext<Self>) {
|
||||
self.object_actions_by_id.remove(uid);
|
||||
ctx.notify()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn count_actions_for_object(&mut self, uid: &ObjectUid) -> usize {
|
||||
self.object_actions_by_id.get(uid).map_or(0, |v| v.len())
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ObjectActions {
|
||||
type Event = ObjectActionsEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for ObjectActions {}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "actions_tests.rs"]
|
||||
pub mod tests;
|
||||
@@ -0,0 +1,565 @@
|
||||
use warpui::App;
|
||||
|
||||
use super::{ObjectAction, ObjectActionSubtype, ObjectActionType, ObjectActions};
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
|
||||
#[test]
|
||||
fn test_object_actions_daily() {
|
||||
App::test((), |mut app| async move {
|
||||
let actions: Vec<ObjectAction> = vec![
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::minutes(4),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::minutes(4)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::hours(23),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::hours(23)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::days(4),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::days(4)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::days(10),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::days(10)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::days(28),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::days(28)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::BundledActions {
|
||||
count: 5,
|
||||
oldest_timestamp: Utc::now() - Duration::days(250),
|
||||
latest_timestamp: Utc::now() - Duration::days(50),
|
||||
latest_processed_at_timestamp: Utc::now() - Duration::days(50),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let object_actions_handle = app.add_model(|_| ObjectActions::new(actions));
|
||||
|
||||
object_actions_handle.read(&app, |handle, _ctx| {
|
||||
assert_eq!(
|
||||
handle.get_action_history_summary_for_action_type(
|
||||
&"asdfljk".to_string(),
|
||||
ObjectActionType::Execute,
|
||||
),
|
||||
Some("2 runs in the last day".to_string())
|
||||
)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_object_actions_rollup_weekly() {
|
||||
App::test((), |mut app| async move {
|
||||
let actions: Vec<ObjectAction> = vec![
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::minutes(4),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::minutes(4)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::hours(23),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::hours(23)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "q23423aaf".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::days(4),
|
||||
processed_at_timestamp: Some(Utc::now()),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "q23423aaf".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::days(10),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::days(10)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::days(28),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::days(10)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "q23423aaf".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::BundledActions {
|
||||
count: 5,
|
||||
oldest_timestamp: Utc::now() - Duration::days(250),
|
||||
latest_timestamp: Utc::now() - Duration::days(50),
|
||||
latest_processed_at_timestamp: Utc::now() - Duration::days(50),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let object_actions_handle = app.add_model(|_| ObjectActions::new(actions));
|
||||
|
||||
object_actions_handle.read(&app, |handle, _ctx| {
|
||||
assert_eq!(
|
||||
handle.get_action_history_summary_for_action_type(
|
||||
&"q23423aaf".to_string(),
|
||||
ObjectActionType::Execute,
|
||||
),
|
||||
Some("1 run in the last week".to_string())
|
||||
)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_object_actions_rollup_monthly() {
|
||||
App::test((), |mut app| async move {
|
||||
let actions: Vec<ObjectAction> = vec![
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::minutes(4),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::minutes(4)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::hours(23),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::hours(23)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::days(4),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::days(4)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "q23423aaf".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::days(10),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::days(10)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "q23423aaf".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::days(15),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::days(15)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "q23423aaf".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::days(28),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::days(28)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "q23423aaf".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::BundledActions {
|
||||
count: 5,
|
||||
oldest_timestamp: Utc::now() - Duration::days(250),
|
||||
latest_timestamp: Utc::now() - Duration::days(50),
|
||||
latest_processed_at_timestamp: Utc::now() - Duration::days(50),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let object_actions_handle = app.add_model(|_| ObjectActions::new(actions));
|
||||
|
||||
object_actions_handle.read(&app, |handle, _ctx| {
|
||||
assert_eq!(
|
||||
handle.get_action_history_summary_for_action_type(
|
||||
&"q23423aaf".to_string(),
|
||||
ObjectActionType::Execute,
|
||||
),
|
||||
Some("3 runs in the last month".to_string())
|
||||
)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_object_actions_rollup_yearly() {
|
||||
App::test((), |mut app| async move {
|
||||
let actions: Vec<ObjectAction> = vec![
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::minutes(4),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::minutes(4)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::hours(23),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::hours(23)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::days(4),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::days(4)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::days(10),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::days(10)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::days(15),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::days(15)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::days(28),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::days(28)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "q23423aaf".to_string(),
|
||||
hashed_sqlite_id: "q23423aaf".to_string(),
|
||||
action_subtype: ObjectActionSubtype::BundledActions {
|
||||
count: 5,
|
||||
oldest_timestamp: Utc::now() - Duration::days(250),
|
||||
latest_timestamp: Utc::now() - Duration::days(50),
|
||||
latest_processed_at_timestamp: Utc::now() - Duration::days(50),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let object_actions_handle = app.add_model(|_| ObjectActions::new(actions));
|
||||
|
||||
object_actions_handle.read(&app, |handle, _ctx| {
|
||||
assert_eq!(
|
||||
handle.get_action_history_summary_for_action_type(
|
||||
&"q23423aaf".to_string(),
|
||||
ObjectActionType::Execute,
|
||||
),
|
||||
Some("5 runs in the last year".to_string())
|
||||
)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_object_actions_rollup_out_of_date_bundle() {
|
||||
App::test((), |mut app| async move {
|
||||
let actions: Vec<ObjectAction> = vec![
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::minutes(4),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::minutes(4)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::hours(23),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::hours(23)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::days(4),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::days(4)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::days(10),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::days(10)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::days(15),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::days(15)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::days(28),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::days(28)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "q23423aaf".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::BundledActions {
|
||||
count: 5,
|
||||
oldest_timestamp: Utc::now() - Duration::days(400),
|
||||
latest_timestamp: Utc::now() - Duration::days(350),
|
||||
latest_processed_at_timestamp: Utc::now() - Duration::days(350),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let object_actions_handle = app.add_model(|_| ObjectActions::new(actions));
|
||||
|
||||
object_actions_handle.read(&app, |handle, _ctx| {
|
||||
assert_eq!(
|
||||
handle.get_action_history_summary_for_action_type(
|
||||
&"q23423aaf".to_string(),
|
||||
ObjectActionType::Execute,
|
||||
),
|
||||
Some("0 runs in the last year".to_string())
|
||||
)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_object_actions_rollup_none() {
|
||||
App::test((), |mut app| async move {
|
||||
let actions: Vec<ObjectAction> = vec![
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::minutes(4),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::minutes(4)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::hours(23),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::hours(23)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::days(4),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::days(4)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::days(10),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::days(10)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::days(15),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::days(15)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::SingleAction {
|
||||
timestamp: Utc::now() - Duration::days(28),
|
||||
processed_at_timestamp: Some(Utc::now() - Duration::days(28)),
|
||||
data: Some("Some data".to_string()),
|
||||
pending: false,
|
||||
},
|
||||
},
|
||||
ObjectAction {
|
||||
action_type: ObjectActionType::Execute,
|
||||
uid: "asdfljk".to_string(),
|
||||
hashed_sqlite_id: "asdfljk".to_string(),
|
||||
action_subtype: ObjectActionSubtype::BundledActions {
|
||||
count: 5,
|
||||
oldest_timestamp: Utc::now() - Duration::days(400),
|
||||
latest_timestamp: Utc::now() - Duration::days(350),
|
||||
latest_processed_at_timestamp: Utc::now() - Duration::days(350),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let object_actions_handle = app.add_model(|_| ObjectActions::new(actions));
|
||||
|
||||
object_actions_handle.read(&app, |handle, _ctx| {
|
||||
assert_eq!(
|
||||
handle.get_action_history_summary_for_action_type(
|
||||
&"q23423aaf".to_string(),
|
||||
ObjectActionType::Execute,
|
||||
),
|
||||
Some("0 runs in the last year".to_string())
|
||||
)
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
use std::{fmt::Debug, sync::Arc};
|
||||
|
||||
use crate::server::cloud_objects::update_manager::InitiatedBy;
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
cloud_object::{
|
||||
CloudModelType, CloudObject, CloudObjectEventEntrypoint, CreateCloudObjectResult,
|
||||
CreateObjectRequest, GenericCloudObject, GenericServerObject, GenericStringObjectFormat,
|
||||
GenericStringObjectUniqueKey, ObjectType, Revision, ServerCloudObject,
|
||||
UpdateCloudObjectResult,
|
||||
},
|
||||
drive::{items::WarpDriveItem, CloudObjectTypeAndId},
|
||||
persistence::ModelEvent,
|
||||
server::{
|
||||
ids::{ObjectUid, ServerId, SyncId},
|
||||
server_api::object::ObjectClient,
|
||||
sync_queue::{QueueItem, SerializedModel},
|
||||
},
|
||||
};
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A trait that generic string-based objects should implement.
|
||||
pub trait CloudStringObject: CloudObject + Send + Sync {
|
||||
/// Returns the object format for this object.
|
||||
fn generic_string_object_format(&self) -> GenericStringObjectFormat;
|
||||
|
||||
/// Returns the id for this specific object.
|
||||
fn id(&self) -> SyncId;
|
||||
|
||||
/// Returns a serialized model from this string object.
|
||||
fn serialized(&self) -> SerializedModel;
|
||||
|
||||
/// Returns a cloned boxed version of this cloud object.
|
||||
/// Note that we can't force this trait to derive from Cloned
|
||||
/// directly because that would make the trait not object safe. This
|
||||
/// is a workaround.
|
||||
fn clone_box(&self) -> Box<dyn CloudStringObject>;
|
||||
}
|
||||
|
||||
/// A `StringModel` is a model that can be serialized and deserialized as a simple string.
|
||||
///
|
||||
/// Any model that has a simple string representation (e.g. JSON, markdown, yaml) that can be atomically updated
|
||||
/// can implement this trait and get most cloud object functionality for free.
|
||||
///
|
||||
/// Objects that implement this type all share common storage and server apis.
|
||||
pub trait StringModel: Clone + Debug + PartialEq + Send + Sync + 'static {
|
||||
type CloudObjectType: CloudObject + 'static;
|
||||
|
||||
/// Returns the name of this model type (e.g. Workflow, Folder, Notebook)
|
||||
fn model_type_name(&self) -> &'static str;
|
||||
|
||||
/// Whether we should enforce revisions for this model type.
|
||||
/// If revisions are not enforced, updates will have last-write-wins semantics.
|
||||
/// If revisions are enforced, the object will need to add logic to
|
||||
/// the update manager for how conflicts are resolved.
|
||||
fn should_enforce_revisions() -> bool;
|
||||
|
||||
/// Returns the serialization format for this model.
|
||||
fn model_format() -> GenericStringObjectFormat;
|
||||
|
||||
/// Whether to show update toasts for this type of model.
|
||||
fn should_show_activity_toasts() -> bool;
|
||||
|
||||
/// Whether to show a warning if this type of model is unsaved at quit time
|
||||
/// (which typically blocks the user from quitting)
|
||||
fn warn_if_unsaved_at_quit() -> bool;
|
||||
|
||||
/// Returns the display name for this model.
|
||||
fn display_name(&self) -> String;
|
||||
|
||||
/// Returns whether to render this model as a WarpDriveItem.
|
||||
fn renders_in_warp_drive(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Returns whether this model can be exported to a file
|
||||
fn can_export(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Returns whether this model can be shared via a link
|
||||
fn supports_linking(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Sets the display name for this model
|
||||
fn set_display_name(&mut self, _name: &str) {}
|
||||
|
||||
/// Creates a new warp drive item for this model type. Returns None
|
||||
/// if this object does not render in Warp Drive.
|
||||
fn to_warp_drive_item(
|
||||
&self,
|
||||
_id: SyncId,
|
||||
_appearance: &Appearance,
|
||||
_object: &Self::CloudObjectType,
|
||||
) -> Option<Box<dyn WarpDriveItem>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns a sync queue item of this object that would allow it to be updated
|
||||
/// properly on the server. Takes an optional revision_ts to set as the revision
|
||||
/// in the sync queue item.
|
||||
fn update_object_queue_item(
|
||||
&self,
|
||||
revision_ts: Option<Revision>,
|
||||
object: &Self::CloudObjectType,
|
||||
) -> QueueItem;
|
||||
|
||||
/// Returns a new instance from a server update, or None if the update should be ignored.
|
||||
fn new_from_server_update(&self, server_cloud_object: &ServerCloudObject) -> Option<Self>;
|
||||
|
||||
/// Returns whether this model type should clear on a unique key conflict.
|
||||
fn should_clear_on_unique_key_conflict(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Returns a unique key for this object, if one exists. Unique keys are used
|
||||
/// to enforce that only one object with a given key can exist in the generic string
|
||||
/// object server database.
|
||||
fn uniqueness_key(&self) -> Option<GenericStringObjectUniqueKey>;
|
||||
}
|
||||
|
||||
/// A serializer goes from a model to a string and back.
|
||||
pub trait Serializer<M>: Debug + Clone + 'static {
|
||||
fn serialize(model: &M) -> SerializedModel;
|
||||
fn deserialize_owned(serialized: &str) -> Result<M>
|
||||
where
|
||||
Self: Sized;
|
||||
}
|
||||
|
||||
/// A `GenericStringModel` is a generic implementation of model types that can serialize to/from string.
|
||||
/// given a particular serializer.
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct GenericStringModel<M, S>
|
||||
where
|
||||
M: StringModel<
|
||||
CloudObjectType = GenericCloudObject<GenericStringObjectId, GenericStringModel<M, S>>,
|
||||
>,
|
||||
S: Serializer<M>,
|
||||
{
|
||||
pub string_model: M,
|
||||
}
|
||||
|
||||
impl<M, S> CloudStringObject for GenericCloudObject<GenericStringObjectId, GenericStringModel<M, S>>
|
||||
where
|
||||
M: StringModel<
|
||||
CloudObjectType = GenericCloudObject<GenericStringObjectId, GenericStringModel<M, S>>,
|
||||
>,
|
||||
S: Serializer<M>,
|
||||
{
|
||||
fn generic_string_object_format(&self) -> GenericStringObjectFormat {
|
||||
M::model_format()
|
||||
}
|
||||
|
||||
fn id(&self) -> SyncId {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn serialized(&self) -> SerializedModel {
|
||||
self.model.serialized()
|
||||
}
|
||||
|
||||
fn clone_box(&self) -> Box<dyn CloudStringObject> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Implements the CloudModelType trait for all generic string models.
|
||||
///
|
||||
/// This has common logic for storing string models to SQLite, sending them to the server
|
||||
/// updating from the server -- basically for anything not specific to the contents
|
||||
/// of the string model.
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
impl<M, S> CloudModelType for GenericStringModel<M, S>
|
||||
where
|
||||
M: StringModel<
|
||||
CloudObjectType = GenericCloudObject<GenericStringObjectId, GenericStringModel<M, S>>,
|
||||
>,
|
||||
S: Serializer<M>,
|
||||
{
|
||||
type CloudObjectType = GenericCloudObject<GenericStringObjectId, Self>;
|
||||
type IdType = GenericStringObjectId;
|
||||
|
||||
fn model_type_name(&self) -> &'static str {
|
||||
self.string_model.model_type_name()
|
||||
}
|
||||
|
||||
fn object_type(&self) -> ObjectType {
|
||||
ObjectType::GenericStringObject(M::model_format())
|
||||
}
|
||||
|
||||
fn cloud_object_type_and_id(&self, id: SyncId) -> CloudObjectTypeAndId {
|
||||
CloudObjectTypeAndId::GenericStringObject {
|
||||
object_type: M::model_format(),
|
||||
id,
|
||||
}
|
||||
}
|
||||
|
||||
fn display_name(&self) -> String {
|
||||
self.string_model.display_name()
|
||||
}
|
||||
|
||||
fn set_display_name(&mut self, name: &str) {
|
||||
self.string_model.set_display_name(name);
|
||||
}
|
||||
|
||||
fn upsert_event(&self, object: &GenericCloudObject<GenericStringObjectId, Self>) -> ModelEvent {
|
||||
let object = object as &dyn CloudStringObject;
|
||||
ModelEvent::UpsertGenericStringObject {
|
||||
object: CloudStringObject::clone_box(object),
|
||||
}
|
||||
}
|
||||
|
||||
fn supports_linking(&self) -> bool {
|
||||
self.string_model.supports_linking()
|
||||
}
|
||||
|
||||
fn should_show_activity_toasts(&self) -> bool {
|
||||
M::should_show_activity_toasts()
|
||||
}
|
||||
|
||||
fn warn_if_unsaved_at_quit(&self) -> bool {
|
||||
M::warn_if_unsaved_at_quit()
|
||||
}
|
||||
|
||||
fn can_export(&self) -> bool {
|
||||
self.string_model.can_export()
|
||||
}
|
||||
|
||||
fn bulk_upsert_event(
|
||||
objects: &[GenericCloudObject<GenericStringObjectId, Self>],
|
||||
) -> ModelEvent {
|
||||
ModelEvent::UpsertGenericStringObjects(
|
||||
objects.iter().map(CloudStringObject::clone_box).collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn create_object_queue_item(
|
||||
&self,
|
||||
object: &GenericCloudObject<GenericStringObjectId, Self>,
|
||||
entrypoint: CloudObjectEventEntrypoint,
|
||||
initiated_by: InitiatedBy,
|
||||
) -> Option<QueueItem> {
|
||||
if let SyncId::ClientId(client_id) = object.id {
|
||||
return Some(QueueItem::CreateObject {
|
||||
object_type: self.object_type(),
|
||||
owner: object.permissions.owner,
|
||||
id: client_id,
|
||||
title: None,
|
||||
serialized_model: Some(object.model.serialized().into()),
|
||||
initial_folder_id: object.metadata.folder_id,
|
||||
entrypoint,
|
||||
initiated_by,
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn update_object_queue_item(
|
||||
&self,
|
||||
revision_ts: Option<Revision>,
|
||||
object: &GenericCloudObject<GenericStringObjectId, Self>,
|
||||
) -> QueueItem {
|
||||
self.string_model
|
||||
.update_object_queue_item(revision_ts, object)
|
||||
}
|
||||
|
||||
fn should_clear_on_unique_key_conflict(&self) -> bool {
|
||||
self.string_model.should_clear_on_unique_key_conflict()
|
||||
}
|
||||
|
||||
fn should_update_after_server_conflict(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn new_from_server_update(&self, server_cloud_object: &ServerCloudObject) -> Option<Self> {
|
||||
self.string_model
|
||||
.new_from_server_update(server_cloud_object)
|
||||
.map(Self::new)
|
||||
}
|
||||
|
||||
fn serialized(&self) -> SerializedModel {
|
||||
S::serialize(&self.string_model)
|
||||
}
|
||||
|
||||
async fn send_create_request(
|
||||
object_client: Arc<dyn ObjectClient>,
|
||||
request: CreateObjectRequest,
|
||||
) -> Result<CreateCloudObjectResult> {
|
||||
let model_as_str = request
|
||||
.serialized_model
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing serialized model"))?
|
||||
.model_as_str();
|
||||
let model = S::deserialize_owned(model_as_str)?;
|
||||
object_client
|
||||
.create_generic_string_object(M::model_format(), model.uniqueness_key(), request)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn send_update_request(
|
||||
&self,
|
||||
object_client: Arc<dyn ObjectClient>,
|
||||
server_id: ServerId,
|
||||
revision: Option<Revision>,
|
||||
) -> Result<UpdateCloudObjectResult<GenericServerObject<GenericStringObjectId, Self>>> {
|
||||
let revision =
|
||||
if M::should_enforce_revisions() {
|
||||
Some(revision.ok_or_else(|| {
|
||||
anyhow::anyhow!("Missing revision on update of generic object")
|
||||
})?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let res = object_client
|
||||
.update_generic_string_object(server_id.into(), self.serialized(), revision)
|
||||
.await;
|
||||
res.and_then(|update_result| match update_result {
|
||||
UpdateCloudObjectResult::Success {
|
||||
revision_and_editor,
|
||||
} => Ok(UpdateCloudObjectResult::Success {
|
||||
revision_and_editor,
|
||||
}),
|
||||
UpdateCloudObjectResult::Rejected { object } => {
|
||||
// Downcast to the concrete type to handle an update rejection (should be rare)
|
||||
let concrete_object: Option<&GenericServerObject<GenericStringObjectId, Self>> =
|
||||
(&object).into();
|
||||
let object = concrete_object
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to convert object to concrete type"))?;
|
||||
Ok(UpdateCloudObjectResult::Rejected { object })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn renders_in_warp_drive(&self) -> bool {
|
||||
self.string_model.renders_in_warp_drive()
|
||||
}
|
||||
|
||||
fn to_warp_drive_item(
|
||||
&self,
|
||||
id: SyncId,
|
||||
appearance: &Appearance,
|
||||
object: &GenericCloudObject<GenericStringObjectId, Self>,
|
||||
) -> Option<Box<dyn WarpDriveItem>> {
|
||||
self.string_model.to_warp_drive_item(id, appearance, object)
|
||||
}
|
||||
}
|
||||
|
||||
impl<M, S> GenericStringModel<M, S>
|
||||
where
|
||||
M: StringModel<
|
||||
CloudObjectType = GenericCloudObject<GenericStringObjectId, GenericStringModel<M, S>>,
|
||||
>,
|
||||
S: Serializer<M>,
|
||||
{
|
||||
pub fn deserialize_owned(serialized: &str) -> Result<Self> {
|
||||
S::deserialize_owned(serialized).map(Self::new)
|
||||
}
|
||||
|
||||
pub fn new(model: M) -> Self {
|
||||
Self {
|
||||
string_model: model,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn json_model(&self) -> &M {
|
||||
&self.string_model
|
||||
}
|
||||
}
|
||||
|
||||
/// Object id type that is common for all generic string objects.
|
||||
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||
pub struct GenericStringObjectId(ServerId);
|
||||
crate::server_id_traits! { GenericStringObjectId, "GenericStringObject" }
|
||||
|
||||
impl From<GenericStringObjectId> for SyncId {
|
||||
fn from(id: GenericStringObjectId) -> Self {
|
||||
Self::ServerId(id.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl GenericStringObjectId {
|
||||
pub fn uid(&self) -> ObjectUid {
|
||||
self.0.uid()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
|
||||
use crate::{cloud_object::JsonObjectType, server::sync_queue::SerializedModel};
|
||||
|
||||
use super::generic_string_model::{Serializer, StringModel};
|
||||
|
||||
/// A `JsonModel` is a string model that can be serialized to and deserialized from JSON.
|
||||
pub trait JsonModel: StringModel + Serialize + DeserializeOwned + 'static {
|
||||
/// Returns the JsonObjectType for this model.
|
||||
fn json_object_type() -> JsonObjectType;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct JsonSerializer;
|
||||
|
||||
impl<M: JsonModel> Serializer<M> for JsonSerializer {
|
||||
fn serialize(model: &M) -> SerializedModel {
|
||||
SerializedModel::new(serde_json::to_string(model).expect("model should serialize"))
|
||||
}
|
||||
|
||||
fn deserialize_owned(serialized: &str) -> anyhow::Result<M>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
Ok(serde_json::from_str(serialized)?)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod actions;
|
||||
pub mod generic_string_model;
|
||||
pub mod json_model;
|
||||
pub mod persistence;
|
||||
pub mod view;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,462 @@
|
||||
use std::{cell::RefCell, collections::HashMap};
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use warp_graphql::scalars::time::ServerTimestamp;
|
||||
use warpui::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
auth::{AuthStateProvider, UserUid},
|
||||
cloud_object::{CloudObject, CloudObjectLocation, Space},
|
||||
drive::{
|
||||
folders::CloudFolder,
|
||||
sharing::{ContentEditability, SharingAccessLevel},
|
||||
},
|
||||
safe_info,
|
||||
server::{
|
||||
cloud_objects::update_manager::{
|
||||
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
|
||||
},
|
||||
ids::{ObjectUid, SyncId},
|
||||
},
|
||||
workspaces::user_profiles::UserProfiles,
|
||||
};
|
||||
|
||||
use super::persistence::{CloudModel, CloudModelEvent};
|
||||
|
||||
pub const EDITOR_TIMEOUT_DURATION_MINUTES: i64 = 15;
|
||||
|
||||
#[derive(Default, Clone, Debug, PartialEq)]
|
||||
pub enum EditorState {
|
||||
#[default]
|
||||
None,
|
||||
CurrentUser,
|
||||
OtherUserActive,
|
||||
OtherUserIdle,
|
||||
}
|
||||
|
||||
/// Stores information about the current editor of
|
||||
/// a particular notebook, for display purposes.
|
||||
/// For now, this just includes the state and
|
||||
/// an email, but will eventually hold more information
|
||||
/// about the user.
|
||||
#[derive(Default, Clone, Debug, PartialEq)]
|
||||
pub struct Editor {
|
||||
pub state: EditorState,
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
pub fn no_editor() -> Self {
|
||||
Self {
|
||||
state: EditorState::None,
|
||||
email: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Singleton model for storing and querying the data and logic logic needed by various view, based on the information
|
||||
/// stored in [CloudModel]. As a general, rule, any new API that requires logic beyond just retriving the raw value
|
||||
/// in [CloudModel], should be stored here. This includes logic such as object trashed status, the object current editor,
|
||||
/// and object location.
|
||||
///
|
||||
/// Any API added to this model should be unit tested in model_test.rs
|
||||
pub struct CloudViewModel {
|
||||
folder_timestamp_cache: FolderTimestampCache,
|
||||
}
|
||||
|
||||
type FolderTimestampCache = RefCell<HashMap<SyncId, ServerTimestamp>>;
|
||||
|
||||
pub enum CloudViewModelEvent {
|
||||
/// A model change has invalidated object sort timestamps.
|
||||
SortTimestampsChanged,
|
||||
}
|
||||
|
||||
impl CloudViewModel {
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
ctx.subscribe_to_model(&CloudModel::handle(ctx), Self::handle_cloud_model_event);
|
||||
ctx.subscribe_to_model(
|
||||
&UpdateManager::handle(ctx),
|
||||
Self::handle_update_manager_event,
|
||||
);
|
||||
Self {
|
||||
folder_timestamp_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn mock(ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self::new(ctx)
|
||||
}
|
||||
|
||||
/// Returns the current editor of the object based on what current exists in CloudModel. If the current editor
|
||||
/// matches the logged in user's email, we assume that that user is the current editor.
|
||||
/// If the current editor hasn't made an edit in the past 15 minutes, they are considered idle and
|
||||
/// we instead just return Editor::OtherUserIdle. This is to prevent introducing friction into the baton grabbing process
|
||||
/// when it's not needed. For more info see:
|
||||
/// https://docs.google.com/document/d/1KgDFLApPg1uDVP-vOwhZzL1kRIviS8mMECIZg2VCKLY/edit
|
||||
pub fn object_current_editor(&self, uid: &ObjectUid, ctx: &AppContext) -> Option<Editor> {
|
||||
let cloud_model = CloudModel::as_ref(ctx);
|
||||
let object = cloud_model.get_by_uid(uid)?;
|
||||
|
||||
match &object.metadata().current_editor_uid {
|
||||
Some(uid) => {
|
||||
let auth_state = AuthStateProvider::as_ref(ctx).get();
|
||||
let user_uid = auth_state.user_id();
|
||||
|
||||
// If the logged in user matches the current UID, then the editor is the current
|
||||
// user.
|
||||
if user_uid.is_some_and(|user_uid| user_uid.as_string() == uid.clone()) {
|
||||
return Some(Editor {
|
||||
state: EditorState::CurrentUser,
|
||||
email: auth_state.user_email().clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let editor_uid = UserUid::new(uid);
|
||||
let editor_email = UserProfiles::as_ref(ctx)
|
||||
.profile_for_uid(editor_uid)
|
||||
.map(|profile| profile.email.clone());
|
||||
|
||||
match &object.metadata().revision {
|
||||
Some(revision) => {
|
||||
let time_since_last_edit = Utc::now() - revision.utc();
|
||||
let time_since_last_metadata_change = Utc::now()
|
||||
- object
|
||||
.metadata()
|
||||
.metadata_last_updated_ts
|
||||
.unwrap_or(Utc::now().into())
|
||||
.utc();
|
||||
if time_since_last_edit > Duration::minutes(EDITOR_TIMEOUT_DURATION_MINUTES)
|
||||
&& time_since_last_metadata_change
|
||||
> Duration::minutes(EDITOR_TIMEOUT_DURATION_MINUTES)
|
||||
{
|
||||
safe_info!(
|
||||
safe: ("Current editor idle, eagerly grabbing edit access for notebook"),
|
||||
full: ("Current editor idle, eagerly grabbing edit access for notebook with editor: {}", uid.clone())
|
||||
);
|
||||
Some(Editor {
|
||||
state: EditorState::OtherUserIdle,
|
||||
email: editor_email,
|
||||
})
|
||||
} else {
|
||||
Some(Editor {
|
||||
state: EditorState::OtherUserActive,
|
||||
email: editor_email,
|
||||
})
|
||||
}
|
||||
}
|
||||
None => Some(Editor {
|
||||
state: EditorState::OtherUserActive,
|
||||
email: editor_email,
|
||||
}),
|
||||
}
|
||||
}
|
||||
_ => Some(Editor::no_editor()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the [`Space`] that contains an object.
|
||||
pub fn object_space(&self, id: &ObjectUid, app: &AppContext) -> Option<Space> {
|
||||
CloudModel::as_ref(app)
|
||||
.get_by_uid(id)
|
||||
.map(|object| object.space(app))
|
||||
}
|
||||
|
||||
/// Get the current user's access level on a Warp Drive object.
|
||||
///
|
||||
/// This is based on the client's current view of the object permissions, which may be stale. The
|
||||
/// server is the source of truth for all permission data, and it may reject a request that the
|
||||
/// client expects is allowed.
|
||||
pub fn access_level(&self, object_uid: &ObjectUid, app: &AppContext) -> SharingAccessLevel {
|
||||
match CloudModel::as_ref(app).get_by_uid(object_uid) {
|
||||
Some(object) => Self::object_access_level(object, app),
|
||||
None => SharingAccessLevel::View,
|
||||
}
|
||||
}
|
||||
|
||||
fn object_access_level(object: &dyn CloudObject, app: &AppContext) -> SharingAccessLevel {
|
||||
match object.space(app) {
|
||||
// For now, users have full access to all objects in their own drives. We may introduce
|
||||
// drive-level ACLs in the future.
|
||||
Space::Personal | Space::Team { .. } => SharingAccessLevel::Full,
|
||||
Space::Shared => {
|
||||
let mut access_level = SharingAccessLevel::View;
|
||||
|
||||
// Check the default link-based access (if set, this is *at least* View).
|
||||
if let Some(link_settings) = &object.permissions().anyone_with_link {
|
||||
access_level = link_settings.access_level;
|
||||
}
|
||||
|
||||
let user_uid = AuthStateProvider::as_ref(app).get().user_id();
|
||||
if let Some(user_uid) = user_uid {
|
||||
for guest in object.permissions().guests.iter() {
|
||||
if guest.subject.is_user(user_uid) {
|
||||
access_level = access_level.max(guest.access_level);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the user created an object in a shared space, they will be treated as a guest and not the owner.
|
||||
// The guest permissions aren't fetched until the object is re-fetched, and this fixes this behavior
|
||||
// by forcing edit access if they created the object.
|
||||
if let (Some(creator_uid), Some(user_uid)) =
|
||||
(object.metadata().creator_uid.clone(), user_uid)
|
||||
{
|
||||
if creator_uid == user_uid.as_string() {
|
||||
access_level = access_level.max(SharingAccessLevel::Edit);
|
||||
}
|
||||
}
|
||||
|
||||
access_level
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current user's editability state for a Warp Drive object.
|
||||
pub fn object_editability(
|
||||
&self,
|
||||
object_uid: &ObjectUid,
|
||||
app: &AppContext,
|
||||
) -> ContentEditability {
|
||||
match CloudModel::as_ref(app).get_by_uid(object_uid) {
|
||||
Some(object) => {
|
||||
let access_level = Self::object_access_level(object, app);
|
||||
if access_level < SharingAccessLevel::Edit {
|
||||
ContentEditability::ReadOnly
|
||||
} else if AuthStateProvider::as_ref(app)
|
||||
.get()
|
||||
.is_anonymous_or_logged_out()
|
||||
{
|
||||
// The object is editable, but the user is not logged in.
|
||||
if object.space(app) == Space::Personal {
|
||||
ContentEditability::Editable
|
||||
} else {
|
||||
ContentEditability::RequiresLogin
|
||||
}
|
||||
} else {
|
||||
ContentEditability::Editable
|
||||
}
|
||||
}
|
||||
// Assume objects not yet in CloudModel are new, and therefore editable.
|
||||
None => ContentEditability::Editable,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the timestamp to sort `object` according to `timestamp_kind`.
|
||||
pub fn object_sorting_timestamp(
|
||||
&self,
|
||||
object: &dyn CloudObject,
|
||||
timestamp_kind: UpdateTimestamp,
|
||||
app: &AppContext,
|
||||
) -> Option<ServerTimestamp> {
|
||||
match timestamp_kind {
|
||||
// When sorting in the trash, we only ever consider the object's own trashed timestamp.
|
||||
// For trashed folders, their indirectly-trashed children will not have a trashed_ts,
|
||||
// so there's no need to recurse.
|
||||
UpdateTimestamp::Trashed => object.metadata().trashed_ts,
|
||||
// When sorting in the main index, we consider all of the children of a folder. This
|
||||
// can be expensive, so it's cached.
|
||||
UpdateTimestamp::Revision => {
|
||||
self.sorting_timestamp_rec(object, CloudModel::as_ref(app), app)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the sorting timestamp for `object`:
|
||||
/// * For a folder, this is the max of the folder's timestamp and all of its children's timestamps
|
||||
/// (recursively, for sub-folders).
|
||||
/// * For other objects, this is the object's own timestamp.
|
||||
fn sorting_timestamp_rec(
|
||||
&self,
|
||||
object: &dyn CloudObject,
|
||||
cloud_model: &CloudModel,
|
||||
app: &AppContext,
|
||||
) -> Option<ServerTimestamp> {
|
||||
let folder: Option<&CloudFolder> = object.into();
|
||||
match folder {
|
||||
// For non-folder objects, always use the object's own timestamp.
|
||||
None => object.metadata().revision.clone().map(Into::into),
|
||||
Some(folder) => self
|
||||
.folder_timestamp_cache
|
||||
// Skip the cache if it's already mutably borrowed. This should not happen in practice,
|
||||
// because the UI framework is single-threaded.
|
||||
.try_borrow()
|
||||
.ok()
|
||||
.and_then(|cache| cache.get(&folder.id).cloned())
|
||||
.or_else(|| {
|
||||
let max_child_timestamp = cloud_model
|
||||
.active_cloud_objects_in_location_without_descendents(
|
||||
CloudObjectLocation::Folder(folder.id),
|
||||
app,
|
||||
)
|
||||
// TODO(ben): This check won't be needed soon.
|
||||
.filter(|child| child.permissions().owner == folder.permissions().owner)
|
||||
.filter_map(|child| self.sorting_timestamp_rec(child, cloud_model, app))
|
||||
.max();
|
||||
// The `Ord` implementation of `Option` always considers `None` less than
|
||||
// `Some`.
|
||||
let folder_timestamp = folder.metadata().revision.clone().map(Into::into);
|
||||
let timestamp = max_child_timestamp.max(folder_timestamp);
|
||||
|
||||
if let Some(timestamp) = timestamp {
|
||||
if let Ok(mut cache) = self.folder_timestamp_cache.try_borrow_mut() {
|
||||
cache.insert(folder.id, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
timestamp
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_cloud_model_event(&mut self, event: &CloudModelEvent, ctx: &mut ModelContext<Self>) {
|
||||
match event {
|
||||
CloudModelEvent::ObjectUpdated { type_and_id, .. }
|
||||
| CloudModelEvent::ObjectTrashed { type_and_id, .. }
|
||||
| CloudModelEvent::ObjectUntrashed { type_and_id, .. }
|
||||
| CloudModelEvent::ObjectPermissionsUpdated { type_and_id, .. } => {
|
||||
// If an object is updated, we need to recompute the timestamps of its parents.
|
||||
if self.invalidate_object_timestamps(&type_and_id.uid(), CloudModel::as_ref(ctx)) {
|
||||
ctx.emit(CloudViewModelEvent::SortTimestampsChanged);
|
||||
}
|
||||
}
|
||||
CloudModelEvent::ObjectMoved {
|
||||
from_folder,
|
||||
to_folder,
|
||||
..
|
||||
} => {
|
||||
// Both the old parent and the new parent need to be invalidated, since this object
|
||||
// could affect the sort timestamp of both. Even if the moved object were a folder,
|
||||
// its own sort timestamp isn't affected.
|
||||
let cloud_model = CloudModel::as_ref(ctx);
|
||||
let old_parent_changed = from_folder.is_some_and(|folder_id| {
|
||||
self.invalidate_folder_timestamps(&folder_id, cloud_model)
|
||||
});
|
||||
let new_parent_changed = to_folder.is_some_and(|folder_id| {
|
||||
self.invalidate_folder_timestamps(&folder_id, cloud_model)
|
||||
});
|
||||
if old_parent_changed || new_parent_changed {
|
||||
ctx.emit(CloudViewModelEvent::SortTimestampsChanged);
|
||||
}
|
||||
}
|
||||
CloudModelEvent::ObjectCreated { type_and_id } => {
|
||||
// There are three cases for an ObjectCreated event:
|
||||
// 1. We created a new object locally (in which case type_and_id is a client ID)
|
||||
// 2. We were notified about a new object from the server.
|
||||
// 3. A locally-created object was saved to the server, so we now have a server ID
|
||||
// for it.
|
||||
// Because we sort on server timestamps, only the second or third cases can affect
|
||||
// sorting.
|
||||
if type_and_id.has_server_id()
|
||||
&& self
|
||||
.invalidate_object_timestamps(&type_and_id.uid(), CloudModel::as_ref(ctx))
|
||||
{
|
||||
ctx.emit(CloudViewModelEvent::SortTimestampsChanged);
|
||||
}
|
||||
}
|
||||
CloudModelEvent::ObjectDeleted { folder_id, .. } => {
|
||||
if let Some(folder_id) = folder_id {
|
||||
if self.invalidate_folder_timestamps(folder_id, CloudModel::as_ref(ctx)) {
|
||||
ctx.emit(CloudViewModelEvent::SortTimestampsChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
CloudModelEvent::NotebookEditorChangedFromServer { .. }
|
||||
| CloudModelEvent::ObjectForceExpanded { .. }
|
||||
| CloudModelEvent::ObjectSynced { .. }
|
||||
| CloudModelEvent::InitialLoadCompleted => (),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_update_manager_event(
|
||||
&mut self,
|
||||
event: &UpdateManagerEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let UpdateManagerEvent::ObjectOperationComplete { result } = event else {
|
||||
return;
|
||||
};
|
||||
|
||||
if result.success_type != OperationSuccessType::Success {
|
||||
return;
|
||||
}
|
||||
|
||||
let cloud_model = CloudModel::as_ref(ctx);
|
||||
if let ObjectOperation::Create { .. } = result.operation {
|
||||
// If a folder was created, remove the cache entry tied to its client ID.
|
||||
// TODO @ianhodge: Update the way we do this check once we remove the generic
|
||||
let server_id = &result.server_id.expect("Expect server id on success");
|
||||
if cloud_model.get_folder_by_uid(&server_id.uid()).is_some() {
|
||||
if let Some(client_id) = result.client_id {
|
||||
let sync_id = SyncId::ClientId(client_id);
|
||||
self.folder_timestamp_cache.borrow_mut().remove(&sync_id);
|
||||
}
|
||||
}
|
||||
|
||||
// For any new object, we need to recalculate its ancestors' timestamp with their
|
||||
// new child.
|
||||
if let Some(parent_id) = cloud_model
|
||||
.get_by_uid(&server_id.uid())
|
||||
.and_then(|object| object.metadata().folder_id)
|
||||
{
|
||||
if self.invalidate_folder_timestamps(&parent_id, cloud_model) {
|
||||
ctx.emit(CloudViewModelEvent::SortTimestampsChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalidate all cached timestamps for the object with the given ID, and its parents.
|
||||
fn invalidate_object_timestamps(&mut self, uid: &ObjectUid, cloud_model: &CloudModel) -> bool {
|
||||
let Some(object) = cloud_model.get_by_uid(uid) else {
|
||||
return false;
|
||||
};
|
||||
let folder: Option<&CloudFolder> = object.into();
|
||||
match folder {
|
||||
Some(folder) => self.invalidate_folder_timestamps(&folder.id, cloud_model),
|
||||
None => {
|
||||
if let Some(parent_id) = object.metadata().folder_id {
|
||||
self.invalidate_folder_timestamps(&parent_id, cloud_model)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalidate all cached timestamps for the given folder and its parents.
|
||||
fn invalidate_folder_timestamps(
|
||||
&mut self,
|
||||
folder_id: &SyncId,
|
||||
cloud_model: &CloudModel,
|
||||
) -> bool {
|
||||
let had_revision_ts = self
|
||||
.folder_timestamp_cache
|
||||
.borrow_mut()
|
||||
.remove(folder_id)
|
||||
.is_some();
|
||||
|
||||
let had_parent_ts = cloud_model
|
||||
.get_folder(folder_id)
|
||||
.and_then(|folder| folder.metadata().folder_id.as_ref())
|
||||
.is_some_and(|parent| self.invalidate_folder_timestamps(parent, cloud_model));
|
||||
had_revision_ts || had_parent_ts
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for CloudViewModel {
|
||||
type Event = CloudViewModelEvent;
|
||||
}
|
||||
|
||||
/// Mark CloudViewModel as global application state.
|
||||
impl SingletonEntity for CloudViewModel {}
|
||||
|
||||
/// The timestamp to use when sorting objects by their last updated time.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum UpdateTimestamp {
|
||||
/// Sort objects by their revision timestamp, when they were last edited.
|
||||
#[default]
|
||||
Revision,
|
||||
/// Sort objects by their trashed timestamp.
|
||||
Trashed,
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
use warpui::AppContext;
|
||||
|
||||
use crate::server::cloud_objects::update_manager::{
|
||||
InitiatedBy, ObjectOperation, OperationSuccessType,
|
||||
};
|
||||
|
||||
use super::{CloudObject, GenericStringObjectFormat, JsonObjectType, ObjectType};
|
||||
|
||||
pub struct CloudObjectToastMessage;
|
||||
|
||||
impl CloudObjectToastMessage {
|
||||
pub fn toast_message(
|
||||
object: &dyn CloudObject,
|
||||
operation: &ObjectOperation,
|
||||
success_type: &OperationSuccessType,
|
||||
app: &AppContext,
|
||||
) -> Option<String> {
|
||||
let object_name = object.model_type_name().to_owned();
|
||||
let object_name_lowercase = object_name.to_ascii_lowercase();
|
||||
|
||||
match (object.object_type(), operation, success_type) {
|
||||
// We should only show toasts for creates initiated by the user, not by the system
|
||||
(_, ObjectOperation::Create { initiated_by: InitiatedBy::User }, OperationSuccessType::Success) => {
|
||||
let containing_object_name = object.containing_object_name(app);
|
||||
Some(format!("{object_name} saved to {containing_object_name}"))
|
||||
}
|
||||
// notebooks intentionally do not have an update message, as they are updated
|
||||
// as the user types and so toasts would be VERY noisy
|
||||
(
|
||||
ObjectType::Notebook,
|
||||
ObjectOperation::Update,
|
||||
OperationSuccessType::Success,
|
||||
) => None,
|
||||
(_, ObjectOperation::Update, OperationSuccessType::Success) => {
|
||||
Some(format!("{object_name} updated"))
|
||||
}
|
||||
(_, ObjectOperation::MoveToFolder, OperationSuccessType::Success) | (_, ObjectOperation::MoveToDrive, OperationSuccessType::Success) => {
|
||||
let containing_object_name = object.containing_object_name(app);
|
||||
Some(format!("{object_name} moved to {containing_object_name}"))
|
||||
}
|
||||
(_, ObjectOperation::Trash, OperationSuccessType::Success) => {
|
||||
Some(format!("{object_name} trashed"))
|
||||
}
|
||||
(_, ObjectOperation::Untrash, OperationSuccessType::Success) => {
|
||||
Some(format!("{object_name} restored"))
|
||||
}
|
||||
(_, ObjectOperation::Leave, OperationSuccessType::Success) => {
|
||||
Some(format!("Left {object_name}"))
|
||||
}
|
||||
(_, ObjectOperation::Create { initiated_by: InitiatedBy::User }, OperationSuccessType::Failure) => {
|
||||
Some(format!("Failed to create {object_name_lowercase}"))
|
||||
}
|
||||
(_, ObjectOperation::Create { initiated_by: InitiatedBy::User }, OperationSuccessType::Denied(message)) => {
|
||||
Some(message.to_string())
|
||||
}
|
||||
(_, ObjectOperation::Update, OperationSuccessType::Failure) => {
|
||||
Some(format!("Failed to update {object_name_lowercase}"))
|
||||
}
|
||||
(_, ObjectOperation::MoveToFolder, OperationSuccessType::Failure) | (_, ObjectOperation::MoveToDrive, OperationSuccessType::Failure) => {
|
||||
Some(format!("Failed to move {object_name_lowercase}"))
|
||||
}
|
||||
(_, ObjectOperation::Trash, OperationSuccessType::Failure) => {
|
||||
Some(format!("Failed to trash {object_name_lowercase}"))
|
||||
}
|
||||
(_, ObjectOperation::Untrash, OperationSuccessType::Failure) => {
|
||||
Some(format!("Failed to restore {object_name_lowercase}"))
|
||||
}
|
||||
// We should only show deletion failure toasts for user-initiated deletions.
|
||||
(_, ObjectOperation::Delete { initiated_by: InitiatedBy::User }, OperationSuccessType::Failure) => {
|
||||
Some(format!("Failed to delete {object_name_lowercase}"))
|
||||
}
|
||||
(_, ObjectOperation::Leave, OperationSuccessType::Failure) => {
|
||||
Some(format!("Failed to leave {object_name}"))
|
||||
}
|
||||
(
|
||||
ObjectType::Workflow,
|
||||
ObjectOperation::Update,
|
||||
OperationSuccessType::Rejection,
|
||||
) => {
|
||||
Some("This workflow could not be saved because changes were made while you were editing.".to_string())
|
||||
}
|
||||
(
|
||||
ObjectType::GenericStringObject(GenericStringObjectFormat::Json(JsonObjectType::EnvVarCollection)),
|
||||
ObjectOperation::Update,
|
||||
OperationSuccessType::Rejection,
|
||||
) => {
|
||||
Some("Environment variables could not be saved because changes were made while you were editing.".to_string())
|
||||
}
|
||||
(
|
||||
ObjectType::GenericStringObject(GenericStringObjectFormat::Json(JsonObjectType::AIFact)),
|
||||
ObjectOperation::Update,
|
||||
OperationSuccessType::Rejection,
|
||||
) => {
|
||||
Some("Rule could not be saved because changes were made while you were editing.".to_string())
|
||||
}
|
||||
(_, ObjectOperation::TakeEditAccess, OperationSuccessType::Failure) => {
|
||||
Some(format!("Failed to start editing {object_name_lowercase}"))
|
||||
}
|
||||
(_, ObjectOperation::UpdatePermissions, OperationSuccessType::Success) => {
|
||||
Some(format!("Successfully updated permissions for {object_name_lowercase}"))
|
||||
}
|
||||
(_, ObjectOperation::UpdatePermissions, OperationSuccessType::Failure) => {
|
||||
Some(format!("Failed to update permissions for {object_name_lowercase}"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toast_deletion_confirm_message(
|
||||
num_objects: i32,
|
||||
operation: &ObjectOperation,
|
||||
success_type: &OperationSuccessType,
|
||||
) -> Option<String> {
|
||||
let count_objects_message = match num_objects {
|
||||
1 => "1 object".to_string(),
|
||||
n => {
|
||||
format!("{n} objects")
|
||||
}
|
||||
};
|
||||
match (operation, success_type) {
|
||||
// We should only show deletion failure toasts for user-initiated deletions.
|
||||
(
|
||||
ObjectOperation::Delete {
|
||||
initiated_by: InitiatedBy::User,
|
||||
},
|
||||
OperationSuccessType::Success,
|
||||
) => Some(format!("{count_objects_message} deleted forever")),
|
||||
(ObjectOperation::EmptyTrash, OperationSuccessType::Success) => Some(format!(
|
||||
"Trash emptied: {count_objects_message} deleted forever"
|
||||
)),
|
||||
(ObjectOperation::EmptyTrash, OperationSuccessType::Failure) => {
|
||||
Some("Failed to empty trash".to_string())
|
||||
}
|
||||
(ObjectOperation::EmptyTrash, OperationSuccessType::Rejection) => {
|
||||
Some("No objects in trash to empty".to_string())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user