Compare commits
3 commits
Author | SHA1 | Date | |
---|---|---|---|
93793bb580 | |||
1f4b63b1f8 | |||
f35c7cd085 |
22 changed files with 551 additions and 2896 deletions
717
Cargo.lock
generated
717
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
14
Cargo.toml
14
Cargo.toml
|
@ -10,9 +10,7 @@ crate-type = ["cdylib", "rlib"]
|
||||||
actix-files = { version = "0.6", optional = true }
|
actix-files = { version = "0.6", optional = true }
|
||||||
actix-web = { version = "4", optional = true, features = ["macros"] }
|
actix-web = { version = "4", optional = true, features = ["macros"] }
|
||||||
console_error_panic_hook = "0.1"
|
console_error_panic_hook = "0.1"
|
||||||
gloo-utils = "0.2"
|
|
||||||
http = { version = "1.0.0", optional = true }
|
http = { version = "1.0.0", optional = true }
|
||||||
js-sys = "0.3"
|
|
||||||
leptos = { version = "0.6" }
|
leptos = { version = "0.6" }
|
||||||
leptos_meta = { version = "0.6" }
|
leptos_meta = { version = "0.6" }
|
||||||
leptos_actix = { version = "0.6", optional = true }
|
leptos_actix = { version = "0.6", optional = true }
|
||||||
|
@ -29,7 +27,6 @@ gloo-net = "0.5"
|
||||||
gloo-timers = { version = "0.2", features = ["futures"] }
|
gloo-timers = { version = "0.2", features = ["futures"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
wasm-bindgen-test = "0.3"
|
|
||||||
serde_json="1.0.133"
|
serde_json="1.0.133"
|
||||||
thiserror = "2.0.9"
|
thiserror = "2.0.9"
|
||||||
zerofrom = "0.1"
|
zerofrom = "0.1"
|
||||||
|
@ -51,13 +48,6 @@ ssr = [
|
||||||
"dep:rusqlite"
|
"dep:rusqlite"
|
||||||
]
|
]
|
||||||
|
|
||||||
# feature for wasm tests
|
|
||||||
wasm-test = [
|
|
||||||
"leptos/csr",
|
|
||||||
"leptos_meta/csr",
|
|
||||||
"leptos_router/csr",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Override secp256k1's default features
|
# Override secp256k1's default features
|
||||||
[dependencies.secp256k1]
|
[dependencies.secp256k1]
|
||||||
version = "0.30.0"
|
version = "0.30.0"
|
||||||
|
@ -87,8 +77,8 @@ style-file = "style/main.scss"
|
||||||
#
|
#
|
||||||
# Optional. Env: LEPTOS_ASSETS_DIR.
|
# Optional. Env: LEPTOS_ASSETS_DIR.
|
||||||
assets-dir = "assets"
|
assets-dir = "assets"
|
||||||
# The IP and port (ex: 127.0.0.1:3004) where the server serves the content. Use it in your server setup.
|
# 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:3004"
|
site-addr = "127.0.0.1:3000"
|
||||||
# The port to use for automatic reload monitoring
|
# The port to use for automatic reload monitoring
|
||||||
reload-port = 3001
|
reload-port = 3001
|
||||||
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
|
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
|
||||||
|
|
52
README.md
52
README.md
|
@ -1,7 +1,7 @@
|
||||||
# [CompareWare](https://compareware.org/)
|
# [CompareWare](https://compareware.org/)
|
||||||
|
|
||||||
CompareWare is an open-source platform for comparing tools (software, hardware, etc.) with structured, crowdsourced data.
|
CompareWare is an open-source platform for comparing tools (software, hardware, etc.) with structured, crowdsourced data.
|
||||||
It combines Rust's **Leptos** for a modern, reactive frontend and **Nostr** for decentralized data storage (TBI).
|
It combines Rust's **Leptos** for a modern, reactive frontend and **rusqlite** for data storage.
|
||||||
|
|
||||||
## **Features**
|
## **Features**
|
||||||
- **Item Management**: Add, view, and manage items with metadata and key-value tags.
|
- **Item Management**: Add, view, and manage items with metadata and key-value tags.
|
||||||
|
@ -99,56 +99,6 @@ sequenceDiagram
|
||||||
```
|
```
|
||||||
3. Access the application at: [http://localhost:3004](http://localhost:3004)
|
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**
|
### **Collaboration**
|
||||||
We welcome contributions! Here’s how you can help:
|
We welcome contributions! Here’s how you can help:
|
||||||
|
|
||||||
|
|
|
@ -156,51 +156,3 @@ th {
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
overflow: auto;
|
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;
|
|
||||||
}
|
|
|
@ -2,14 +2,10 @@ services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "3004:3004"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./compareware.db:/app/compareware.db
|
- ./compareware.db:/app/compareware.db
|
||||||
environment:
|
environment:
|
||||||
- RUST_LOG=info
|
- RUST_LOG=info
|
||||||
- LEPTOS_ENV=production
|
- 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
|
restart: unless-stopped
|
|
@ -28,8 +28,6 @@ COPY . .
|
||||||
RUN rustup target add wasm32-unknown-unknown
|
RUN rustup target add wasm32-unknown-unknown
|
||||||
# Build project
|
# Build project
|
||||||
ENV LEPTOS_OUTPUT_NAME="compareware"
|
ENV LEPTOS_OUTPUT_NAME="compareware"
|
||||||
ENV LEPTOS_SITE_ADDR="0.0.0.0:3004"
|
|
||||||
ENV LEPTOS_SITE_ROOT="site"
|
|
||||||
|
|
||||||
# Build with release profile
|
# Build with release profile
|
||||||
RUN cargo leptos build --release
|
RUN cargo leptos build --release
|
||||||
|
@ -52,8 +50,7 @@ COPY assets /app/assets
|
||||||
|
|
||||||
# Configure container, expose port and set entrypoint
|
# Configure container, expose port and set entrypoint
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
EXPOSE 3004
|
EXPOSE 3000
|
||||||
ENV LEPTOS_SITE_ADDR="0.0.0.0:3004"
|
ENV LEPTOS_SITE_ADDR=0.0.0.0:3000
|
||||||
ENV LEPTOS_SITE_ROOT="site"
|
ENV LEPTOS_SITE_ROOT="site"
|
||||||
ENV LEPTOS_OPTIONS='{"site_addr":"0.0.0.0:3004"}'
|
|
||||||
CMD ["./compareware"]
|
CMD ["./compareware"]
|
|
@ -35,7 +35,7 @@ export default defineConfig({
|
||||||
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
||||||
actionTimeout: 0,
|
actionTimeout: 0,
|
||||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
// baseURL: 'http://localhost:3004',
|
// baseURL: 'http://localhost:3000',
|
||||||
|
|
||||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
trace: "on-first-retry",
|
trace: "on-first-retry",
|
||||||
|
@ -99,6 +99,6 @@ export default defineConfig({
|
||||||
/* Run your local dev server before starting the tests */
|
/* Run your local dev server before starting the tests */
|
||||||
// webServer: {
|
// webServer: {
|
||||||
// command: 'npm run start',
|
// command: 'npm run start',
|
||||||
// port: 3004,
|
// port: 3000,
|
||||||
// },
|
// },
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
test("homepage has title and links to intro page", async ({ page }) => {
|
test("homepage has title and links to intro page", async ({ page }) => {
|
||||||
await page.goto("http://localhost:3004/");
|
await page.goto("http://localhost:3000/");
|
||||||
|
|
||||||
await expect(page).toHaveTitle("Welcome to Leptos");
|
await expect(page).toHaveTitle("Welcome to Leptos");
|
||||||
|
|
||||||
|
|
|
@ -29,10 +29,6 @@ pub fn App() -> impl IntoView {
|
||||||
// }
|
// }
|
||||||
// });
|
// });
|
||||||
view! {
|
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>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/*url" view=move || {
|
<Route path="/*url" view=move || {
|
||||||
|
|
|
@ -10,9 +10,12 @@ use std::sync::Arc;
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use urlencoding::encode;
|
use urlencoding::encode;
|
||||||
use crate::components::typeahead_input::TypeaheadInput;
|
#[derive(Deserialize, Clone, Debug)]
|
||||||
use crate::models::item::WikidataSuggestion;
|
struct WikidataSuggestion {
|
||||||
use leptos::html::Input;
|
id: String,
|
||||||
|
label: String,
|
||||||
|
description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
//function to load items from database
|
//function to load items from database
|
||||||
pub async fn load_items_from_db(current_url: &str) -> Result<Vec<Item>, String> {
|
pub async fn load_items_from_db(current_url: &str) -> Result<Vec<Item>, String> {
|
||||||
|
@ -120,7 +123,6 @@ pub fn ItemsList(
|
||||||
items: ReadSignal<Vec<Item>>,
|
items: ReadSignal<Vec<Item>>,
|
||||||
set_items: WriteSignal<Vec<Item>>,
|
set_items: WriteSignal<Vec<Item>>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let node_ref = create_node_ref::<Input>();
|
|
||||||
// State to track selected properties
|
// State to track selected properties
|
||||||
let (selected_properties, set_selected_properties) = create_signal(HashMap::<String, bool>::new());
|
let (selected_properties, set_selected_properties) = create_signal(HashMap::<String, bool>::new());
|
||||||
|
|
||||||
|
@ -141,8 +143,6 @@ pub fn ItemsList(
|
||||||
|
|
||||||
// State to manage property cache
|
// State to manage property cache
|
||||||
let (property_cache, set_property_cache) = create_signal(HashMap::<String, HashMap<String, String>>::new());
|
let (property_cache, set_property_cache) = create_signal(HashMap::<String, HashMap<String, String>>::new());
|
||||||
|
|
||||||
let (suggestions, set_suggestions) = create_signal(Vec::<WikidataSuggestion>::new());
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
fn get_current_url() -> String {
|
fn get_current_url() -> String {
|
||||||
use leptos::use_context;
|
use leptos::use_context;
|
||||||
|
@ -206,18 +206,12 @@ pub fn ItemsList(
|
||||||
|
|
||||||
// Fetch labels for the custom properties
|
// Fetch labels for the custom properties
|
||||||
let property_ids = custom_props_clone;
|
let property_ids = custom_props_clone;
|
||||||
match fetch_property_labels(property_ids).await {
|
let labels = fetch_property_labels(property_ids).await;
|
||||||
Ok(labels) => {
|
|
||||||
set_property_labels.update(|labels_map| {
|
set_property_labels.update(|labels_map| {
|
||||||
for (key, value) in labels {
|
for (key, value) in labels {
|
||||||
labels_map.insert(key, value);
|
labels_map.insert(key, value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
log!("Error fetching property labels: {:?}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// log!("Items after loading: {:?}", items.get());
|
// log!("Items after loading: {:?}", items.get());
|
||||||
}
|
}
|
||||||
|
@ -455,16 +449,10 @@ pub fn ItemsList(
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if !missing_ids.is_empty() {
|
if !missing_ids.is_empty() {
|
||||||
match fetch_property_labels(missing_ids).await {
|
let new_labels = fetch_property_labels(missing_ids).await;
|
||||||
Ok(new_labels) => {
|
|
||||||
set_property_labels.update(|labels| {
|
set_property_labels.update(|labels| {
|
||||||
labels.extend(new_labels.clone());
|
labels.extend(new_labels.clone());
|
||||||
});
|
});
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
log!("Error fetching property labels: {:?}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second pass: build results
|
// Second pass: build results
|
||||||
|
@ -506,7 +494,7 @@ pub fn ItemsList(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_property_labels(property_ids: Vec<String>) -> Result<HashMap<String, String>, String> {
|
async fn fetch_property_labels(property_ids: Vec<String>) -> HashMap<String, String> {
|
||||||
log!("Fetching property labels for properties: {:?}", property_ids);
|
log!("Fetching property labels for properties: {:?}", property_ids);
|
||||||
|
|
||||||
// Remove the "http://www.wikidata.org/prop/" prefix from property IDs
|
// Remove the "http://www.wikidata.org/prop/" prefix from property IDs
|
||||||
|
@ -541,7 +529,7 @@ pub fn ItemsList(
|
||||||
log!("Received response from Wikidata. Status: {}", response.status());
|
log!("Received response from Wikidata. Status: {}", response.status());
|
||||||
if response.status() != 200 {
|
if response.status() != 200 {
|
||||||
log!("Error: Unexpected status code {}", response.status());
|
log!("Error: Unexpected status code {}", response.status());
|
||||||
return Err(format!("Unexpected status code: {}", response.status()));
|
return HashMap::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
match response.text().await {
|
match response.text().await {
|
||||||
|
@ -569,23 +557,23 @@ pub fn ItemsList(
|
||||||
log!("Warning: No bindings found in the response");
|
log!("Warning: No bindings found in the response");
|
||||||
}
|
}
|
||||||
log!("Fetched {} property labels", result.len());
|
log!("Fetched {} property labels", result.len());
|
||||||
Ok(result)
|
result
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log!("Error parsing response from Wikidata: {:?}", e);
|
log!("Error parsing response from Wikidata: {:?}", e);
|
||||||
Err(format!("Error parsing response: {:?}", e))
|
HashMap::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log!("Error reading response body: {:?}", e);
|
log!("Error reading response body: {:?}", e);
|
||||||
Err(format!("Error reading response body: {:?}", e))
|
HashMap::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log!("Error fetching property labels from Wikidata: {:?}", e);
|
log!("Error fetching property labels from Wikidata: {:?}", e);
|
||||||
Err(format!("Error fetching property labels: {:?}", e))
|
HashMap::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -597,65 +585,41 @@ pub fn ItemsList(
|
||||||
let set_property_labels = set_property_labels.clone();
|
let set_property_labels = set_property_labels.clone();
|
||||||
let property_cache = property_cache.clone();
|
let property_cache = property_cache.clone();
|
||||||
let set_property_cache = set_property_cache.clone();
|
let set_property_cache = set_property_cache.clone();
|
||||||
let set_custom_properties = set_custom_properties.clone();
|
|
||||||
let set_selected_properties = set_selected_properties.clone();
|
|
||||||
|
|
||||||
Arc::new(move |property: String| {
|
Arc::new(move |property: String| {
|
||||||
// Normalize the property ID
|
// Normalize the property ID
|
||||||
let normalized_property = property.replace("http://www.wikidata.org/prop/", "");
|
let normalized_property = property.replace("http://www.wikidata.org/prop/", "");
|
||||||
|
let normalized_property_clone = normalized_property.clone();
|
||||||
// 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
|
// Check if label already exists
|
||||||
if !property_labels_snapshot.contains_key(&normalized_property) {
|
if !property_labels.get().contains_key(&normalized_property) {
|
||||||
let normalized_property_clone = normalized_property.clone();
|
spawn_local({
|
||||||
let set_property_labels_clone = set_property_labels.clone();
|
let normalized_property = normalized_property.clone();
|
||||||
|
let set_property_labels = set_property_labels.clone();
|
||||||
// Add a placeholder label immediately
|
async move {
|
||||||
|
let labels = fetch_property_labels(vec![normalized_property.clone()]).await;
|
||||||
set_property_labels.update(|map| {
|
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);
|
map.extend(labels);
|
||||||
});
|
});
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
log!("Error fetching property labels: {:?}", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if property is already selected
|
// Check if property is already selected
|
||||||
if !selected_properties_snapshot.contains_key(&normalized_property) {
|
if !selected_properties.get().contains_key(&normalized_property) && !normalized_property.is_empty() {
|
||||||
// Add property to selected properties
|
// Add property to selected properties
|
||||||
set_selected_properties.update(|selected| {
|
set_selected_properties.update(|selected| {
|
||||||
selected.insert(normalized_property.clone(), true);
|
selected.insert(normalized_property.clone(), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save the selected property to the database
|
// Save the selected property to the database
|
||||||
let current_url_clone = Rc::clone(¤t_url);
|
spawn_local({
|
||||||
let normalized_property_clone = normalized_property.clone();
|
let current_url = Rc::clone(¤t_url);
|
||||||
|
let normalized_property = normalized_property_clone.clone();
|
||||||
spawn_local(async move {
|
async move {
|
||||||
let response = gloo_net::http::Request::post(
|
let response = gloo_net::http::Request::post(
|
||||||
&format!("/api/urls/{}/properties", encode(¤t_url_clone))
|
&format!("/api/urls/{}/properties", encode(¤t_url))
|
||||||
)
|
)
|
||||||
.json(&normalized_property_clone)
|
.json(&normalized_property)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
@ -672,84 +636,82 @@ pub fn ItemsList(
|
||||||
log!("Error saving property: {:?}", err);
|
log!("Error saving property: {:?}", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update custom properties if not already present
|
|
||||||
if !custom_properties_snapshot.contains(&normalized_property) {
|
|
||||||
set_custom_properties.update(|props| {
|
set_custom_properties.update(|props| {
|
||||||
|
if !props.contains(&normalized_property) && !normalized_property.is_empty() {
|
||||||
props.push(normalized_property.clone());
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update items with the new property
|
// Ensure the grid updates reactively
|
||||||
set_items.update(|items| {
|
set_items.update(|items| {
|
||||||
for item in items {
|
for item in items {
|
||||||
// Only add if it doesn't exist
|
item.custom_properties.entry(normalized_property.clone()).or_insert_with(|| "".to_string());
|
||||||
if !item.custom_properties.contains_key(&normalized_property) {
|
|
||||||
item.custom_properties.insert(normalized_property.clone(), "".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Save each item to the database
|
// Save the updated item to the database
|
||||||
let items_snapshot = items.get();
|
|
||||||
for item in items_snapshot {
|
|
||||||
let item_clone = item.clone();
|
let item_clone = item.clone();
|
||||||
let current_url_clone = Rc::clone(¤t_url);
|
spawn_local({
|
||||||
let selected_properties_clone = selected_properties;
|
let current_url = Rc::clone(¤t_url);
|
||||||
|
async move {
|
||||||
spawn_local(async move {
|
save_item_to_db(item_clone, selected_properties, current_url.to_string()).await;
|
||||||
save_item_to_db(item_clone, selected_properties_clone, current_url_clone.to_string()).await;
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Log the addition
|
// Use the property label from the property_labels signal
|
||||||
let property_label = property_labels_snapshot
|
let property_label = property_labels.get().get(&normalized_property).cloned().unwrap_or_else(|| normalized_property.clone());
|
||||||
.get(&normalized_property)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| normalized_property.clone());
|
|
||||||
|
|
||||||
log!("Added property with label: {}", property_label);
|
log!("Added property with label: {}", property_label);
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch Wikidata properties for items with IDs
|
}
|
||||||
let items_snapshot = items.get();
|
});
|
||||||
for item in items_snapshot {
|
// 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
|
||||||
if let Some(wikidata_id) = &item.wikidata_id {
|
if let Some(wikidata_id) = &item.wikidata_id {
|
||||||
let wikidata_id_clone = wikidata_id.clone();
|
let wikidata_id = wikidata_id.clone();
|
||||||
let normalized_property_clone = normalized_property.clone();
|
let set_items = set_items.clone();
|
||||||
let set_items_clone = set_items.clone();
|
let set_fetched_properties = set_fetched_properties.clone();
|
||||||
let set_property_labels_clone = set_property_labels.clone();
|
let property_clone = normalized_property.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 {
|
spawn_local(async move {
|
||||||
let properties = fetch_item_properties(
|
let properties = fetch_item_properties(
|
||||||
&wikidata_id_clone,
|
&wikidata_id,
|
||||||
set_property_labels_clone,
|
set_property_labels.clone(),
|
||||||
property_cache_clone,
|
property_cache.clone(),
|
||||||
set_property_cache_clone,
|
set_property_cache.clone(),
|
||||||
property_labels_clone
|
property_labels.clone()
|
||||||
).await;
|
).await;
|
||||||
|
|
||||||
if let Some(value) = properties.get(&normalized_property_clone) {
|
// Update the specific property for this item
|
||||||
set_items_clone.update(|items| {
|
if let Some(value) = properties.get(&property_clone) {
|
||||||
for item in items {
|
set_items.update(|items| {
|
||||||
if item.wikidata_id.as_ref() == Some(&wikidata_id_clone) {
|
if let Some(item) = items.iter_mut()
|
||||||
|
.find(|i| i.wikidata_id.as_ref() == Some(&wikidata_id))
|
||||||
|
{
|
||||||
item.custom_properties.insert(
|
item.custom_properties.insert(
|
||||||
normalized_property_clone.clone(),
|
property_clone.clone(),
|
||||||
value.clone()
|
value.clone()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
};
|
})};
|
||||||
|
|
||||||
// Update item fields
|
// Update item fields
|
||||||
let update_item = {
|
let update_item = {
|
||||||
|
@ -842,121 +804,118 @@ pub fn ItemsList(
|
||||||
<tbody>
|
<tbody>
|
||||||
{properties.into_iter().map(|property| {
|
{properties.into_iter().map(|property| {
|
||||||
let update_item_cloned = Arc::clone(&update_item);
|
let update_item_cloned = Arc::clone(&update_item);
|
||||||
let current_url_for_closure = Rc::clone(¤t_url);
|
|
||||||
log!("Rendering property: {}", property);
|
log!("Rendering property: {}", property);
|
||||||
view! {
|
view! {
|
||||||
<tr>
|
<tr>
|
||||||
<td>{ property }</td>
|
<td>{ property }</td>
|
||||||
{{
|
{move || items.get().iter().enumerate().map(|(index, item)| {
|
||||||
// Clone current_url before the nested closure
|
|
||||||
let current_url_for_inner = Rc::clone(¤t_url_for_closure);
|
|
||||||
|
|
||||||
move || {
|
|
||||||
let items = items.get();
|
|
||||||
items.iter().enumerate().map(|(index, item)| {
|
|
||||||
let update_item_clone = Arc::clone(&update_item_cloned);
|
let update_item_clone = Arc::clone(&update_item_cloned);
|
||||||
let current_url_clone = Rc::clone(¤t_url_for_inner);
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<td>
|
<td>
|
||||||
{match property {
|
{match property {
|
||||||
"Name" => view! {
|
"Name" => view! {
|
||||||
<div class="typeahead-container">
|
<div class="editable-cell">
|
||||||
<TypeaheadInput
|
<EditableCell
|
||||||
value=item.name.clone()
|
value=item.name.clone()
|
||||||
fetch_suggestions=Callback::new({
|
on_input=move |value| {
|
||||||
let key = format!("name-{}", index);
|
update_item_clone(index, "name", value.clone());
|
||||||
let wikidata_suggestions_clone = wikidata_suggestions.clone();
|
fetch_wikidata_suggestions(format!("name-{}", index), value);
|
||||||
|
|
||||||
move |query: String| -> Vec<WikidataSuggestion> {
|
|
||||||
// Fetch suggestions in a separate function to avoid capturing too much
|
|
||||||
fetch_wikidata_suggestions(key.clone(), query.clone());
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
|
|
||||||
move |suggestion: WikidataSuggestion| {
|
|
||||||
let wikidata_id = suggestion.id.clone();
|
|
||||||
|
|
||||||
set_items_clone.update(|items| {
|
|
||||||
if let Some(item) = items.get_mut(index) {
|
|
||||||
item.name = suggestion.display.label.value.clone();
|
|
||||||
item.description = suggestion.display.description.value.clone();
|
|
||||||
item.wikidata_id = Some(wikidata_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);
|
||||||
});
|
});
|
||||||
|
}))
|
||||||
// Fetch properties in a separate task
|
on_blur=Some(Callback::new(move |_| {
|
||||||
let set_property_labels_for_task = set_property_labels_clone.clone();
|
log!("Input blurred, delaying hiding suggestions");
|
||||||
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 {
|
spawn_local(async move {
|
||||||
fetch_item_properties(
|
gloo_timers::future::sleep(std::time::Duration::from_millis(500)).await;
|
||||||
&wikidata_id_for_task,
|
log!("Hiding suggestions after delay");
|
||||||
set_property_labels_for_task,
|
set_show_suggestions.update(|suggestions| {
|
||||||
property_cache_for_task,
|
suggestions.insert(format!("name-{}", index), false);
|
||||||
set_property_cache_for_task,
|
|
||||||
property_labels_for_task
|
|
||||||
).await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
is_last_row={index == items.len() - 1}
|
|
||||||
on_input=Callback::new({
|
|
||||||
// Clone items.len() before moving into the closure
|
|
||||||
let items_len = items.len();
|
|
||||||
let set_items_clone = set_items.clone();
|
|
||||||
let current_url_clone = Rc::clone(¤t_url_clone);
|
|
||||||
let selected_properties_clone = selected_properties.clone();
|
|
||||||
|
|
||||||
move |value: String| {
|
|
||||||
if index == items_len - 1 && !value.is_empty() {
|
|
||||||
let current_url_for_new_item = Rc::clone(¤t_url_clone);
|
|
||||||
let selected_properties_for_new_item = selected_properties_clone.clone();
|
|
||||||
|
|
||||||
set_items_clone.update(|items| {
|
|
||||||
let new_item = Item {
|
|
||||||
id: Uuid::new_v4().to_string(),
|
|
||||||
name: String::new(),
|
|
||||||
description: String::new(),
|
|
||||||
wikidata_id: None,
|
|
||||||
custom_properties: HashMap::new(),
|
|
||||||
};
|
|
||||||
items.push(new_item.clone());
|
|
||||||
|
|
||||||
// Save the new item to the database in a separate task
|
|
||||||
let new_item_clone = new_item.clone();
|
|
||||||
let current_url_for_task = Rc::clone(¤t_url_for_new_item);
|
|
||||||
let selected_properties_for_task = selected_properties_for_new_item;
|
|
||||||
|
|
||||||
spawn_local(async move {
|
|
||||||
save_item_to_db(
|
|
||||||
new_item_clone,
|
|
||||||
selected_properties_for_task,
|
|
||||||
current_url_for_task.to_string()
|
|
||||||
).await;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}))
|
||||||
}
|
input_type=InputType::Text
|
||||||
})
|
|
||||||
node_ref=create_node_ref()
|
|
||||||
/>
|
/>
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
}.into_view(),
|
}.into_view(),
|
||||||
|
|
||||||
"Description" => view! {
|
"Description" => view! {
|
||||||
<EditableCell
|
<EditableCell
|
||||||
value=item.description.clone()
|
value=item.description.clone()
|
||||||
|
@ -979,9 +938,7 @@ pub fn ItemsList(
|
||||||
}}
|
}}
|
||||||
</td>
|
</td>
|
||||||
}
|
}
|
||||||
}).collect::<Vec<_>>()
|
}).collect::<Vec<_>>()}
|
||||||
}
|
|
||||||
}}
|
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
}).collect::<Vec<_>>()}
|
}).collect::<Vec<_>>()}
|
||||||
|
@ -1023,8 +980,7 @@ pub fn ItemsList(
|
||||||
{move || {
|
{move || {
|
||||||
let update_item_cell = Arc::clone(&update_item_inner);
|
let update_item_cell = Arc::clone(&update_item_inner);
|
||||||
let property_clone_for_cells = normalized_property.clone();
|
let property_clone_for_cells = normalized_property.clone();
|
||||||
let items = items.get();
|
items.get().iter().enumerate().map(move |(index, item)| {
|
||||||
items.iter().enumerate().map(move |(index, item)| {
|
|
||||||
let update_item_cell = Arc::clone(&update_item_cell);
|
let update_item_cell = Arc::clone(&update_item_cell);
|
||||||
let property_clone_for_closure = property_clone_for_cells.clone();
|
let property_clone_for_closure = property_clone_for_cells.clone();
|
||||||
view! {
|
view! {
|
||||||
|
@ -1052,29 +1008,18 @@ pub fn ItemsList(
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div style="margin-bottom: 20px;">
|
<div style="margin-bottom: 20px;">
|
||||||
<input
|
<input type="text" id="new-property" placeholder="Add New Property" list="properties" on:keydown=move |event| {
|
||||||
type="text"
|
|
||||||
id="new-property"
|
|
||||||
placeholder="Add New Property"
|
|
||||||
list="properties"
|
|
||||||
on:keydown=move |event| {
|
|
||||||
if event.key() == "Enter" {
|
if event.key() == "Enter" {
|
||||||
// Safely get the input element
|
let input_element = event.target().unwrap().dyn_into::<web_sys::HtmlInputElement>().unwrap();
|
||||||
if let Some(target) = event.target() {
|
|
||||||
if let Ok(input_element) = target.dyn_into::<web_sys::HtmlInputElement>() {
|
|
||||||
let input_value = input_element.value();
|
let input_value = input_element.value();
|
||||||
|
|
||||||
// Extract property ID from "Label (P123)" format
|
// Extract property ID from "Label (P123)" format
|
||||||
let property_id = if input_value.contains(" (") && input_value.ends_with(')') {
|
let property_id = input_value
|
||||||
let parts: Vec<&str> = input_value.rsplitn(2, " (").collect();
|
.split(" (")
|
||||||
if parts.len() == 2 {
|
.last()
|
||||||
parts[0].trim_end_matches(')').to_string()
|
.and_then(|s| s.strip_suffix(')'))
|
||||||
} else {
|
.unwrap_or(&input_value)
|
||||||
input_value.clone()
|
.to_string();
|
||||||
}
|
|
||||||
} else {
|
|
||||||
input_value.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
if !property_id.is_empty() {
|
if !property_id.is_empty() {
|
||||||
// Add the property using the extracted ID
|
// Add the property using the extracted ID
|
||||||
|
@ -1082,23 +1027,17 @@ pub fn ItemsList(
|
||||||
input_element.set_value("");
|
input_element.set_value("");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} />
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<datalist id="properties">
|
<datalist id="properties">
|
||||||
{move || {
|
{move || {
|
||||||
let property_labels_snapshot = property_labels.get();
|
let property_labels = property_labels.get().clone();
|
||||||
property_labels_snapshot.iter()
|
property_labels.into_iter().map(|(property_id, label)| {
|
||||||
.map(|(property_id, label)| {
|
|
||||||
let option_value = format!("{} ({})", label, property_id);
|
|
||||||
view! {
|
view! {
|
||||||
<option value={option_value.clone()}>
|
<option value={format!("{} ({})", label, property_id)}>
|
||||||
{option_value}
|
{ format!("{} ({})", label, property_id) }
|
||||||
</option>
|
</option>
|
||||||
}
|
}
|
||||||
})
|
}).collect::<Vec<_>>()
|
||||||
.collect::<Vec<_>>()
|
|
||||||
}}
|
}}
|
||||||
</datalist>
|
</datalist>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,3 +1,2 @@
|
||||||
pub mod items_list;
|
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
|
@ -3,8 +3,6 @@ pub mod components;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod nostr;
|
pub mod nostr;
|
||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod utils;
|
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub mod db;
|
pub mod db;
|
||||||
|
|
||||||
|
|
22
src/main.rs
22
src/main.rs
|
@ -2,15 +2,10 @@
|
||||||
use actix_web::{web, HttpResponse, Responder};
|
use actix_web::{web, HttpResponse, Responder};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
use compareware::db::Database;
|
use compareware::db::Database;
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
use compareware::api::{ItemRequest,create_item, get_items, get_selected_properties, add_selected_property};
|
use compareware::api::{ItemRequest,create_item, get_items, get_selected_properties, add_selected_property};
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
use compareware::models::item::Item;
|
use compareware::models::item::Item;
|
||||||
use compareware::utils::panic_hook;
|
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
#[actix_web::main]
|
#[actix_web::main]
|
||||||
async fn main() -> std::io::Result<()> {
|
async fn main() -> std::io::Result<()> {
|
||||||
use actix_files::Files;
|
use actix_files::Files;
|
||||||
|
@ -23,11 +18,6 @@ async fn main() -> std::io::Result<()> {
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
panic_hook::init();
|
|
||||||
|
|
||||||
// Setup logging
|
|
||||||
std::env::set_var("RUST_LOG", "info");
|
|
||||||
|
|
||||||
// Initialize the database
|
// Initialize the database
|
||||||
let db = Database::new("compareware.db").unwrap();
|
let db = Database::new("compareware.db").unwrap();
|
||||||
db.create_schema().await.unwrap(); // Ensure the schema is created
|
db.create_schema().await.unwrap(); // Ensure the schema is created
|
||||||
|
@ -35,9 +25,10 @@ async fn main() -> std::io::Result<()> {
|
||||||
println!("Schema created successfully!");
|
println!("Schema created successfully!");
|
||||||
|
|
||||||
// Load configuration
|
// Load configuration
|
||||||
let conf = get_configuration(Some("Cargo.toml")).await.unwrap();
|
let conf = get_configuration(None).await.unwrap();
|
||||||
let addr = conf.leptos_options.site_addr;
|
let addr = conf.leptos_options.site_addr;
|
||||||
|
|
||||||
|
|
||||||
// Generate the list of routes in your Leptos App
|
// Generate the list of routes in your Leptos App
|
||||||
let routes = generate_route_list(App);
|
let routes = generate_route_list(App);
|
||||||
println!("listening on http://{}", &addr);
|
println!("listening on http://{}", &addr);
|
||||||
|
@ -88,7 +79,6 @@ async fn main() -> std::io::Result<()> {
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
// Handler to get items for a specific URL
|
// Handler to get items for a specific URL
|
||||||
async fn get_items_handler(
|
async fn get_items_handler(
|
||||||
db: web::Data<Arc<Mutex<Database>>>,
|
db: web::Data<Arc<Mutex<Database>>>,
|
||||||
|
@ -97,7 +87,6 @@ async fn get_items_handler(
|
||||||
get_items(db, web::Query(url.into_inner())).await
|
get_items(db, web::Query(url.into_inner())).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
// Handler to create an item for a specific URL
|
// Handler to create an item for a specific URL
|
||||||
async fn create_item_handler(
|
async fn create_item_handler(
|
||||||
db: web::Data<Arc<Mutex<Database>>>,
|
db: web::Data<Arc<Mutex<Database>>>,
|
||||||
|
@ -136,13 +125,11 @@ async fn add_selected_property_handler(
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
add_selected_property(db, url, property).await
|
add_selected_property(db, url, property).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
// Define the index handler
|
// Define the index handler
|
||||||
async fn index() -> HttpResponse {
|
async fn index() -> HttpResponse {
|
||||||
HttpResponse::Ok().body("Welcome to CompareWare!")
|
HttpResponse::Ok().body("Welcome to CompareWare!")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
// Define the URL handler
|
// Define the URL handler
|
||||||
async fn url_handler(url: web::Path<String>) -> HttpResponse {
|
async fn url_handler(url: web::Path<String>) -> HttpResponse {
|
||||||
|
@ -173,15 +160,12 @@ pub fn main() {
|
||||||
|
|
||||||
#[cfg(all(not(feature = "ssr"), feature = "csr"))]
|
#[cfg(all(not(feature = "ssr"), feature = "csr"))]
|
||||||
pub fn main() {
|
pub fn main() {
|
||||||
// Initialize custom panic hook for better diagnostics
|
|
||||||
panic_hook::init();
|
|
||||||
|
|
||||||
// a client-side main function is required for using `trunk serve`
|
// a client-side main function is required for using `trunk serve`
|
||||||
// prefer using `cargo leptos serve` instead
|
// prefer using `cargo leptos serve` instead
|
||||||
// to run: `trunk serve --open --features csr`
|
// to run: `trunk serve --open --features csr`
|
||||||
use compareware::app::*;
|
use compareware::app::*;
|
||||||
|
|
||||||
// console_error_panic_hook::set_once();
|
console_error_panic_hook::set_once();
|
||||||
|
|
||||||
leptos::mount_to_body(App);
|
leptos::mount_to_body(App);
|
||||||
}
|
}
|
|
@ -10,40 +10,3 @@ pub struct Item {
|
||||||
pub wikidata_id: Option<String>,
|
pub wikidata_id: Option<String>,
|
||||||
pub custom_properties: HashMap<String, 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,
|
|
||||||
}
|
|
|
@ -1,13 +0,0 @@
|
||||||
/// 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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,2 +0,0 @@
|
||||||
pub mod leptos_owner;
|
|
||||||
pub mod panic_hook;
|
|
|
@ -1,54 +0,0 @@
|
||||||
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");
|
|
||||||
}
|
|
|
@ -1,154 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
|
@ -1,52 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
|
@ -1,2 +0,0 @@
|
||||||
pub mod bloodhound_mock;
|
|
||||||
pub mod jquery_mock;
|
|
|
@ -1,558 +0,0 @@
|
||||||
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