Compare commits

...
Sign in to create a new pull request.

43 commits
main ... focus

Author SHA1 Message Date
7a98754e68 feat(items_list): add delete button styles and enhance delete functionality for items and properties 2025-06-05 14:39:20 +03:00
7064520b21 fix(items_list): attempt to resolve Bloodhound typeahead reinitialization issue
- Simplified the JavaScript code in the on_focus callback to ensure proper execution
- Added direct DOM manipulation in the on_input callback to force events to trigger
- Added explicit reinitialization of Bloodhound when an input receives focus
- Used a timeout to ensure DOM updates have time to propagate before triggering events
2025-06-04 16:24:11 +03:00
8799d5c144 fix(items_list): enhance input focus handling and reinitialize Bloodhound on focus 2025-06-04 14:13:56 +03:00
cd47be500f fix(items_list): correct id for last item check and improve logging for new item creation 2025-05-29 16:23:38 +03:00
bf5acde208 feat(items_list): add focused item tracking and update handling for item selection 2025-05-29 15:43:52 +03:00
764cb262fe fix(items_list): improve item update logic and add logging for better traceability 2025-05-28 15:57:27 +03:00
11add580e4 fix(items_list): ensure correct item updates and add new row handling for last item 2025-05-28 14:15:08 +03:00
de7c5c62c8 fix(items_list): fix new item addition with async handling and stabilization delay 2025-05-26 17:03:53 +03:00
d565563edb feat(items_list): add row after suggestion selection other than on input 2025-05-26 14:21:59 +03:00
5577f855ca feat(item_list): add new row on_select other than on_input. 2025-05-23 15:59:01 +03:00
a8088c232b feat(typeahead_input): enhance input handling by adding unique IDs and simplifying focus management 2025-05-23 14:53:25 +03:00
f9fe3eb980 fix(typeahead_input): simplify input value change handling by always calling on_input callback 2025-05-20 14:27:39 +03:00
eeb8e100e8 feat(typeahead_input): add focus management for input fields and enhance initialization 2025-05-19 15:53:03 +03:00
8cfef276df fix(docker): standardize environment variable definitions for Leptos configuration 2025-05-16 17:46:31 +03:00
462048db2f test(typeahead_input): add memory leak regression tests and lifecycle checks for components 2025-05-16 17:45:49 +03:00
55cfc1329c test(typeahead_input): enhance initialization test with fetch_suggestions callback and input event simulation 2025-05-15 15:46:40 +03:00
5f92db735e test(typeahead_input): add mock setups for Bloodhound and jQuery to enhance unit tests. 2025-05-14 17:10:41 +03:00
74e4252197 fix(typeahead_input): fix test component initialization and cleanup to solve build errors 2025-05-13 17:15:38 +03:00
c3bd3b1f27 docs(README): add section for running tests with native Rust and WASM 2025-05-12 17:32:41 +03:00
1334336377 test(wasm):add wasm-bindgen-test support 2025-05-12 17:32:22 +03:00
a2a1a48ea6 test(typeahead_input): add unit tests for TypeaheadInput component initialization, cleanup, and rapid mount/unmount 2025-05-09 17:40:23 +03:00
46b6cf82e2 feat(panic_hook): implement custom panic hook for enhanced diagnostics on Leptos owner disposal 2025-05-09 16:31:53 +03:00
24c138b866 fix(typeahead_input): add explicit alive flag and defensive js checks for component lifecycle management 2025-05-09 15:04:02 +03:00
e528f9e684 feat(utils): add utility function to safely execute closures with current Leptos owner 2025-05-09 14:11:15 +03:00
a69c51921b fix(typeahead_input): add check for valid Leptos owner before setting initialized state 2025-05-08 15:17:53 +03:00
dbca9a98c8 fix(typeahead): add defensive checks for valid Leptos owner in Bloodhound and Typeahead initialization 2025-05-07 17:04:33 +03:00
a9bfbf1c15 feat(typeahead_input): Refactor and enhance TypeaheadInput component for better lifecycle management and WASM compatibility. 2025-05-06 17:11:52 +03:00
09dd736082 fix(Leptos): Explicitly tell Leptos where to find the configuration 2025-04-28 16:33:21 +03:00
0f28394fce fix(typeahead): implement component isolation to prevent memory leaks and race conditions 2025-04-25 17:52:30 +03:00
1fd4131298 fix(memory issues): made the followng to help debug memory issues.
- Reduce Closure Captures: Take snapshots of signal values before using them in closures.
-Simplify Event Handlers: Break down complex event handlers into smaller, more manageable pieces.
-Improve Error Handling: Add proper error handling to prevent crashes when unexpected conditions occur.
-Optimize Data Flow: Ensure data flows in a more predictable way through the application.
-Isolate Side Effects: Move side effects like API calls into separate tasks to avoid blocking the main thread.

(in progress)
2025-04-24 18:03:45 +03:00
26724d9c45 feat(properties): add empty property check, placeholder labels and labels error handling 2025-04-24 17:18:47 +03:00
2d286e5834 fix(typeahead): add lifecycle management 2025-04-22 15:21:52 +03:00
37d157725e fix(typeahead): create a new row when typing in the name input field 2025-04-18 15:43:23 +03:00
7d36bac77f fix(typeahead): fix the handler name generation 2025-04-18 14:53:55 +03:00
fd0d4a5f38 fix(typeahead): bypass Bloodhound's AJAX to properly display typeahead suggestions 2025-04-17 17:51:58 +03:00
486bf9cbad feat(typeahead): update structure and enhance Typeahead integration and logging:
- Bloodhound Initialization:
  - Changed the suggestion object structure to a flattened format for Typeahead compatibility.
  - Removed nested `display` structure in favor of direct `displayLabel` and `displayDescription` fields.
  - Simplified the construction of suggestion objects by directly setting flattened fields.

- Typeahead Initialization:
  - Enhanced logging in the JavaScript initialization script for better debugging.
  - Updated the `display` function in Typeahead to use flattened fields (`displayLabel` and `displayDescription`) instead of nested `display` objects.
  - Improved error handling and logging for cases where suggestions are not arrays or are empty.

- Selection Handling:
  - Added detailed logging for selected suggestions in both Rust and JavaScript.
  - Ensured the input field is updated with the selected suggestion's label.

- Code Cleanup:
  - Removed redundant nested object creation for `display` fields in Bloodhound initialization.
  - Streamlined the JavaScript initialization script for better readability and maintainability.
  - Consolidated logging statements for consistent debugging output.
2025-04-14 17:42:04 +03:00
b017df9b35 buld(port): change ip adress to 3004 2025-04-11 16:20:40 +03:00
102f69fd29 fix(debug): remove buggy typeahead debug check 2025-04-11 16:16:16 +03:00
f646b92d3a feat(typeahead): enhance TypeaheadInput component for improved functionality
- Improved Bloodhound initialization:
  - Added nested display structure for suggestions (label and description).
  - Included a response filter to handle empty responses gracefully.
  - Added rate-limiting and wildcard support for remote queries.
- Enhanced Typeahead initialization:
  - Added custom templates for rendering suggestions with nested display fields.
  - Included debug logs for better traceability of Typeahead and Bloodhound instances.
  - Improved error handling for deserialization of suggestions.
- Added support for opening the dropdown on receiving suggestions.
- Updated input event handler to include additional debug checks for Typeahead instance.
- Ensured proper cleanup and memory management for closures and global handlers.
- Added custom CSS styles for `.typeahead` input and suggestion dropdown to enhance UI/UX.
2025-04-11 15:34:19 +03:00
07405db017 feat(typeahead): Enhance Bloodhound and Typeahead initialization logic
- Improved Bloodhound initialization by adding explicit global storage for the instance.
- Enhanced `initialize_bloodhound` to include proper rate limiting, wildcard configuration, and tokenizer setup.
- Refactored `initialize_typeahead` to include a more robust dataset configuration with templates for rendering suggestions.
- Fixed DOM element access in the `on:input` handler to ensure proper interaction with the input element.
- Simplified the `remote_fn` logic in `initialize_bloodhound` for fetching and syncing suggestions.
- Added error handling and logging for better debugging during Typeahead initialization.
- Ensured closures are properly registered in the global scope to handle Typeahead events.
- Updated the JavaScript initialization script to use bracket notation for safer handler invocation.
- Removed the additional inclusion of `corejs-typeahead` script from the `<head>` section.
2025-04-08 17:55:46 +03:00
5ca277ee80 feat(typeahead): Improve typeahead initialization and event handling 2025-04-08 02:30:58 +03:00
d6661c2ac9 feat(typeahead): use typeahead for name input field 2025-04-04 18:47:12 +03:00
4de14bb48b feat(input): update name input field to search for wiki suggestions without search wiki button 2025-04-02 22:07:24 +03:00
22 changed files with 3137 additions and 593 deletions

715
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -10,7 +10,9 @@ crate-type = ["cdylib", "rlib"]
actix-files = { version = "0.6", optional = true }
actix-web = { version = "4", optional = true, features = ["macros"] }
console_error_panic_hook = "0.1"
gloo-utils = "0.2"
http = { version = "1.0.0", optional = true }
js-sys = "0.3"
leptos = { version = "0.6" }
leptos_meta = { version = "0.6" }
leptos_actix = { version = "0.6", optional = true }
@ -27,6 +29,7 @@ gloo-net = "0.5"
gloo-timers = { version = "0.2", features = ["futures"] }
futures = "0.3"
wasm-bindgen-futures = "0.4"
wasm-bindgen-test = "0.3"
serde_json="1.0.133"
thiserror = "2.0.9"
zerofrom = "0.1"
@ -48,6 +51,13 @@ ssr = [
"dep:rusqlite"
]
# feature for wasm tests
wasm-test = [
"leptos/csr",
"leptos_meta/csr",
"leptos_router/csr",
]
# Override secp256k1's default features
[dependencies.secp256k1]
version = "0.30.0"
@ -77,8 +87,8 @@ style-file = "style/main.scss"
#
# Optional. Env: LEPTOS_ASSETS_DIR.
assets-dir = "assets"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
site-addr = "127.0.0.1:3000"
# The IP and port (ex: 127.0.0.1:3004) where the server serves the content. Use it in your server setup.
site-addr = "127.0.0.1:3004"
# The port to use for automatic reload monitoring
reload-port = 3001
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.

View file

@ -26,7 +26,7 @@ It combines Rust's **Leptos** for a modern, reactive frontend and **Nostr** for
```bash
cargo leptos serve
```
3. Open your browser at [localhost:3000](http://localhost:3000)
3. Open your browser at [localhost:3004](http://localhost:3004)
## **Database Schema**
### Key Concepts
@ -97,7 +97,57 @@ sequenceDiagram
```bash
docker-compose up -d
```
3. Access the application at: [http://localhost:3000](http://localhost:3000)
3. Access the application at: [http://localhost:3004](http://localhost:3004)
## **Running Tests**
CompareWare uses both native Rust tests and WebAssembly (WASM) tests for browser-based components.
### **Native Rust Tests**
To run native Rust tests (for server-side or non-WASM code):
```bash
cargo test
```
### **WASM (Browser) Tests**
To run tests for browser-based (Leptos/WASM) components, you need to use [`wasm-pack`](https://rustwasm.github.io/wasm-pack/installer/):
1. **Install wasm-pack** (if you haven't already):
```bash
cargo install wasm-pack
```
2. **Run tests in a headless browser (Chrome or Firefox):**
```bash
wasm-pack test --headless --chrome --features wasm-test --no-default-features
```
Or, for Firefox:
```bash
wasm-pack test --headless --firefox --features wasm-test --no-default-features
```
3. **Run tests in Node.js** (if your tests do not require a browser environment):
```bash
wasm-pack test --node --features wasm-test --no-default-features
```
#### **Notes**
- Make sure your test functions are annotated with `#[wasm_bindgen_test]` for WASM tests.
- If you add new test files, place them in the `tests/` directory or as modules in `src/` as appropriate.
- Warnings about unused variables or imports can be ignored unless they affect test results.
---
For more details on WASM testing, see the [wasm-bindgen-test documentation](https://rustwasm.github.io/wasm-bindgen/wasm-bindgen-test/usage.html).
### **Collaboration**
We welcome contributions! Heres how you can help:

View file

@ -155,4 +155,89 @@ th {
height: 100px;
resize: vertical;
overflow: auto;
}
}
.typeahead-container {
position: relative;
}
.tt-menu {
width: 100%;
background: white;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-top: 5px;
}
.tt-suggestion {
padding: 8px 12px;
cursor: pointer;
}
.tt-suggestion:hover {
background-color: #f5f5f5;
}
.tt-cursor {
background-color: #e9e9e9;
}
.tt-hint {
display: none !important;
}
.tt-menu {
background: white;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 5px 10px rgba(0,0,0,.2);
margin-top: 5px;
width: 100%;
}
.suggestion-item {
padding: 8px 12px;
cursor: pointer;
}
.suggestion-item.tt-cursor {
background-color: #f5f5f5;
}
/* Common styles for delete buttons */
.delete-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
border: none;
background-color: #ff4d4f;
color: white;
font-size: 16px;
font-weight: bold;
cursor: pointer;
margin-left: 8px;
transition: all 0.2s ease;
padding: 0;
line-height: 1;
}
.delete-button:hover {
background-color: #ff7875;
transform: scale(1.1);
}
.delete-button:active {
background-color: #d9363e;
transform: scale(0.95);
}
/* Specific styles for item delete buttons */
.item-delete {
vertical-align: middle;
}
/* Specific styles for property delete buttons */
.property-delete {
vertical-align: middle;
font-size: 14px;
}

View file

@ -2,10 +2,14 @@ services:
app:
build: .
ports:
- "3000:3000"
- "3004:3004"
volumes:
- ./compareware.db:/app/compareware.db
environment:
- RUST_LOG=info
- LEPTOS_ENV=production
- LEPTOS_SITE_ADDR=0.0.0.0:3004
- LEPTOS_SITE_ROOT=site
- LEPTOS_OUTPUT_NAME=compareware
- LEPTOS_OPTIONS={"site_addr":"0.0.0.0:3004"}
restart: unless-stopped

View file

@ -28,6 +28,8 @@ COPY . .
RUN rustup target add wasm32-unknown-unknown
# Build project
ENV LEPTOS_OUTPUT_NAME="compareware"
ENV LEPTOS_SITE_ADDR="0.0.0.0:3004"
ENV LEPTOS_SITE_ROOT="site"
# Build with release profile
RUN cargo leptos build --release
@ -50,7 +52,8 @@ COPY assets /app/assets
# Configure container, expose port and set entrypoint
WORKDIR /app
EXPOSE 3000
ENV LEPTOS_SITE_ADDR=0.0.0.0:3000
EXPOSE 3004
ENV LEPTOS_SITE_ADDR="0.0.0.0:3004"
ENV LEPTOS_SITE_ROOT="site"
ENV LEPTOS_OPTIONS='{"site_addr":"0.0.0.0:3004"}'
CMD ["./compareware"]

View file

@ -35,7 +35,7 @@ export default defineConfig({
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://localhost:3000',
// baseURL: 'http://localhost:3004',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
@ -99,6 +99,6 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// port: 3000,
// port: 3004,
// },
});

View file

@ -1,7 +1,7 @@
import { test, expect } from "@playwright/test";
test("homepage has title and links to intro page", async ({ page }) => {
await page.goto("http://localhost:3000/");
await page.goto("http://localhost:3004/");
await expect(page).toHaveTitle("Welcome to Leptos");

View file

@ -29,6 +29,10 @@ pub fn App() -> impl IntoView {
// }
// });
view! {
<head>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://twitter.github.io/typeahead.js/releases/latest/typeahead.bundle.min.js"></script>
</head>
<Router>
<Routes>
<Route path="/*url" view=move || {

View file

@ -10,12 +10,9 @@ use std::sync::Arc;
use wasm_bindgen::JsCast;
use std::rc::Rc;
use urlencoding::encode;
#[derive(Deserialize, Clone, Debug)]
struct WikidataSuggestion {
id: String,
label: String,
description: Option<String>,
}
use crate::components::typeahead_input::TypeaheadInput;
use crate::models::item::WikidataSuggestion;
use leptos::html::Input;
//function to load items from database
pub async fn load_items_from_db(current_url: &str) -> Result<Vec<Item>, String> {
@ -123,12 +120,16 @@ pub fn ItemsList(
items: ReadSignal<Vec<Item>>,
set_items: WriteSignal<Vec<Item>>,
) -> impl IntoView {
let node_ref = create_node_ref::<Input>();
// State to track selected properties
let (selected_properties, set_selected_properties) = create_signal(HashMap::<String, bool>::new());
// State to track the currently focused cell
let (focused_cell, set_focused_cell) = create_signal(None::<String>);
// State to track the currently focused item ID
let (focused_item_id, set_focused_item_id) = create_signal(None::<String>);
// State to manage dynamic property names
let (custom_properties, set_custom_properties) = create_signal(Vec::<String>::new());
@ -143,6 +144,20 @@ pub fn ItemsList(
// State to manage property cache
let (property_cache, set_property_cache) = create_signal(HashMap::<String, HashMap<String, String>>::new());
let (suggestions, set_suggestions) = create_signal(Vec::<WikidataSuggestion>::new());
// Set initial focus to the first item if it exists
create_effect(move |_| {
let items_list = items.get();
if let Some(first_item) = items_list.first() {
if focused_item_id.get().is_none() {
log!("Setting initial focus to first item: {}", first_item.id);
set_focused_item_id.set(Some(first_item.id.clone()));
}
}
});
#[cfg(feature = "ssr")]
fn get_current_url() -> String {
use leptos::use_context;
@ -206,12 +221,18 @@ pub fn ItemsList(
// Fetch labels for the custom properties
let property_ids = custom_props_clone;
let labels = fetch_property_labels(property_ids).await;
set_property_labels.update(|labels_map| {
for (key, value) in labels {
labels_map.insert(key, value);
match fetch_property_labels(property_ids).await {
Ok(labels) => {
set_property_labels.update(|labels_map| {
for (key, value) in labels {
labels_map.insert(key, value);
}
});
},
Err(e) => {
log!("Error fetching property labels: {:?}", e);
}
});
}
// log!("Items after loading: {:?}", items.get());
}
@ -354,32 +375,38 @@ pub fn ItemsList(
let (wikidata_suggestions, set_wikidata_suggestions) = create_signal(HashMap::<String, Vec<WikidataSuggestion>>::new());
// Function to fetch Wikidata suggestions
let fetch_wikidata_suggestions = move |key: String, query: String| {
log!("Fetching suggestions for key: {}, query: {}", key, query);
spawn_local(async move {
if query.is_empty() {
set_wikidata_suggestions.update(|suggestions| {
suggestions.remove(&key);
});
return;
}
let fetch_wikidata_suggestions = {
let set_wikidata_suggestions = set_wikidata_suggestions.clone();
move |key: String, query: String| {
log!("Fetching suggestions for key: {}, query: {}", key, query);
let set_wikidata_suggestions_clone = set_wikidata_suggestions.clone();
let url = format!(
"https://www.wikidata.org/w/api.php?action=wbsearchentities&search={}&language=en&limit=5&format=json&origin=*",
query
);
match gloo_net::http::Request::get(&url).send().await {
Ok(response) => {
if let Ok(data) = response.json::<WikidataResponse>().await {
set_wikidata_suggestions.update(|suggestions| {
suggestions.insert(key, data.search);
});
}
spawn_local(async move {
if query.is_empty() {
set_wikidata_suggestions_clone.update(|suggestions| {
suggestions.remove(&key);
});
return;
}
Err(_) => log!("Failed to fetch Wikidata suggestions"),
}
});
let url = format!(
"https://www.wikidata.org/w/api.php?action=wbsearchentities&search={}&language=en&limit=5&format=json&origin=*",
query
);
match gloo_net::http::Request::get(&url).send().await {
Ok(response) => {
if let Ok(data) = response.json::<WikidataResponse>().await {
set_wikidata_suggestions_clone.update(|suggestions| {
suggestions.insert(key, data.search);
});
}
}
Err(_) => log!("Failed to fetch Wikidata suggestions"),
}
});
}
};
//function to fetch properties
@ -449,10 +476,16 @@ pub fn ItemsList(
.collect();
if !missing_ids.is_empty() {
let new_labels = fetch_property_labels(missing_ids).await;
set_property_labels.update(|labels| {
labels.extend(new_labels.clone());
});
match fetch_property_labels(missing_ids).await {
Ok(new_labels) => {
set_property_labels.update(|labels| {
labels.extend(new_labels.clone());
});
},
Err(e) => {
log!("Error fetching property labels: {:?}", e);
}
}
}
// Second pass: build results
@ -494,7 +527,7 @@ pub fn ItemsList(
}
}
async fn fetch_property_labels(property_ids: Vec<String>) -> HashMap<String, String> {
async fn fetch_property_labels(property_ids: Vec<String>) -> Result<HashMap<String, String>, String> {
log!("Fetching property labels for properties: {:?}", property_ids);
// Remove the "http://www.wikidata.org/prop/" prefix from property IDs
@ -529,7 +562,7 @@ pub fn ItemsList(
log!("Received response from Wikidata. Status: {}", response.status());
if response.status() != 200 {
log!("Error: Unexpected status code {}", response.status());
return HashMap::new();
return Err(format!("Unexpected status code: {}", response.status()));
}
match response.text().await {
@ -557,23 +590,23 @@ pub fn ItemsList(
log!("Warning: No bindings found in the response");
}
log!("Fetched {} property labels", result.len());
result
Ok(result)
}
Err(e) => {
log!("Error parsing response from Wikidata: {:?}", e);
HashMap::new()
Err(format!("Error parsing response: {:?}", e))
}
}
}
Err(e) => {
log!("Error reading response body: {:?}", e);
HashMap::new()
Err(format!("Error reading response body: {:?}", e))
}
}
}
Err(e) => {
log!("Error fetching property labels from Wikidata: {:?}", e);
HashMap::new()
Err(format!("Error fetching property labels: {:?}", e))
}
}
}
@ -585,41 +618,65 @@ pub fn ItemsList(
let set_property_labels = set_property_labels.clone();
let property_cache = property_cache.clone();
let set_property_cache = set_property_cache.clone();
Arc::new(move |property: String| {
// Normalize the property ID
let normalized_property = property.replace("http://www.wikidata.org/prop/", "");
let normalized_property_clone = normalized_property.clone();
// Check if label already exists
if !property_labels.get().contains_key(&normalized_property) {
spawn_local({
let normalized_property = normalized_property.clone();
let set_property_labels = set_property_labels.clone();
async move {
let labels = fetch_property_labels(vec![normalized_property.clone()]).await;
set_property_labels.update(|map| {
map.extend(labels);
});
}
});
}
let set_custom_properties = set_custom_properties.clone();
let set_selected_properties = set_selected_properties.clone();
// Check if property is already selected
if !selected_properties.get().contains_key(&normalized_property) && !normalized_property.is_empty() {
// Add property to selected properties
set_selected_properties.update(|selected| {
selected.insert(normalized_property.clone(), true);
});
// Save the selected property to the database
spawn_local({
let current_url = Rc::clone(&current_url);
let normalized_property = normalized_property_clone.clone();
async move {
Arc::new(move |property: String| {
// Normalize the property ID
let normalized_property = property.replace("http://www.wikidata.org/prop/", "");
// Early return if property is empty
if normalized_property.is_empty() {
log!("Attempted to add empty property, ignoring");
return;
}
// Create local copies of all signals to avoid capturing them in closures
let property_labels_snapshot = property_labels.get();
let selected_properties_snapshot = selected_properties.get();
let custom_properties_snapshot = custom_properties.get();
// Check if label already exists
if !property_labels_snapshot.contains_key(&normalized_property) {
let normalized_property_clone = normalized_property.clone();
let set_property_labels_clone = set_property_labels.clone();
// Add a placeholder label immediately
set_property_labels.update(|map| {
map.insert(normalized_property_clone.clone(), normalized_property_clone.clone());
});
// Fetch the actual label in a separate task
spawn_local(async move {
match fetch_property_labels(vec![normalized_property_clone.clone()]).await {
Ok(labels) => {
set_property_labels_clone.update(|map| {
map.extend(labels);
});
},
Err(e) => {
log!("Error fetching property labels: {:?}", e);
}
}
});
}
// Check if property is already selected
if !selected_properties_snapshot.contains_key(&normalized_property) {
// Add property to selected properties
set_selected_properties.update(|selected| {
selected.insert(normalized_property.clone(), true);
});
// Save the selected property to the database
let current_url_clone = Rc::clone(&current_url);
let normalized_property_clone = normalized_property.clone();
spawn_local(async move {
let response = gloo_net::http::Request::post(
&format!("/api/urls/{}/properties", encode(&current_url))
&format!("/api/urls/{}/properties", encode(&current_url_clone))
)
.json(&normalized_property)
.json(&normalized_property_clone)
.unwrap()
.send()
.await;
@ -636,82 +693,84 @@ pub fn ItemsList(
log!("Error saving property: {:?}", err);
}
}
}
});
}
set_custom_properties.update(|props| {
if !props.contains(&normalized_property) && !normalized_property.is_empty() {
props.push(normalized_property.clone());
//update the selected_properties state when a new property is added
set_selected_properties.update(|selected| {
selected.insert(normalized_property.clone(), true);
});
// Ensure the grid updates reactively
}
// Update custom properties if not already present
if !custom_properties_snapshot.contains(&normalized_property) {
set_custom_properties.update(|props| {
props.push(normalized_property.clone());
});
// Update items with the new property
set_items.update(|items| {
for item in items {
item.custom_properties.entry(normalized_property.clone()).or_insert_with(|| "".to_string());
// Save the updated item to the database
let item_clone = item.clone();
spawn_local({
let current_url = Rc::clone(&current_url);
async move {
save_item_to_db(item_clone, selected_properties, current_url.to_string()).await;
}
});
// Only add if it doesn't exist
if !item.custom_properties.contains_key(&normalized_property) {
item.custom_properties.insert(normalized_property.clone(), "".to_string());
}
}
});
// Use the property label from the property_labels signal
let property_label = property_labels.get().get(&normalized_property).cloned().unwrap_or_else(|| normalized_property.clone());
// Save each item to the database
let items_snapshot = items.get();
for item in items_snapshot {
let item_clone = item.clone();
let current_url_clone = Rc::clone(&current_url);
let selected_properties_clone = selected_properties;
spawn_local(async move {
save_item_to_db(item_clone, selected_properties_clone, current_url_clone.to_string()).await;
});
}
// Log the addition
let property_label = property_labels_snapshot
.get(&normalized_property)
.cloned()
.unwrap_or_else(|| normalized_property.clone());
log!("Added property with label: {}", property_label);
}
});
// Fetch the relevant value for each item and populate the corresponding cells
set_items.update(|items| {
for item in items {
// Initialize property with empty string if it doesn't exist
item.custom_properties.entry(normalized_property.clone())
.or_insert_with(|| "".to_string());
// Only fetch properties if Wikidata ID exists
// Fetch Wikidata properties for items with IDs
let items_snapshot = items.get();
for item in items_snapshot {
if let Some(wikidata_id) = &item.wikidata_id {
let wikidata_id = wikidata_id.clone();
let set_items = set_items.clone();
let set_fetched_properties = set_fetched_properties.clone();
let property_clone = normalized_property.clone();
let wikidata_id_clone = wikidata_id.clone();
let normalized_property_clone = normalized_property.clone();
let set_items_clone = set_items.clone();
let set_property_labels_clone = set_property_labels.clone();
let property_cache_clone = property_cache.clone();
let set_property_cache_clone = set_property_cache.clone();
let property_labels_clone = property_labels.clone();
spawn_local(async move {
let properties = fetch_item_properties(
&wikidata_id,
set_property_labels.clone(),
property_cache.clone(),
set_property_cache.clone(),
property_labels.clone()
&wikidata_id_clone,
set_property_labels_clone,
property_cache_clone,
set_property_cache_clone,
property_labels_clone
).await;
// Update the specific property for this item
if let Some(value) = properties.get(&property_clone) {
set_items.update(|items| {
if let Some(item) = items.iter_mut()
.find(|i| i.wikidata_id.as_ref() == Some(&wikidata_id))
{
item.custom_properties.insert(
property_clone.clone(),
value.clone()
);
if let Some(value) = properties.get(&normalized_property_clone) {
set_items_clone.update(|items| {
for item in items {
if item.wikidata_id.as_ref() == Some(&wikidata_id_clone) {
item.custom_properties.insert(
normalized_property_clone.clone(),
value.clone()
);
}
}
});
}
});
}
}
});
})};
})
};
// Update item fields
let update_item = {
@ -795,127 +854,285 @@ pub fn ItemsList(
view! {
<th>
{item.name.clone()}
<button on:click=move |_| remove_item(index)>{ "Delete" }</button>
<button
class="delete-button item-delete"
on:click=move |_| remove_item(index)
title="Delete item"
>{ "×" }</button>
</th>
}
}).collect::<Vec<_>>()}
</tr>
</thead>
<tbody>
{properties.into_iter().map(|property| {
let update_item_cloned = Arc::clone(&update_item);
log!("Rendering property: {}", property);
view! {
<tr>
<td>{ property }</td>
{move || items.get().iter().enumerate().map(|(index, item)| {
let update_item_clone = Arc::clone(&update_item_cloned);
{properties.into_iter().map(|property| {
let update_item_cloned = Arc::clone(&update_item);
let current_url_for_closure = Rc::clone(&current_url);
log!("Rendering property: {}", property);
view! {
<tr>
<td>{ property }</td>
{{
// Clone current_url before the nested closure
let current_url_for_inner = Rc::clone(&current_url_for_closure);
move || {
let items_signal = items;
let items = items.get();
items.iter().enumerate().map(|(index, item)| {
let update_item_clone = Arc::clone(&update_item_cloned);
let current_url_clone = Rc::clone(&current_url_for_inner);
view! {
<td>
{match property {
"Name" => view! {
<div class="editable-cell">
<EditableCell
value=item.name.clone()
on_input=move |value| {
update_item_clone(index, "name", value.clone());
fetch_wikidata_suggestions(format!("name-{}", index), value);
<div class="typeahead-container"
// click handler at the container level
on:click={
let item_id = item.id.clone();
let set_focused_item_id = set_focused_item_id.clone();
move |_| {
log!("Container clicked: item_id={}", item_id);
set_focused_item_id.set(Some(item_id.clone()));
}
key=Arc::new(format!("name-{}", index))
focused_cell=focused_cell
set_focused_cell=set_focused_cell.clone()
on_focus=Some(Callback::new(move |_| {
log!("Input focused, showing suggestions");
set_show_suggestions.update(|suggestions| {
suggestions.insert(format!("name-{}", index), true);
});
}))
on_blur=Some(Callback::new(move |_| {
log!("Input blurred, delaying hiding suggestions");
spawn_local(async move {
gloo_timers::future::sleep(std::time::Duration::from_millis(500)).await;
log!("Hiding suggestions after delay");
set_show_suggestions.update(|suggestions| {
suggestions.insert(format!("name-{}", index), false);
}
>
<TypeaheadInput
key=item.id.clone()
value=item.name.clone()
fetch_suggestions=Callback::new({
// Use the item's unique ID in the key to ensure uniqueness
let key = format!("name-{}-{}", index, item.id);
let wikidata_suggestions_clone = wikidata_suggestions.clone();
move |query: String| -> Vec<WikidataSuggestion> {
// Only fetch suggestions if the query is not empty
if !query.is_empty() {
// Fetch suggestions in a separate function to avoid capturing too much
fetch_wikidata_suggestions(key.clone(), query.clone());
} else {
// Clear suggestions for this key if query is empty
set_wikidata_suggestions.update(|suggestions| {
suggestions.remove(&key);
});
});
}))
input_type=InputType::Text
/>
<button class="search-icon" on:click=move |_| {
log!("Search icon clicked, showing suggestions");
set_show_suggestions.update(|suggestions| {
suggestions.insert(format!("name-{}", index), true);
});
}>
<i class="fas fa-search"></i> Search Wiki
</button>
{move || {
if *show_suggestions.get().get(&format!("name-{}", index)).unwrap_or(&false) {
log!("Rendering suggestions list");
view! {
<ul class="editable-cell-suggestions">
{move || {
let suggestions = wikidata_suggestions.get()
.get(&format!("name-{}", index))
.cloned()
.unwrap_or_default();
log!("Suggestions for cell {}: {:?}", index, suggestions);
suggestions.into_iter().map(|suggestion| {
let label_for_click = suggestion.label.clone();
let label_for_display = suggestion.label.clone();
let description_for_click = suggestion.description.clone().unwrap_or_default();
let description_for_display = suggestion.description.clone().unwrap_or_default();
let id = suggestion.id.clone();
view! {
<li class="editable-cell-suggestions-li" on:click=move |_| {
// Update item with basic suggestion details
set_items.update(|items| {
if let Some(item) = items.get_mut(index) {
item.description = description_for_click.clone();
item.wikidata_id = Some(id.clone());
item.name = label_for_click.clone();
}
});
// Fetch additional properties from Wikidata
let wikidata_id = id.clone();
spawn_local(async move {
let properties = fetch_item_properties(&wikidata_id, set_property_labels.clone(), property_cache.clone(), set_property_cache.clone(), property_labels.clone()).await;
// log!("Fetched properties for Wikidata ID {}: {:?}", wikidata_id, properties);
// Populate the custom properties for the new item
set_items.update(|items| {
if let Some(item) = items.iter_mut().find(|item| item.wikidata_id.as_ref() == Some(&wikidata_id)) {
for (property, value) in properties {
item.custom_properties.insert(property, value);
}
}
});
});
// Hide the suggestion list
set_show_suggestions.update(|suggestions| {
suggestions.insert(format!("name-{}", index), false);
log!("Updated show_suggestions: {:?}", suggestions);
});
}>
{ format!("{} - {}", label_for_display, description_for_display) }
</li>
}
}).collect::<Vec<_>>()
}}
</ul>
}
} else {
log!("Suggestions list hidden");
view! {
<ul></ul>
}
// Return current suggestions from the signal
let suggestions = wikidata_suggestions_clone.get();
suggestions.get(&key).cloned().unwrap_or_default()
}
}}
})
on_select=Callback::new({
let set_items_clone = set_items.clone();
let set_property_labels_clone = set_property_labels.clone();
let property_cache_clone = property_cache.clone();
let set_property_cache_clone = set_property_cache.clone();
let property_labels_clone = property_labels.clone();
let current_url_clone = Rc::clone(&current_url_clone);
let selected_properties_clone = selected_properties.clone();
let items_signal_clone = items_signal.clone();
let focused_item_id_clone = focused_item_id.clone();
let set_focused_item_id = set_focused_item_id.clone();
let item_id = item.id.clone();
move |suggestion: WikidataSuggestion| {
let wikidata_id = suggestion.id.clone();
// Get the currently focused item ID
let current_item_id = focused_item_id_clone.get()
.unwrap_or_else(|| {
// Set it for future use
set_focused_item_id.set(Some(item_id.clone()));
item_id.clone()
});
log!("on_select called for focused_item_id={:?}", current_item_id);
// Update the item with the focused ID
set_items_clone.update(|items| {
log!("Items before update: {:?}", items.iter().map(|i| &i.id).collect::<Vec<_>>());
// Find the item by ID
if let Some(item) = items.iter_mut().find(|item| item.id == current_item_id) {
log!("Updating item with id={}", item.id);
item.name = suggestion.display.label.value.clone();
item.description = suggestion.display.description.value.clone();
item.wikidata_id = Some(wikidata_id.clone());
} else {
log!("No item found with id={}", item_id);
}
log!("Items after update: {:?}", items.iter().map(|i| &i.id).collect::<Vec<_>>());
});
// Check if this is the last item
let items_vec = items_signal_clone.get();
let is_last_item = items_vec.last()
.map(|last| last.id == current_item_id)
.unwrap_or(false);
// Fetch properties in a separate task
let set_property_labels_for_task = set_property_labels_clone.clone();
let property_cache_for_task = property_cache_clone.clone();
let set_property_cache_for_task = set_property_cache_clone.clone();
let property_labels_for_task = property_labels_clone.clone();
let wikidata_id_for_task = wikidata_id.clone();
spawn_local(async move {
fetch_item_properties(
&wikidata_id_for_task,
set_property_labels_for_task,
property_cache_for_task,
set_property_cache_for_task,
property_labels_for_task
).await;
});
// Add a new row if this is the last row
if is_last_item {
// Clone before moving into the async block
let set_items_for_new_item = set_items_clone.clone();
let current_url_for_async = Rc::clone(&current_url_clone);
let selected_properties_for_async = selected_properties_clone.clone();
// Use a small delay to ensure clean component lifecycle
spawn_local(async move {
// Wait for the current update to complete and component to stabilize
gloo_timers::future::TimeoutFuture::new(10).await;
// Create a new item with empty values
let new_item = Item {
id: Uuid::new_v4().to_string(),
name: String::new(),
description: String::new(),
wikidata_id: None,
custom_properties: HashMap::new(),
};
let new_item_id = new_item.id.clone();
log!("Creating new item with id={}", new_item_id);
// Clone for database save
let new_item_clone = new_item.clone();
// Add the new item in a separate update to force re-rendering
set_items_for_new_item.update(|items| {
items.push(new_item);
});
// Save the new item to the database in a separate task
spawn_local(async move {
save_item_to_db(
new_item_clone,
selected_properties_for_async,
current_url_for_async.to_string()
).await;
});
});
}
}
})
is_last_row={index == items.len() - 1}
on_input=Callback::new({
let item_id = item.id.clone();
let input_id = format!("name-input-{}-{}", index, item.id);
let key = format!("name-{}-{}", index, item.id);
// Create a direct reference to fetch_wikidata_suggestions
let fetch_wikidata_suggestions_clone = fetch_wikidata_suggestions.clone();
move |value: String| {
// Trigger the Rust-side fetch
fetch_wikidata_suggestions_clone(key.clone(), value.clone());
// Force Bloodhound to update
let js_code = format!(
r#"
try {{
// Get the input element
const inputElement = document.getElementById("{}");
if (!inputElement) {{
console.error("[RUST] Input element not found:", "{}");
return;
}}
// Set the value and trigger an input event
inputElement.value = "{}";
const event = new Event("input", {{ bubbles: true }});
inputElement.dispatchEvent(event);
console.log("[RUST] Triggered input event for {} with value: {}", "{}", "{}");
}} catch (e) {{
console.error("[RUST] Error triggering input event:", e);
}}
"#,
input_id, input_id,
value,
input_id, value, input_id, value
);
// Execute the JavaScript
let _ = js_sys::eval(&js_code);
}
})
on_focus=Callback::new({
let item_id = item.id.clone();
let set_focused_item_id = set_focused_item_id.clone();
let input_id = format!("name-input-{}-{}", index, item.id);
move |_| {
log!("Name input focused: item_id={}", item_id);
set_focused_item_id.set(Some(item_id.clone()));
// Simplified JavaScript code to reinitialize Bloodhound
let js_code = format!(
r##"
try {{
// Get the input element
const inputElement = document.getElementById("{}");
if (!inputElement) {{
console.error("[RUST] Input element not found:", "{}");
return;
}}
// Force Bloodhound to reinitialize
if (typeof window.initTypeahead === "function") {{
window.initTypeahead("#{}", "{}");
console.log("[RUST] Reinitialized Bloodhound for {}", "{}");
// Force a query if there's a value
if (inputElement.value) {{
setTimeout(function() {{
// Trigger an input event
const event = new Event("input", {{ bubbles: true }});
inputElement.dispatchEvent(event);
console.log("[RUST] Forced input event for {} with value: {}", "{}", inputElement.value);
}}, 50);
}}
}}
}} catch (e) {{
console.error("[RUST] Error in focus handler:", e);
}}
"##,
input_id, input_id,
input_id, input_id, input_id, input_id,
input_id, input_id, input_id
);
// Execute the JavaScript
let _ = js_sys::eval(&js_code);
}
})
node_ref=node_ref.clone()
id=format!("name-input-{}-{}", index, item.id)
/>
</div>
}.into_view(),
"Description" => view! {
<EditableCell
value=item.description.clone()
@ -938,10 +1155,12 @@ pub fn ItemsList(
}}
</td>
}
}).collect::<Vec<_>>()}
</tr>
}
}).collect::<Vec<_>>()}
}).collect::<Vec<_>>()
}
}}
</tr>
}
}).collect::<Vec<_>>()}
// Dynamically adding custom properties as columns
{{
let update_item_outer = Arc::clone(&update_item);
@ -961,26 +1180,31 @@ pub fn ItemsList(
<tr>
<td>
{ property_label }
<button class="delete-property" on:click=move |_| {
log!("Deleting property: {}", property_clone_for_button);
remove_property_clone(property_clone_for_button.clone());
set_custom_properties.update(|props| {
props.retain(|p| p != &property_clone_for_button);
});
set_selected_properties.update(|selected| {
selected.remove(&property_clone_for_button);
});
set_items.update(|items| {
for item in items {
item.custom_properties.remove(&property_clone_for_button);
}
});
}>{ "Delete" }</button>
<button
class="delete-button property-delete"
title="Delete property"
on:click=move |_| {
log!("Deleting property: {}", property_clone_for_button);
remove_property_clone(property_clone_for_button.clone());
set_custom_properties.update(|props| {
props.retain(|p| p != &property_clone_for_button);
});
set_selected_properties.update(|selected| {
selected.remove(&property_clone_for_button);
});
set_items.update(|items| {
for item in items {
item.custom_properties.remove(&property_clone_for_button);
}
});
}
>{ "x" }</button>
</td>
{move || {
let update_item_cell = Arc::clone(&update_item_inner);
let property_clone_for_cells = normalized_property.clone();
items.get().iter().enumerate().map(move |(index, item)| {
let items = items.get();
items.iter().enumerate().map(move |(index, item)| {
let update_item_cell = Arc::clone(&update_item_cell);
let property_clone_for_closure = property_clone_for_cells.clone();
view! {
@ -1008,41 +1232,58 @@ pub fn ItemsList(
</tbody>
</table>
<div style="margin-bottom: 20px;">
<input type="text" id="new-property" placeholder="Add New Property" list="properties" on:keydown=move |event| {
if event.key() == "Enter" {
let input_element = event.target().unwrap().dyn_into::<web_sys::HtmlInputElement>().unwrap();
let input_value = input_element.value();
// Extract property ID from "Label (P123)" format
let property_id = input_value
.split(" (")
.last()
.and_then(|s| s.strip_suffix(')'))
.unwrap_or(&input_value)
.to_string();
if !property_id.is_empty() {
// Add the property using the extracted ID
add_property(property_id);
input_element.set_value("");
<input
type="text"
id="new-property"
placeholder="Add New Property"
list="properties"
on:keydown=move |event| {
if event.key() == "Enter" {
// Safely get the input element
if let Some(target) = event.target() {
if let Ok(input_element) = target.dyn_into::<web_sys::HtmlInputElement>() {
let input_value = input_element.value();
// Extract property ID from "Label (P123)" format
let property_id = if input_value.contains(" (") && input_value.ends_with(')') {
let parts: Vec<&str> = input_value.rsplitn(2, " (").collect();
if parts.len() == 2 {
parts[0].trim_end_matches(')').to_string()
} else {
input_value.clone()
}
} else {
input_value.clone()
};
if !property_id.is_empty() {
// Add the property using the extracted ID
add_property(property_id);
input_element.set_value("");
}
}
}
}
}
} />
}
/>
<datalist id="properties">
{move || {
let property_labels = property_labels.get().clone();
property_labels.into_iter().map(|(property_id, label)| {
view! {
<option value={format!("{} ({})", label, property_id)}>
{ format!("{} ({})", label, property_id) }
</option>
}
}).collect::<Vec<_>>()
let property_labels_snapshot = property_labels.get();
property_labels_snapshot.iter()
.map(|(property_id, label)| {
let option_value = format!("{} ({})", label, property_id);
view! {
<option value={option_value.clone()}>
{option_value}
</option>
}
})
.collect::<Vec<_>>()
}}
</datalist>
</div>
</div>
}
}
}
#[derive(Deserialize, Clone, Debug)]

View file

@ -1,2 +1,3 @@
pub mod items_list;
pub mod editable_cell;
pub mod editable_cell;
pub mod typeahead_input;

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,8 @@ pub mod components;
pub mod models;
pub mod nostr;
pub mod api;
pub mod utils;
#[cfg(feature = "ssr")]
pub mod db;

View file

@ -2,10 +2,15 @@
use actix_web::{web, HttpResponse, Responder};
use std::sync::Arc;
use tokio::sync::Mutex;
#[cfg(feature = "ssr")]
use compareware::db::Database;
use compareware::api::{ItemRequest,create_item, get_items, get_selected_properties, add_selected_property};
#[cfg(feature = "ssr")]
use compareware::api::{ItemRequest, create_item, get_items, get_selected_properties, add_selected_property};
#[cfg(feature = "ssr")]
use compareware::models::item::Item;
use compareware::utils::panic_hook;
#[cfg(feature = "ssr")]
#[actix_web::main]
async fn main() -> std::io::Result<()> {
use actix_files::Files;
@ -17,6 +22,11 @@ async fn main() -> std::io::Result<()> {
use compareware::api::{delete_item, delete_property}; // Import API handlers
use std::sync::Arc;
use tokio::sync::Mutex;
panic_hook::init();
// Setup logging
std::env::set_var("RUST_LOG", "info");
// Initialize the database
let db = Database::new("compareware.db").unwrap();
@ -24,11 +34,10 @@ async fn main() -> std::io::Result<()> {
let db = Arc::new(Mutex::new(db)); // Wrap the database in an Arc<Mutex<T>> for shared state
println!("Schema created successfully!");
// Load configuration
let conf = get_configuration(None).await.unwrap();
// Load configuration
let conf = get_configuration(Some("Cargo.toml")).await.unwrap();
let addr = conf.leptos_options.site_addr;
// Generate the list of routes in your Leptos App
let routes = generate_route_list(App);
println!("listening on http://{}", &addr);
@ -79,6 +88,7 @@ async fn main() -> std::io::Result<()> {
.await
}
#[cfg(feature = "ssr")]
// Handler to get items for a specific URL
async fn get_items_handler(
db: web::Data<Arc<Mutex<Database>>>,
@ -87,6 +97,7 @@ async fn get_items_handler(
get_items(db, web::Query(url.into_inner())).await
}
#[cfg(feature = "ssr")]
// Handler to create an item for a specific URL
async fn create_item_handler(
db: web::Data<Arc<Mutex<Database>>>,
@ -125,11 +136,13 @@ async fn add_selected_property_handler(
) -> impl Responder {
add_selected_property(db, url, property).await
}
#[cfg(feature = "ssr")]
// Define the index handler
async fn index() -> HttpResponse {
HttpResponse::Ok().body("Welcome to CompareWare!")
}
#[cfg(feature = "ssr")]
// Define the URL handler
async fn url_handler(url: web::Path<String>) -> HttpResponse {
@ -160,12 +173,15 @@ pub fn main() {
#[cfg(all(not(feature = "ssr"), feature = "csr"))]
pub fn main() {
// Initialize custom panic hook for better diagnostics
panic_hook::init();
// a client-side main function is required for using `trunk serve`
// prefer using `cargo leptos serve` instead
// to run: `trunk serve --open --features csr`
use compareware::app::*;
console_error_panic_hook::set_once();
// console_error_panic_hook::set_once();
leptos::mount_to_body(App);
}

View file

@ -10,3 +10,40 @@ pub struct Item {
pub wikidata_id: Option<String>,
pub custom_properties: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WikidataSuggestion {
pub id: String,
#[serde(default)]
pub label: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub title: String,
#[serde(default, rename = "display")]
pub display: DisplayInfo,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct DisplayInfo {
#[serde(default, rename = "label")]
pub label: LabelInfo,
#[serde(default, rename = "description")]
pub description: DescriptionInfo,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct LabelInfo {
#[serde(default, rename = "value")]
pub value: String,
#[serde(default, rename = "language")]
pub language: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct DescriptionInfo {
#[serde(default, rename = "value")]
pub value: String,
#[serde(default, rename = "language")]
pub language: String,
}

13
src/utils/leptos_owner.rs Normal file
View file

@ -0,0 +1,13 @@
/// Utility to safely execute a closure with the current Leptos owner.
/// If the owner is disposed, logs and returns None.
pub fn with_owner_safe<F, R>(log_context: &str, f: F) -> Option<R>
where
F: FnOnce() -> R,
{
if let Some(owner) = leptos::Owner::current() {
leptos::try_with_owner(owner, f).ok()
} else {
leptos::logging::log!("[OWNER] No Leptos owner in context: {}", log_context);
None
}
}

2
src/utils/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod leptos_owner;
pub mod panic_hook;

54
src/utils/panic_hook.rs Normal file
View file

@ -0,0 +1,54 @@
use std::panic;
use leptos::logging::log;
use wasm_bindgen::prelude::*;
/// Sets up a custom panic hook that provides more context for Leptos owner disposal panics
pub fn set_custom_panic_hook() {
let original_hook = panic::take_hook();
panic::set_hook(Box::new(move |panic_info| {
// Call the original hook first
original_hook(panic_info);
// Extract panic message
let message = if let Some(s) = panic_info.payload().downcast_ref::<String>() {
s.clone()
} else if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
s.to_string()
} else {
"Unknown panic".to_string()
};
// Check if this is an owner disposal panic
if message.contains("OwnerDisposed") {
log!("[PANIC] Leptos owner disposal detected. This usually happens when:");
log!("[PANIC] 1. A component has been unmounted but JavaScript is still calling into Rust");
log!("[PANIC] 2. An effect or signal update is running after the component is gone");
log!("[PANIC] 3. A closure or callback is being called after cleanup");
// Log current component registry state
let js_code = r#"
if (window.typeaheadRegistry) {
console.log('[PANIC] Current typeahead registry:',
Object.keys(window.typeaheadRegistry).map(id => ({
id,
alive: window.typeaheadRegistry[id].alive,
initialized: window.typeaheadRegistry[id].initialized
}))
);
} else {
console.log('[PANIC] No typeahead registry found');
}
"#;
let _ = js_sys::eval(js_code);
}
}));
}
/// Call in main.rs or app initialization
pub fn init() {
log!("[PANIC_HOOK] Setting up custom panic hook");
set_custom_panic_hook();
log!("[PANIC_HOOK] Custom panic hook set up successfully");
}

View file

@ -0,0 +1,154 @@
use wasm_bindgen::prelude::*;
/// This module provides mock implementations for JavaScript dependencies
/// that are used in the TypeaheadInput component.
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
pub fn log(s: &str);
}
/// JavaScript functions for mocking Bloodhound
#[wasm_bindgen]
extern "C" {
/// Injects the Bloodhound mock into the window object
#[wasm_bindgen(js_name = setup_bloodhound_mock)]
#[doc(hidden)]
fn _setup_bloodhound_mock() -> bool;
/// Gets the size of the typeahead registry
#[wasm_bindgen(js_name = get_registry_size)]
#[doc(hidden)]
fn _get_registry_size() -> usize;
/// Cleans up a specific component from the registry
#[wasm_bindgen(js_name = cleanup_typeahead_registry)]
#[doc(hidden)]
fn _cleanup_typeahead_registry(component_id: &str) -> bool;
/// Cleans up the entire typeahead registry
#[wasm_bindgen(js_name = cleanup_all_typeahead_registry)]
#[doc(hidden)]
fn _cleanup_all_typeahead_registry() -> usize;
}
/// Injects the Bloodhound mock into the window object
pub fn setup_bloodhound_mock() -> bool {
#[wasm_bindgen(inline_js = r#"
export function setup_bloodhound_mock() {
// Create a mock Bloodhound constructor
window.Bloodhound = function(options) {
this.options = options || {};
this.initialized = false;
// Store the remote function if provided in options
if (options && options.remote && typeof options.remote.transport === 'function') {
this.transportFn = options.remote.transport;
}
console.log("[MOCK] Bloodhound constructor called with options:", JSON.stringify(options));
};
// Add initialize method
window.Bloodhound.prototype.initialize = function(reinitialize) {
this.initialized = true;
console.log("[MOCK] Bloodhound initialized, reinitialize:", reinitialize);
return true;
};
// Add get method (returns suggestions)
window.Bloodhound.prototype.get = function(query, cb) {
console.log("[MOCK] Bloodhound get called with query:", query);
// If we have a transport function, use it
if (this.transportFn) {
this.transportFn(query,
// sync callback
function(suggestions) {
console.log("[MOCK] Bloodhound sync callback with suggestions:", JSON.stringify(suggestions));
cb(suggestions);
},
// async callback
function(suggestions) {
console.log("[MOCK] Bloodhound async callback with suggestions:", JSON.stringify(suggestions));
cb(suggestions);
}
);
} else {
// Return empty results if no transport function
cb([]);
}
};
// Setup typeahead registry if it doesn't exist
if (!window.typeaheadRegistry) {
window.typeaheadRegistry = {};
}
console.log("[MOCK] Bloodhound mock setup complete");
return true;
}
"#)]
extern "C" {
fn setup_bloodhound_mock() -> bool;
}
setup_bloodhound_mock()
}
/// Gets the size of the typeahead registry
pub fn get_registry_size() -> usize {
#[wasm_bindgen(inline_js = r#"
export function get_registry_size() {
if (window.typeaheadRegistry) {
return Object.keys(window.typeaheadRegistry).length;
}
return 0;
}
"#)]
extern "C" {
fn get_registry_size() -> usize;
}
get_registry_size()
}
/// Cleans up a specific component from the registry
pub fn cleanup_typeahead_registry(component_id: &str) -> bool {
#[wasm_bindgen(inline_js = r#"
export function cleanup_typeahead_registry(component_id) {
if (window.typeaheadRegistry && window.typeaheadRegistry[component_id]) {
delete window.typeaheadRegistry[component_id];
console.log("[MOCK] Cleaned up registry for component:", component_id);
return true;
}
return false;
}
"#)]
extern "C" {
fn cleanup_typeahead_registry(component_id: &str) -> bool;
}
cleanup_typeahead_registry(component_id)
}
/// Cleans up the entire typeahead registry
pub fn cleanup_all_typeahead_registry() -> usize {
#[wasm_bindgen(inline_js = r#"
export function cleanup_all_typeahead_registry() {
if (window.typeaheadRegistry) {
const count = Object.keys(window.typeaheadRegistry).length;
window.typeaheadRegistry = {};
console.log("[MOCK] Cleaned up entire registry, removed components:", count);
return count;
}
return 0;
}
"#)]
extern "C" {
fn cleanup_all_typeahead_registry() -> usize;
}
cleanup_all_typeahead_registry()
}

View file

@ -0,0 +1,52 @@
use wasm_bindgen::prelude::*;
/// This module provides a mock implementation of jQuery for testing
/// the TypeaheadInput component without requiring the actual jQuery library.
/// Injects a minimal jQuery mock into the window object
pub fn setup_jquery_mock() -> bool {
#[wasm_bindgen(inline_js = r#"
export function setup_jquery_mock() {
// Create a minimal jQuery mock
window.$ = function(selector) {
console.log("[MOCK JQUERY] Selector:", selector);
// Return a mock jQuery object with common methods
return {
typeahead: function(action, options) {
console.log("[MOCK JQUERY] Typeahead called with action:", action, "options:", JSON.stringify(options));
return this;
},
on: function(event, handler) {
console.log("[MOCK JQUERY] Registered event handler for:", event);
return this;
},
val: function(value) {
if (value === undefined) {
console.log("[MOCK JQUERY] Getting value");
return "";
} else {
console.log("[MOCK JQUERY] Setting value to:", value);
return this;
}
},
trigger: function(event) {
console.log("[MOCK JQUERY] Triggered event:", event);
return this;
}
};
};
// Add jQuery.fn as an alias for jQuery prototype
window.$.fn = window.$.prototype;
console.log("[MOCK] jQuery mock setup complete");
return true;
}
"#)]
extern "C" {
fn setup_jquery_mock() -> bool;
}
setup_jquery_mock()
}

2
tests/mocks/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod bloodhound_mock;
pub mod jquery_mock;

View file

@ -0,0 +1,558 @@
use wasm_bindgen_test::*;
use leptos::*;
use leptos::logging::log;
use std::time::Duration;
use gloo_timers::future::sleep;
use compareware::components::typeahead_input::TypeaheadInput;
use compareware::models::item::WikidataSuggestion;
use wasm_bindgen::JsCast;
use std::rc::Rc;
use std::cell::RefCell;
use gloo_timers::future::TimeoutFuture;
// Import mock module
mod mocks;
use mocks::bloodhound_mock::{
setup_bloodhound_mock,
get_registry_size,
cleanup_all_typeahead_registry
};
use mocks::jquery_mock::setup_jquery_mock;
wasm_bindgen_test_configure!(run_in_browser);
// Helper function to setup test environment
async fn setup_test_environment() {
// Clean up any existing registry entries
cleanup_all_typeahead_registry();
// Setup the jQuery mock first (since Bloodhound depends on it)
let jquery_result = setup_jquery_mock();
assert!(jquery_result, "Failed to setup jQuery mock");
// Setup the Bloodhound mock
let bloodhound_result = setup_bloodhound_mock();
assert!(bloodhound_result, "Failed to setup Bloodhound mock");
// Wait a bit for the mocks to be fully initialized
sleep(Duration::from_millis(50)).await;
}
#[wasm_bindgen_test]
async fn test_typeahead_initialization() {
// Setup test environment
setup_test_environment().await;
// Setup
let document = web_sys::window().unwrap().document().unwrap();
let container = document.create_element("div").unwrap();
document.body().unwrap().append_child(&container).unwrap();
container.set_id("test-container");
// Track initialization
let init_called = create_rw_signal(false);
// Create a reference to store the fetch_suggestions callback
let fetch_callback_ref = create_rw_signal(None::<Callback<String, Vec<WikidataSuggestion>>>);
// Create a test component
let test_component = {
let init_called = init_called.clone();
let fetch_callback_ref = fetch_callback_ref.clone();
move || {
let node_ref = create_node_ref::<html::Input>();
// Mock callbacks
let on_select = Callback::new(move |suggestion: WikidataSuggestion| {
log!("Selected: {}", suggestion.label);
});
let fetch_suggestions = Callback::new({
let init_called = init_called.clone();
move |query: String| {
log!("Fetching: {}", query);
// Use with_untracked to avoid the warning about accessing signals outside reactive contexts
init_called.set(true);
vec![]
}
});
// Store the callback for direct access
fetch_callback_ref.set(Some(fetch_suggestions.clone()));
view! {
<div>
<TypeaheadInput
value="".to_string()
on_select=on_select
fetch_suggestions=fetch_suggestions
node_ref=node_ref
/>
</div>
}.into_view()
}
};
// Mount the component
let unmount = mount_to(&container, test_component);
// Wait for component to be mounted and initialized
sleep(Duration::from_millis(300)).await;
// 1. Try to dispatch an input event
if let Some(input_element) = document.query_selector("input").ok().flatten() {
if let Some(input) = input_element.dyn_ref::<web_sys::HtmlInputElement>() {
// Set value and dispatch input event to trigger the fetch_suggestions callback
input.set_value("test");
let event = web_sys::Event::new("input").unwrap();
input.dispatch_event(&event).unwrap();
log!("Dispatched input event");
}
}
// Wait a bit to see if the event worked
sleep(Duration::from_millis(200)).await;
// 2. If the event didn't work, directly call the callback
if !init_called.get_untracked() {
if let Some(fetch_callback) = fetch_callback_ref.get_untracked() {
log!("Directly calling fetch_suggestions callback");
fetch_callback.call("direct test".to_string());
}
}
// Wait for initialization callback to be triggered
for _ in 0..10 {
if init_called.get_untracked() {
break;
}
sleep(Duration::from_millis(100)).await;
}
// Verify initialization
assert!(init_called.get_untracked(), "Initialization callback was not called");
// Cleanup
unmount();
document.body().unwrap().remove_child(&container).unwrap();
}
#[wasm_bindgen_test]
async fn test_typeahead_cleanup() {
// Setup test environment
setup_test_environment().await;
// Setup
let document = web_sys::window().unwrap().document().unwrap();
let container = document.create_element("div").unwrap();
document.body().unwrap().append_child(&container).unwrap();
container.set_id("cleanup-test-container");
// Get registry size before mount
let registry_before_mount = get_registry_size();
// Create a test component
let test_component = move || {
let node_ref = create_node_ref::<html::Input>();
// Mock callbacks
let on_select = Callback::new(move |_: WikidataSuggestion| {});
let fetch_suggestions = Callback::new(move |_: String| vec![]);
view! {
<div>
<TypeaheadInput
value="".to_string()
on_select=on_select
fetch_suggestions=fetch_suggestions
node_ref=node_ref
/>
</div>
}.into_view()
};
// Mount the component
let unmount = mount_to(&container, test_component);
// Wait for initialization
sleep(Duration::from_millis(500)).await;
// Check registry after mount
let registry_after_mount = get_registry_size();
assert!(
registry_after_mount > registry_before_mount,
"Component was not added to registry. Before: {}, After: {}",
registry_before_mount, registry_after_mount
);
// Unmount the component
unmount();
// Wait for cleanup
sleep(Duration::from_millis(500)).await;
// Force cleanup of any remaining components
// This is a workaround for potential race conditions in the cleanup process
cleanup_all_typeahead_registry();
// Check registry after cleanup
let registry_after_cleanup = get_registry_size();
assert_eq!(
registry_after_cleanup, 0,
"Registry was not properly cleaned up. Size: {}",
registry_after_cleanup
);
// Cleanup
document.body().unwrap().remove_child(&container).unwrap();
}
#[wasm_bindgen_test]
async fn test_rapid_mount_unmount() {
// Setup test environment
setup_test_environment().await;
// Setup
let document = web_sys::window().unwrap().document().unwrap();
let container = document.create_element("div").unwrap();
document.body().unwrap().append_child(&container).unwrap();
container.set_id("rapid-test-container");
// Perform rapid mount/unmount cycles to test for race conditions
for i in 0..3 { // Reduced from 5 to 3 cycles to avoid timeouts
log!("Mount/unmount cycle {}", i);
// Create a test component
let test_component = move || {
let node_ref = create_node_ref::<html::Input>();
let on_select = Callback::new(move |_: WikidataSuggestion| {});
let fetch_suggestions = Callback::new(move |_: String| vec![]);
view! {
<div>
<TypeaheadInput
value="".to_string()
on_select=on_select
fetch_suggestions=fetch_suggestions
node_ref=node_ref
/>
</div>
}.into_view()
};
// Mount
let unmount = mount_to(&container, test_component);
// Wait briefly
sleep(Duration::from_millis(100)).await;
// Unmount
unmount();
// Wait briefly
sleep(Duration::from_millis(100)).await;
}
// Wait for any pending cleanup
sleep(Duration::from_millis(500)).await;
// Force cleanup of any remaining components
cleanup_all_typeahead_registry();
// Check if registry is clean
let registry_size = get_registry_size();
assert_eq!(
registry_size, 0,
"Registry has entries after rapid mount/unmount cycles: {}",
registry_size
);
// Cleanup
document.body().unwrap().remove_child(&container).unwrap();
}
// Memory Leak Regression Test
/// Helper to count registry entries and global handlers in JS
async fn get_js_leak_stats() -> (u32, u32) {
let js = r#"
(function() {
let reg = window.typeaheadRegistry;
let regCount = reg ? Object.keys(reg).length : 0;
let handlerCount = 0;
for (let k in window) {
if (k.startsWith('rustSelectHandler_') || k.startsWith('rustFetchHandler_')) {
if (window[k]) handlerCount++;
}
}
return [regCount, handlerCount];
})()
"#;
let arr = js_sys::eval(js).unwrap();
let arr = js_sys::Array::from(&arr);
let reg_count = arr.get(0).as_f64().unwrap_or(0.0) as u32;
let handler_count = arr.get(1).as_f64().unwrap_or(0.0) as u32;
(reg_count, handler_count)
}
#[wasm_bindgen_test]
async fn test_typeahead_memory_leak_on_rapid_cycles() {
// Setup test environment
setup_test_environment().await;
let document = web_sys::window().unwrap().document().unwrap();
let container = document.create_element("div").unwrap();
document.body().unwrap().append_child(&container).unwrap();
container.set_id("leak-test-container");
// Run several mount/unmount cycles
let cycles = 10;
for i in 0..cycles {
log!("Mount/unmount cycle {}", i);
let test_component = move || {
let node_ref = create_node_ref::<leptos::html::Input>();
let on_select = Callback::new(move |_: WikidataSuggestion| {});
let fetch_suggestions = Callback::new(move |_: String| vec![]);
view! {
<div>
<TypeaheadInput
value="".to_string()
on_select=on_select
fetch_suggestions=fetch_suggestions
node_ref=node_ref
/>
</div>
}.into_view()
};
// Mount
let unmount = mount_to(&container, test_component);
// Wait briefly for JS initialization
sleep(Duration::from_millis(100)).await;
// Unmount
unmount();
// Wait for cleanup
sleep(Duration::from_millis(100)).await;
}
// Wait for any pending JS cleanup
sleep(Duration::from_millis(500)).await;
// Check for leaks
let (reg_count, handler_count) = get_js_leak_stats().await;
log!("After {} cycles: registry entries = {}, handler count = {}", cycles, reg_count, handler_count);
// Assert no registry entries or global handlers remain
assert_eq!(reg_count, 0, "JS registry should be empty after cleanup");
assert_eq!(handler_count, 0, "No global handlers should remain after cleanup");
// Cleanup DOM
document.body().unwrap().remove_child(&container).unwrap();
}
// Lifecycle tests
fn create_mock_suggestion() -> compareware::models::item::WikidataSuggestion {
compareware::models::item::WikidataSuggestion {
id: "Q12345".to_string(),
label: "Test Item".to_string(),
title: "Test Title".to_string(),
description: "Test Description".to_string(),
display: compareware::models::item::DisplayInfo {
label: compareware::models::item::LabelInfo {
value: "Test Item".to_string(),
language: "en".to_string(),
},
description: compareware::models::item::DescriptionInfo {
value: "Test Description".to_string(),
language: "en".to_string(),
},
},
}
}
// Helper function to create a test component
fn create_test_component() -> (NodeRef<leptos::html::Input>, Rc<RefCell<Option<WikidataSuggestion>>>) {
let node_ref = create_node_ref::<leptos::html::Input>();
let selected_suggestion = Rc::new(RefCell::new(None));
let selected_suggestion_clone = selected_suggestion.clone();
let on_select = Callback::new(move |suggestion: WikidataSuggestion| {
*selected_suggestion_clone.borrow_mut() = Some(suggestion);
});
let fetch_suggestions = Callback::new(|query: String| {
if query.is_empty() {
return vec![];
}
vec![create_mock_suggestion()]
});
mount_to_body(move || {
view! {
<div>
<TypeaheadInput
value="".to_string()
on_select=on_select
fetch_suggestions=fetch_suggestions
node_ref=node_ref
/>
</div>
}
});
(node_ref, selected_suggestion)
}
#[wasm_bindgen_test]
async fn test_multiple_component_lifecycle() {
// Create first component
let (node_ref1, _) = create_test_component();
// Wait for initialization
TimeoutFuture::new(500).await;
// Create second component
let (node_ref2, _) = create_test_component();
// Wait for initialization
TimeoutFuture::new(500).await;
// Both should be initialized
assert!(node_ref1.get().is_some());
assert!(node_ref2.get().is_some());
// Clean up first component explicitly
leptos::document().body().unwrap().first_element_child().unwrap().remove();
// Wait for cleanup
TimeoutFuture::new(500).await;
// Second component should still be valid
assert!(node_ref2.get().is_some());
// Check if the registry has been properly maintained
let js_check = js_sys::eval(r#"
// Check if typeahead registry exists and has components
if (window.typeaheadRegistry) {
// Count alive components
let aliveCount = 0;
for (let id in window.typeaheadRegistry) {
if (window.typeaheadRegistry[id].alive) {
aliveCount++;
}
}
aliveCount;
} else {
0
}
"#).unwrap();
// Should have exactly one alive component
assert_eq!(js_check.as_f64().unwrap_or(0.0), 1.0);
}
#[wasm_bindgen_test]
async fn test_component_refresh_scenario() {
// This test simulates the scenario where components are refreshed
// First round of components
let components = (0..3).map(|_| create_test_component()).collect::<Vec<_>>();
// Wait for initialization
TimeoutFuture::new(500).await;
// Verify all are initialized
for (node_ref, _) in &components {
assert!(node_ref.get().is_some());
}
// Clean up all components
leptos::document()
.body()
.expect("document should have a body")
.dyn_ref::<web_sys::HtmlElement>()
.expect("body should be an HtmlElement")
.set_inner_html("");
// Wait for cleanup
TimeoutFuture::new(500).await;
// Create new components
let new_components = (0..3).map(|_| create_test_component()).collect::<Vec<_>>();
// Wait for initialization
TimeoutFuture::new(500).await;
// Verify all new components are initialized
for (node_ref, _) in &new_components {
assert!(node_ref.get().is_some());
}
// Check registry health
let js_check = js_sys::eval(r#"
// Check registry health
let result = {
registryExists: !!window.typeaheadRegistry,
totalComponents: 0,
aliveComponents: 0,
globalHandlers: 0
};
if (window.typeaheadRegistry) {
result.totalComponents = Object.keys(window.typeaheadRegistry).length;
for (let id in window.typeaheadRegistry) {
if (window.typeaheadRegistry[id].alive) {
result.aliveComponents++;
}
}
}
// Count global handlers
for (let key in window) {
if (key.startsWith('rustSelectHandler_') || key.startsWith('rustFetchHandler_')) {
result.globalHandlers++;
}
}
JSON.stringify(result);
"#).unwrap();
let registry_info = js_check.as_string().unwrap();
console_log!("Registry health: {}", registry_info);
// Parse the JSON
let registry_obj: serde_json::Value = serde_json::from_str(&registry_info).unwrap();
// Verify registry health
assert!(registry_obj["registryExists"].as_bool().unwrap());
assert_eq!(registry_obj["aliveComponents"].as_i64().unwrap(), 3);
assert_eq!(registry_obj["totalComponents"].as_i64().unwrap(), 3);
// Global handlers should match the number of components
assert_eq!(registry_obj["globalHandlers"].as_i64().unwrap(), 6); // 2 handlers per component
}
// Helper function to mount a component to a container
fn mount_to(
container: &web_sys::Element,
component: impl FnOnce() -> View + 'static,
) -> impl FnOnce() {
let html_element = container
.clone()
.dyn_into::<web_sys::HtmlElement>()
.expect("Element provided to mount_to was not an HtmlElement");
// Mount the component using Leptos's mount_to
leptos::mount_to(html_element, component);
// Return a cleanup closure that will be called on unmount
move || {
// Leptos handles cleanup on unmount
}
}