Show custom icons in Component Browser (#3606)

Show custom icons in Component Browser for entries that have a non-empty `Icon` section in their docs with the section's body containing a name of a predefined icon.

https://www.pivotaltracker.com/story/show/182584336

#### Visuals

A screenshot of a couple custom icons in the Component Browser:

<img width="346" alt="Screenshot 2022-07-27 at 15 55 33" src="https://user-images.githubusercontent.com/273837/181265249-d57f861f-8095-4933-9ef6-e62644e11da3.png">

# Important Notes
- The PR assigns icon names to four items in the standard library, but only three of them are shown in the Component Browser because of [a parsing bug in the Engine](https://www.pivotaltracker.com/story/show/182781673).
- Icon names are assigned only to four items in the standard library because only two currently predefined icons match entries in the currently defined Virtual Component Groups. Adjusting the definitions of icons and Virtual Component Groups is covered by [a different task](https://www.pivotaltracker.com/story/show/182584311).
- A bug in the documentation of the Enso protocol message `DocSection` is fixed. A `text` field in the `Tag` interface is renamed to `body` (this is the field name used in Engine).
This commit is contained in:
Mateusz Czapliński 2022-08-01 15:41:04 +02:00 committed by GitHub
parent 7f8190e663
commit c6835d2de7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 368 additions and 134 deletions

View File

@ -776,6 +776,63 @@ pub enum RegisterOptions {
}
// ===================
// === Doc Section ===
// ===================
/// Text rendered as HTML (may contain HTML tags).
pub type HtmlString = String;
/// Documentation section mark.
#[derive(Hash, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[allow(missing_docs)]
pub enum Mark {
Important,
Info,
Example,
}
/// A single section of the documentation.
#[derive(Hash, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[allow(missing_docs)]
#[serde(tag = "type")]
#[serde(rename_all = "camelCase")]
pub enum DocSection {
/// The documentation tag.
#[serde(rename_all = "camelCase")]
Tag {
/// The tag name.
name: String,
/// The tag text.
body: HtmlString,
},
/// The paragraph of the text.
#[serde(rename_all = "camelCase")]
Paragraph {
/// The elements that make up this paragraph.
body: HtmlString,
},
/// The section that starts with the key followed by the colon and the body.
#[serde(rename_all = "camelCase")]
Keyed {
/// The section key.
key: String,
/// The elements that make up the body of the section.
body: HtmlString,
},
/// The section that starts with the mark followed by the header and the body.
#[serde(rename_all = "camelCase")]
Marked {
/// The section mark.
mark: Mark,
/// The section header.
header: Option<String>,
/// The elements that make up the body of the section.
body: HtmlString,
},
}
// ===========================
// === Suggestion Database ===
// ===========================
@ -844,24 +901,28 @@ pub enum SuggestionEntryType {
pub enum SuggestionEntry {
#[serde(rename_all = "camelCase")]
Atom {
external_id: Option<Uuid>,
name: String,
module: String,
arguments: Vec<SuggestionEntryArgument>,
return_type: String,
documentation: Option<String>,
documentation_html: Option<String>,
external_id: Option<Uuid>,
name: String,
module: String,
arguments: Vec<SuggestionEntryArgument>,
return_type: String,
documentation: Option<String>,
documentation_html: Option<String>,
#[serde(default, deserialize_with = "enso_prelude::deserialize_null_as_default")]
documentation_sections: Vec<DocSection>,
},
#[serde(rename_all = "camelCase")]
Method {
external_id: Option<Uuid>,
name: String,
module: String,
arguments: Vec<SuggestionEntryArgument>,
self_type: String,
return_type: String,
documentation: Option<String>,
documentation_html: Option<String>,
external_id: Option<Uuid>,
name: String,
module: String,
arguments: Vec<SuggestionEntryArgument>,
self_type: String,
return_type: String,
documentation: Option<String>,
documentation_html: Option<String>,
#[serde(default, deserialize_with = "enso_prelude::deserialize_null_as_default")]
documentation_sections: Vec<DocSection>,
},
#[serde(rename_all = "camelCase")]
Function {
@ -882,10 +943,12 @@ pub enum SuggestionEntry {
},
#[serde(rename_all = "camelCase")]
Module {
module: String,
documentation: Option<String>,
documentation_html: Option<String>,
reexport: Option<String>,
module: String,
documentation: Option<String>,
documentation_html: Option<String>,
reexport: Option<String>,
#[serde(default, deserialize_with = "enso_prelude::deserialize_null_as_default")]
documentation_sections: Vec<DocSection>,
},
}

View File

@ -1150,6 +1150,7 @@ impl Searcher {
documentation_html: None,
self_type: Some(self_type.clone()),
scope: model::suggestion_database::entry::Scope::Everywhere,
icon_name: None,
};
let action = Action::Suggestion(action::Suggestion::FromDatabase(Rc::new(entry)));
libraries_cat_builder.add_action(action);
@ -1492,6 +1493,7 @@ pub mod test {
documentation_html: default(),
self_type: None,
scope,
icon_name: None,
};
let entry2 = model::suggestion_database::Entry {
name: "TestVar1".to_string(),
@ -1551,6 +1553,7 @@ pub mod test {
documentation_html: None,
self_type: None,
scope: Scope::Everywhere,
icon_name: None,
};
let entry9 = model::suggestion_database::Entry {
name: "testFunction2".to_string(),

View File

@ -247,10 +247,11 @@ pub(crate) mod tests {
pub fn mock_module(name: &str) -> model::suggestion_database::Entry {
let ls_entry = language_server::SuggestionEntry::Module {
module: name.to_owned(),
documentation: default(),
documentation_html: default(),
reexport: default(),
module: name.to_owned(),
documentation: default(),
documentation_html: default(),
documentation_sections: default(),
reexport: default(),
};
model::suggestion_database::Entry::from_ls_entry(ls_entry).unwrap()
}
@ -268,6 +269,7 @@ pub(crate) mod tests {
documentation_html: None,
self_type: None,
scope: model::suggestion_database::entry::Scope::Everywhere,
icon_name: None,
}
}

View File

@ -473,13 +473,14 @@ mod test {
// Non-empty db
let entry = SuggestionEntry::Atom {
name: "TextAtom".to_string(),
module: "TestProject.TestModule".to_string(),
arguments: vec![],
return_type: "TestAtom".to_string(),
documentation: None,
documentation_html: None,
external_id: None,
name: "TextAtom".to_string(),
module: "TestProject.TestModule".to_string(),
arguments: vec![],
return_type: "TestAtom".to_string(),
documentation: None,
documentation_html: None,
documentation_sections: default(),
external_id: None,
};
let db_entry = SuggestionsDatabaseEntry { id: 12, suggestion: entry };
let response = language_server::response::GetSuggestionDatabase {
@ -498,31 +499,34 @@ mod test {
fn applying_update() {
let mut fixture = TestWithLocalPoolExecutor::set_up();
let entry1 = SuggestionEntry::Atom {
name: "Entry1".to_owned(),
module: "TestProject.TestModule".to_owned(),
arguments: vec![],
return_type: "TestAtom".to_owned(),
documentation: None,
documentation_html: None,
external_id: None,
name: "Entry1".to_owned(),
module: "TestProject.TestModule".to_owned(),
arguments: vec![],
return_type: "TestAtom".to_owned(),
documentation: None,
documentation_html: None,
documentation_sections: default(),
external_id: None,
};
let entry2 = SuggestionEntry::Atom {
name: "Entry2".to_owned(),
module: "TestProject.TestModule".to_owned(),
arguments: vec![],
return_type: "TestAtom".to_owned(),
documentation: None,
documentation_html: None,
external_id: None,
name: "Entry2".to_owned(),
module: "TestProject.TestModule".to_owned(),
arguments: vec![],
return_type: "TestAtom".to_owned(),
documentation: None,
documentation_html: None,
documentation_sections: default(),
external_id: None,
};
let new_entry2 = SuggestionEntry::Atom {
name: "NewEntry2".to_owned(),
module: "TestProject.TestModule".to_owned(),
arguments: vec![],
return_type: "TestAtom".to_owned(),
documentation: None,
documentation_html: None,
external_id: None,
name: "NewEntry2".to_owned(),
module: "TestProject.TestModule".to_owned(),
arguments: vec![],
return_type: "TestAtom".to_owned(),
documentation: None,
documentation_html: None,
documentation_sections: default(),
external_id: None,
};
let arg1 = SuggestionEntryArgument {
name: "Argument1".to_owned(),
@ -791,29 +795,32 @@ mod test {
fn lookup_by_fully_qualified_name_in_db_created_from_ls_response() {
// Initialize a suggestion database with sample entries.
let entry1 = SuggestionEntry::Atom {
name: "TextAtom".to_string(),
module: "TestProject.TestModule".to_string(),
arguments: vec![],
return_type: "TestAtom".to_string(),
documentation: None,
documentation_html: None,
external_id: None,
name: "TextAtom".to_string(),
module: "TestProject.TestModule".to_string(),
arguments: vec![],
return_type: "TestAtom".to_string(),
documentation: None,
documentation_html: None,
documentation_sections: default(),
external_id: None,
};
let entry2 = SuggestionEntry::Method {
name: "create_process".to_string(),
module: "Standard.Builtins.Main".to_string(),
self_type: "Standard.Builtins.Main.System".to_string(),
arguments: vec![],
return_type: "Standard.Builtins.Main.System_Process_Result".to_string(),
documentation: None,
documentation_html: None,
external_id: None,
name: "create_process".to_string(),
module: "Standard.Builtins.Main".to_string(),
self_type: "Standard.Builtins.Main.System".to_string(),
arguments: vec![],
return_type: "Standard.Builtins.Main.System_Process_Result".to_string(),
documentation: None,
documentation_html: None,
documentation_sections: default(),
external_id: None,
};
let entry3 = SuggestionEntry::Module {
module: "local.Unnamed_6.Main".to_string(),
documentation: None,
documentation_html: None,
reexport: None,
module: "local.Unnamed_6.Main".to_string(),
documentation: None,
documentation_html: None,
documentation_sections: default(),
reexport: None,
};
let entry4 = SuggestionEntry::Local {
module: "local.Unnamed_6.Main".to_string(),
@ -873,13 +880,14 @@ mod test {
fn initialize_database_with_invalid_entries() {
// Prepare some nonsense inputs from the Engine.
let entry_with_empty_name = SuggestionEntry::Atom {
name: "".to_string(),
module: "Empty.Entry".to_string(),
arguments: vec![],
return_type: "".to_string(),
documentation: None,
documentation_html: None,
external_id: None,
name: "".to_string(),
module: "Empty.Entry".to_string(),
arguments: vec![],
return_type: "".to_string(),
documentation: None,
documentation_html: None,
documentation_sections: default(),
external_id: None,
};
let empty_entry = SuggestionEntry::Local {
module: "".to_string(),
@ -889,10 +897,11 @@ mod test {
scope: (default()..=default()).into(),
};
let gibberish_entry = SuggestionEntry::Module {
module: GIBBERISH_MODULE_NAME.to_string(),
documentation: None,
documentation_html: None,
reexport: None,
module: GIBBERISH_MODULE_NAME.to_string(),
documentation: None,
documentation_html: None,
documentation_sections: default(),
reexport: None,
};
let ls_response = language_server::response::GetSuggestionDatabase {
@ -913,23 +922,25 @@ mod test {
fn lookup_by_fully_qualified_name_after_db_update() {
// Initialize a suggestion database with a few sample entries.
let entry1 = SuggestionEntry::Atom {
name: "TextAtom".to_string(),
module: "TestProject.TestModule".to_string(),
arguments: vec![],
return_type: "TestAtom".to_string(),
documentation: None,
documentation_html: None,
external_id: None,
name: "TextAtom".to_string(),
module: "TestProject.TestModule".to_string(),
arguments: vec![],
return_type: "TestAtom".to_string(),
documentation: None,
documentation_html: None,
documentation_sections: default(),
external_id: None,
};
let entry2 = SuggestionEntry::Method {
name: "create_process".to_string(),
module: "Standard.Builtins.Main".to_string(),
self_type: "Standard.Builtins.Main.System".to_string(),
arguments: vec![],
return_type: "Standard.Builtins.Main.System_Process_Result".to_string(),
documentation: None,
documentation_html: None,
external_id: None,
name: "create_process".to_string(),
module: "Standard.Builtins.Main".to_string(),
self_type: "Standard.Builtins.Main.System".to_string(),
arguments: vec![],
return_type: "Standard.Builtins.Main.System_Process_Result".to_string(),
documentation: None,
documentation_html: None,
documentation_sections: default(),
external_id: None,
};
fn db_entry(id: SuggestionId, suggestion: SuggestionEntry) -> SuggestionsDatabaseEntry {
SuggestionsDatabaseEntry { id, suggestion }
@ -953,10 +964,11 @@ mod test {
scope: None,
});
let entry3 = SuggestionEntry::Module {
module: "local.Unnamed_6.Main".to_string(),
documentation: None,
documentation_html: None,
reexport: None,
module: "local.Unnamed_6.Main".to_string(),
documentation: None,
documentation_html: None,
documentation_sections: default(),
reexport: None,
};
let update = SuggestionDatabaseUpdatesEvent {
updates: vec![
@ -987,13 +999,14 @@ mod test {
fn lookup_by_fully_qualified_name_after_db_update_reuses_id() {
// Initialize a suggestion database with a sample entry.
let entry1 = SuggestionEntry::Atom {
name: "TextAtom".to_string(),
module: "TestProject.TestModule".to_string(),
arguments: vec![],
return_type: "TestAtom".to_string(),
documentation: None,
documentation_html: None,
external_id: None,
name: "TextAtom".to_string(),
module: "TestProject.TestModule".to_string(),
arguments: vec![],
return_type: "TestAtom".to_string(),
documentation: None,
documentation_html: None,
documentation_sections: default(),
external_id: None,
};
let id = 1;
let response = language_server::response::GetSuggestionDatabase {
@ -1011,10 +1024,11 @@ mod test {
// Apply a DB update adding a different entry at the same `id`.
let entry2 = SuggestionEntry::Module {
module: "local.Unnamed_6.Main".to_string(),
documentation: None,
documentation_html: None,
reexport: None,
module: "local.Unnamed_6.Main".to_string(),
documentation: None,
documentation_html: None,
documentation_sections: default(),
reexport: None,
};
let update = SuggestionDatabaseUpdatesEvent {
updates: vec![entry::Update::Add { id, suggestion: entry2 }],

View File

@ -5,6 +5,8 @@ use crate::prelude::*;
use crate::model::module::MethodId;
use ast::constants::keywords;
use convert_case::Case;
use convert_case::Casing;
use double_representation::module;
use double_representation::tp;
use engine_protocol::language_server;
@ -25,6 +27,16 @@ pub use language_server::types::SuggestionsDatabaseUpdate as Update;
// =================
// === Constants ===
// =================
/// Key of the keyed [`language_server::types::DocSection`] containing a name of an icon in its
/// body.
const ICON_DOC_SECTION_KEY: &str = "Icon";
// ==============
// === Errors ===
// ==============
@ -127,6 +139,38 @@ impl<'a> IntoIterator for &'a QualifiedName {
// ================
// === IconName ===
// ================
/// Name of an icon. The name is composed of words with unspecified casing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IconName {
/// Internally the name is kept in PascalCase to optimize converting into
/// [`component_group_view::icon::Id`].
pascal_cased: ImString,
}
impl IconName {
/// Construct from a name formatted in snake_case.
pub fn from_snake_case(s: impl AsRef<str>) -> Self {
let pascal_cased = s.as_ref().from_case(Case::Snake).to_case(Case::Pascal).into();
Self { pascal_cased }
}
/// Convert to a name formatted in snake_case.
pub fn to_snake_case(&self) -> ImString {
self.pascal_cased.from_case(Case::Pascal).to_case(Case::Snake).into()
}
/// Convert to a name formatted in PascalCase.
pub fn to_pascal_case(&self) -> ImString {
self.pascal_cased.clone()
}
}
// =============
// === Entry ===
// =============
@ -188,6 +232,8 @@ pub struct Entry {
pub self_type: Option<tp::QualifiedName>,
/// A scope where this suggestion is visible.
pub scope: Scope,
/// A name of a custom icon to use when displaying the entry.
pub icon_name: Option<IconName>,
}
impl Entry {
@ -351,6 +397,7 @@ impl Entry {
return_type,
documentation,
documentation_html,
documentation_sections,
..
} => Self {
name,
@ -361,6 +408,7 @@ impl Entry {
self_type: None,
kind: Kind::Atom,
scope: Scope::Everywhere,
icon_name: find_icon_name_in_doc_sections(&documentation_sections),
},
#[allow(unused)]
Method {
@ -371,6 +419,7 @@ impl Entry {
return_type,
documentation,
documentation_html,
documentation_sections,
..
} => Self {
name,
@ -381,6 +430,7 @@ impl Entry {
self_type: Some(self_type.try_into()?),
kind: Kind::Method,
scope: Scope::Everywhere,
icon_name: find_icon_name_in_doc_sections(&documentation_sections),
},
Function { name, module, arguments, return_type, scope, .. } => Self {
name,
@ -391,6 +441,7 @@ impl Entry {
documentation_html: default(),
kind: Kind::Function,
scope: Scope::InModule { range: scope.into() },
icon_name: None,
},
Local { name, module, return_type, scope, .. } => Self {
name,
@ -401,8 +452,11 @@ impl Entry {
documentation_html: default(),
kind: Kind::Local,
scope: Scope::InModule { range: scope.into() },
icon_name: None,
},
Module { module, documentation, documentation_html, .. } => {
Module {
module, documentation, documentation_html, documentation_sections, ..
} => {
let module_name: module::QualifiedName = module.clone().try_into()?;
Self {
documentation_html: Self::make_html_docs(documentation, documentation_html),
@ -413,6 +467,7 @@ impl Entry {
kind: Kind::Module,
scope: Scope::Everywhere,
return_type: module,
icon_name: find_icon_name_in_doc_sections(&documentation_sections),
}
}
};
@ -629,6 +684,27 @@ fn chain_iter_and_entry_name<'a>(
iter.into_iter().chain(iter::once(entry.name.as_str()))
}
fn find_icon_name_in_doc_sections<'a, I>(doc_sections: I) -> Option<IconName>
where I: IntoIterator<Item = &'a language_server::types::DocSection> {
use language_server::types::DocSection;
doc_sections.into_iter().find_map(|section| match section {
DocSection::Keyed { key, body } if key == ICON_DOC_SECTION_KEY => {
let icon_name = IconName::from_snake_case(&body);
let as_snake_case = icon_name.to_snake_case();
if as_snake_case.as_str() != body.as_str() || !body.is_case(Case::Snake) {
let msg = format!(
"The icon name {body} used in the {ICON_DOC_SECTION_KEY} section of the \
documentation of a component is not a valid, losslessly-convertible snake_case \
identifier. The component may be displayed with a different icon than expected."
);
event!(WARN, "{msg}");
}
Some(icon_name)
}
_ => None,
})
}
// =============
@ -665,6 +741,7 @@ mod test {
documentation_html: None,
self_type: None,
scope: Scope::Everywhere,
icon_name: None,
};
let method = Entry {
name: "method".to_string(),
@ -767,6 +844,7 @@ mod test {
documentation_html: None,
self_type: None,
scope: Scope::Everywhere,
icon_name: None,
};
let method = Entry {
name: "method".to_string(),
@ -795,31 +873,34 @@ mod test {
assert_eq!(entry_qualified_name, expected_qualified_name);
}
let atom = language_server::SuggestionEntry::Atom {
name: "TextAtom".to_string(),
module: "TestProject.TestModule".to_string(),
arguments: vec![],
return_type: "TestAtom".to_string(),
documentation: None,
documentation_html: None,
external_id: None,
name: "TextAtom".to_string(),
module: "TestProject.TestModule".to_string(),
arguments: vec![],
return_type: "TestAtom".to_string(),
documentation: None,
documentation_html: None,
documentation_sections: default(),
external_id: None,
};
expect(atom, "TestProject.TestModule.TextAtom");
let method = language_server::SuggestionEntry::Method {
name: "create_process".to_string(),
module: "Standard.Builtins.Main".to_string(),
self_type: "Standard.Builtins.Main.System".to_string(),
arguments: vec![],
return_type: "Standard.Builtins.Main.System_Process_Result".to_string(),
documentation: None,
documentation_html: None,
external_id: None,
name: "create_process".to_string(),
module: "Standard.Builtins.Main".to_string(),
self_type: "Standard.Builtins.Main.System".to_string(),
arguments: vec![],
return_type: "Standard.Builtins.Main.System_Process_Result".to_string(),
documentation: None,
documentation_html: None,
documentation_sections: default(),
external_id: None,
};
expect(method, "Standard.Builtins.Main.System.create_process");
let module = language_server::SuggestionEntry::Module {
module: "local.Unnamed_6.Main".to_string(),
documentation: None,
documentation_html: None,
reexport: None,
module: "local.Unnamed_6.Main".to_string(),
documentation: None,
documentation_html: None,
documentation_sections: default(),
reexport: None,
};
expect(module, "local.Unnamed_6.Main");
let local = language_server::SuggestionEntry::Local {
@ -848,4 +929,31 @@ mod test {
assert_eq!(qualified_name.to_string(), "Foo.Bar".to_string());
assert_eq!(String::from(qualified_name), "Foo.Bar".to_string());
}
/// Test [`find_icon_name_in_doc_sections`] function extracting a name of an icon from the body
/// of a keyed [`DocSection`] which has its key equal to the `Icon` string.
#[test]
fn find_icon_name_in_doc_section_with_icon_key() {
use language_server::types::DocSection;
let doc_sections = [
DocSection::Paragraph { body: "Some paragraph.".into() },
DocSection::Keyed { key: "NotIcon".into(), body: "example_not_icon_body".into() },
DocSection::Keyed { key: "Icon".into(), body: "example_icon_name".into() },
DocSection::Paragraph { body: "Another paragraph.".into() },
];
let icon_name = find_icon_name_in_doc_sections(&doc_sections).unwrap();
assert_eq!(icon_name.to_pascal_case(), "ExampleIconName");
}
/// Test case-insensitive comparison of [`IconName`] values and case-insensitiveness when
/// converting [`IconName`] values to PascalCase.
#[test]
fn icon_name_case_insensitiveness() {
let name_from_small_snake_case = IconName::from_snake_case("an_example_name");
let name_from_mixed_snake_case = IconName::from_snake_case("aN_EXAMPLE_name");
const PASCAL_CASE_NAME: &str = "AnExampleName";
assert_eq!(name_from_small_snake_case, name_from_mixed_snake_case);
assert_eq!(name_from_small_snake_case.to_pascal_case(), PASCAL_CASE_NAME);
assert_eq!(name_from_mixed_snake_case.to_pascal_case(), PASCAL_CASE_NAME);
}
}

View File

@ -185,8 +185,10 @@ impl list_view::entry::ModelProvider<component_group_view::Entry> for Component
let label = component.label();
let highlighted = bytes_of_matched_letters(&*match_info, &label);
let kind = component.suggestion.kind;
let icon_name = component.suggestion.icon_name.as_ref();
let icon = icon_name.and_then(|n| n.to_pascal_case().parse().ok());
Some(component_group_view::entry::Model {
icon: for_each_kind_variant!(kind_to_icon(kind)),
icon: icon.unwrap_or_else(|| for_each_kind_variant!(kind_to_icon(kind))),
highlighted_text: list_view::entry::GlyphHighlightedLabelModel { label, highlighted },
})
}

View File

@ -108,6 +108,7 @@ pub mod mock {
kind: suggestion_database::entry::Kind::Method,
scope: suggestion_database::entry::Scope::Everywhere,
documentation_html: None,
icon_name: None,
}
}
@ -123,6 +124,7 @@ pub mod mock {
kind: suggestion_database::entry::Kind::Method,
scope: suggestion_database::entry::Scope::Everywhere,
documentation_html: None,
icon_name: None,
}
}
}

View File

@ -1,6 +1,6 @@
# Options intended to be common for all developers.
wasm-size-limit: 5.22 MiB
wasm-size-limit: 5.23 MiB
required-versions:
cargo-watch: ^8.1.1

View File

@ -140,6 +140,8 @@ type Table
Select columns with the same names as the ones provided.
table.select_columns (By_Column [column1, column2])
Icon: select_column
select_columns : Column_Selector -> Boolean -> Problem_Behavior -> Table
select_columns self (columns = By_Index [0]) (reorder = False) (on_problems = Report_Warning) =
new_columns = Table_Helpers.select_columns internal_columns=self.internal_columns selector=columns reorder=reorder on_problems=on_problems
@ -538,6 +540,8 @@ type Table
The resulting table contains rows of `self` extended with rows of
`other` with matching indexes. If the index in `other` is not unique,
the corresponding rows of `self` will be duplicated in the result.
Icon: join
join : Table | Column -> Nothing | Text | Column | Vector (Text | Column) -> Boolean -> Text -> Text -> Table
join self other on=Nothing drop_unmatched=False left_suffix='_left' right_suffix='_right' = case other of
Column _ _ _ _ _ -> self.join other.to_table on drop_unmatched left_suffix right_suffix

View File

@ -297,6 +297,8 @@ type Table
Select columns with the same names as the ones provided.
table.select_columns (By_Column [column1, column2])
Icon: select_column
select_columns : Column_Selector -> Boolean -> Problem_Behavior -> Table
select_columns self (columns = By_Index [0]) (reorder = False) (on_problems = Report_Warning) =
new_columns = Table_Helpers.select_columns internal_columns=self.columns selector=columns reorder=reorder on_problems=on_problems
@ -812,6 +814,8 @@ type Table
example_join =
Examples.inventory_table.join Examples.popularity_table
Icon: join
join : Table | Column.Column -> Text | Nothing -> Boolean -> Text -> Text -> Table
join self other on=Nothing drop_unmatched=False left_suffix='_left' right_suffix='_right' =
case other of

View File

@ -610,7 +610,7 @@ interface Tag {
name: string;
/** The tag text. */
text: HTMLString;
body: HTMLString;
}
/** The paragraph of the text.

View File

@ -19,6 +19,38 @@ where
serde_json::from_value(json_value).or_else(|_error| Ok(Ret::default()))
}
/// Deserialize a JSON value that is either of `Ret` type or equals `null`. A `null` is converted
/// to a default value of `Ret` type.
///
/// Example usage:
/// ```
/// # use serde::Deserialize;
/// # use enso_prelude::deserialize_null_as_default;
/// #[derive(Debug, Deserialize, PartialEq)]
/// struct Foo {
/// #[serde(default, deserialize_with = "deserialize_null_as_default")]
/// blah: Vec<i32>,
/// }
/// fn check_deserialized_eq(code: &str, expected_deserialized: &Foo) {
/// let deserialized = serde_json::from_str::<Foo>(code).unwrap();
/// assert_eq!(&deserialized, expected_deserialized);
/// }
/// let empty_foo = Foo { blah: vec![] };
/// check_deserialized_eq(r#"{"blah" : null }"#, &empty_foo);
/// check_deserialized_eq(r#"{}"#, &empty_foo);
/// check_deserialized_eq(r#"{"blah" : [] }"#, &empty_foo);
/// check_deserialized_eq(r#"{"blah" : [1,2,3] }"#, &Foo { blah: vec![1, 2, 3] });
/// ```
#[cfg(feature = "serde_json")]
pub fn deserialize_null_as_default<'d, Ret, D>(d: D) -> Result<Ret, D::Error>
where
for<'e> Ret: Default + Deserialize<'e>,
D: serde::Deserializer<'d>, {
let option_value = Option::deserialize(d)?;
Ok(option_value.unwrap_or_default())
}
#[cfg(test)]
mod tests {
use super::*;