Compare commits
43 commits
Author | SHA1 | Date | |
---|---|---|---|
7a98754e68 | |||
7064520b21 | |||
8799d5c144 | |||
cd47be500f | |||
bf5acde208 | |||
764cb262fe | |||
11add580e4 | |||
de7c5c62c8 | |||
d565563edb | |||
5577f855ca | |||
a8088c232b | |||
f9fe3eb980 | |||
eeb8e100e8 | |||
8cfef276df | |||
462048db2f | |||
55cfc1329c | |||
5f92db735e | |||
74e4252197 | |||
c3bd3b1f27 | |||
1334336377 | |||
a2a1a48ea6 | |||
46b6cf82e2 | |||
24c138b866 | |||
e528f9e684 | |||
a69c51921b | |||
dbca9a98c8 | |||
a9bfbf1c15 | |||
09dd736082 | |||
0f28394fce | |||
1fd4131298 | |||
26724d9c45 | |||
2d286e5834 | |||
37d157725e | |||
7d36bac77f | |||
fd0d4a5f38 | |||
486bf9cbad | |||
b017df9b35 | |||
102f69fd29 | |||
f646b92d3a | |||
07405db017 | |||
5ca277ee80 | |||
d6661c2ac9 | |||
4de14bb48b |
22 changed files with 3137 additions and 593 deletions
715
Cargo.lock
generated
715
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
14
Cargo.toml
14
Cargo.toml
|
@ -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.
|
||||
|
|
54
README.md
54
README.md
|
@ -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! Here’s how you can help:
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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
|
|
@ -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"]
|
|
@ -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,
|
||||
// },
|
||||
});
|
||||
|
|
|
@ -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");
|
||||
|
||||
|
|
|
@ -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 || {
|
||||
|
|
|
@ -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(¤t_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(¤t_url);
|
||||
let normalized_property_clone = normalized_property.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
let response = gloo_net::http::Request::post(
|
||||
&format!("/api/urls/{}/properties", encode(¤t_url))
|
||||
&format!("/api/urls/{}/properties", encode(¤t_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(¤t_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(¤t_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(¤t_url);
|
||||
log!("Rendering property: {}", property);
|
||||
view! {
|
||||
<tr>
|
||||
<td>{ property }</td>
|
||||
{{
|
||||
// Clone current_url before the nested closure
|
||||
let current_url_for_inner = Rc::clone(¤t_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(¤t_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(¤t_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(¤t_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)]
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
pub mod items_list;
|
||||
pub mod editable_cell;
|
||||
pub mod editable_cell;
|
||||
pub mod typeahead_input;
|
1117
src/components/typeahead_input.rs
Normal file
1117
src/components/typeahead_input.rs
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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;
|
||||
|
||||
|
|
26
src/main.rs
26
src/main.rs
|
@ -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);
|
||||
}
|
|
@ -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
13
src/utils/leptos_owner.rs
Normal 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
2
src/utils/mod.rs
Normal file
|
@ -0,0 +1,2 @@
|
|||
pub mod leptos_owner;
|
||||
pub mod panic_hook;
|
54
src/utils/panic_hook.rs
Normal file
54
src/utils/panic_hook.rs
Normal 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");
|
||||
}
|
154
tests/mocks/bloodhound_mock.rs
Normal file
154
tests/mocks/bloodhound_mock.rs
Normal 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()
|
||||
}
|
52
tests/mocks/jquery_mock.rs
Normal file
52
tests/mocks/jquery_mock.rs
Normal 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
2
tests/mocks/mod.rs
Normal file
|
@ -0,0 +1,2 @@
|
|||
pub mod bloodhound_mock;
|
||||
pub mod jquery_mock;
|
558
tests/typeahead_input_unit_test.rs
Normal file
558
tests/typeahead_input_unit_test.rs
Normal 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(®istry_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
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue