Compare commits
44 commits
Author | SHA1 | Date | |
---|---|---|---|
f1595c0756 | |||
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 3356 additions and 688 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-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 }
|
||||||
|
@ -27,6 +29,7 @@ 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"
|
||||||
|
@ -48,6 +51,13 @@ 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"
|
||||||
|
@ -77,8 +87,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:3000) where the server serves the content. Use it in your server setup.
|
# 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:3000"
|
site-addr = "127.0.0.1:3004"
|
||||||
# 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.
|
||||||
|
|
54
README.md
54
README.md
|
@ -26,7 +26,7 @@ It combines Rust's **Leptos** for a modern, reactive frontend and **Nostr** for
|
||||||
```bash
|
```bash
|
||||||
cargo leptos serve
|
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**
|
## **Database Schema**
|
||||||
### Key Concepts
|
### Key Concepts
|
||||||
|
@ -97,7 +97,57 @@ sequenceDiagram
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d
|
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**
|
### **Collaboration**
|
||||||
We welcome contributions! Here’s how you can help:
|
We welcome contributions! Here’s how you can help:
|
||||||
|
|
361
assets/style.css
361
assets/style.css
|
@ -1,158 +1,367 @@
|
||||||
|
/* Variables for non-cell elements */
|
||||||
|
:root {
|
||||||
|
--primary: #1e88e5;
|
||||||
|
--primary-dark: #1565c0;
|
||||||
|
--text-primary: #333;
|
||||||
|
--text-secondary: #666;
|
||||||
|
--background: #f4f4f9;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--border: #ddd;
|
||||||
|
--danger: #ff4d4f;
|
||||||
|
--danger-hover: #ff7875;
|
||||||
|
--danger-active: #d9363e;
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
--spacing-xs: 4px;
|
||||||
|
--spacing-sm: 8px;
|
||||||
|
--spacing-md: 15px;
|
||||||
|
--spacing-lg: 20px;
|
||||||
|
--spacing-xl: 30px;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-lg: 0 8px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
/* Border radius */
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base styles */
|
||||||
body {
|
body {
|
||||||
font-family: Arial, sans-serif;
|
font-family: 'Inter', Arial, sans-serif;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background-color: #f4f4f9;
|
background-color: var(--background);
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
color: #1e88e5;
|
color: var(--primary);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 20px;
|
margin-bottom: var(--spacing-lg);
|
||||||
|
font-size: 2rem;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Container for better content width control */
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form styling */
|
||||||
form {
|
form {
|
||||||
margin: 20px auto;
|
margin: var(--spacing-xl) auto;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
padding: 20px;
|
padding: var(--spacing-lg);
|
||||||
background: white;
|
background: var(--card-bg);
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-md);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-md);
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
form:hover {
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
form input, form textarea, form button {
|
form input, form textarea, form button {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 15px;
|
margin-bottom: var(--spacing-md);
|
||||||
padding: 10px;
|
padding: var(--spacing-md);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
form button {
|
form button {
|
||||||
background-color: #1e88e5;
|
background-color: var(--primary);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.3s ease;
|
transition: background-color 0.3s ease, transform 0.1s ease;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
form button:hover {
|
form button:hover {
|
||||||
background-color: #1565c0;
|
background-color: var(--primary-dark);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
form button:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lists */
|
||||||
ul {
|
ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul li {
|
ul li {
|
||||||
background: #fff;
|
background: var(--card-bg);
|
||||||
margin: 10px 0;
|
margin: var(--spacing-md) 0;
|
||||||
padding: 15px;
|
padding: var(--spacing-lg);
|
||||||
border-radius: 5px;
|
border-radius: var(--radius-sm);
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ul li:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
margin: var(--spacing-lg) 0;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
th, td {
|
th, td {
|
||||||
border: 1px solid #ddd;
|
border: 1px solid var(--border);
|
||||||
padding: 8px;
|
padding: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
background-color: #f2f2f2;
|
background-color: #f2f2f2;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr {
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background-color: rgba(30, 136, 229, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Style for the grid container */
|
/* Style for the grid container */
|
||||||
.grid-container {
|
.grid-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); /* Adjust the minimum width for cells */
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
gap: 1px; /* Gap between cells */
|
gap: 1px;
|
||||||
background-color: #ccc;/* Grid line color */
|
background-color: #ccc;
|
||||||
border: 1px solid #aaa; /* Outer border */
|
border: 1px solid #aaa;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Style for individual cells */
|
/* Style for individual cells */
|
||||||
.editable-cell {
|
.editable-cell {
|
||||||
display: flex; /* Use flexbox for better layout control */
|
display: flex;
|
||||||
flex-direction: column; /* Stack children vertically */
|
flex-direction: column;
|
||||||
width: 100%; /* Full width of the allocated space */
|
width: 100%;
|
||||||
height: 100%; /* Full height of the allocated space */
|
height: 100%;
|
||||||
position: relative; /* Relative positioning for absolute children */
|
position: relative;
|
||||||
box-sizing: border-box; /* Ensure padding and border are included in width/height */
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Style for the input field inside the editable cell */
|
/* Style for the input field inside the editable cell */
|
||||||
.editable-cell-input {
|
.editable-cell-input {
|
||||||
width: 100%; /* Ensure input takes up full width */
|
width: 100%;
|
||||||
height: 100%; /* Ensure input takes up full height */
|
height: 100%;
|
||||||
border: none; /* Remove input box borders */
|
border: none;
|
||||||
padding: 8px; /* Add padding for spacing */
|
padding: 8px;
|
||||||
box-sizing: border-box; /* Ensure padding doesn't cause overflow */
|
box-sizing: border-box;
|
||||||
font-size: 14px; /* Adjust font size */
|
font-size: 14px;
|
||||||
text-align: left; /* Align text to the left */
|
text-align: left;
|
||||||
outline: none; /* Remove outline for better UI */
|
outline: none;
|
||||||
background-color: transparent; /* Make background transparent */
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Style for the focused input field */
|
/* Style for the focused input field */
|
||||||
.editable-cell-input:focus {
|
.editable-cell-input:focus {
|
||||||
background-color: #e0f7fa; /* Light blue background when focused */
|
background-color: #e0f7fa;
|
||||||
border: 1px solid #00796b; /* Green border when focused */
|
border: 1px solid #00796b;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Style for the suggestions list */
|
/* Suggestion styling */
|
||||||
.editable-cell-suggestions {
|
.editable-cell-suggestions {
|
||||||
position: absolute; /* Position suggestions absolutely within the cell */
|
position: absolute;
|
||||||
top: 100%; /* Place suggestions below the input field */
|
top: 100%;
|
||||||
left: 0; /* Align suggestions with the left edge of the cell */
|
left: 0;
|
||||||
width: 100%; /* Full width of the cell */
|
width: 100%;
|
||||||
max-height: 200px; /* Limit height of suggestions list */
|
max-height: 200px;
|
||||||
overflow-y: auto; /* Add scrollbar if suggestions exceed max height */
|
overflow-y: auto;
|
||||||
background-color: white; /* White background for suggestions */
|
background-color: white;
|
||||||
border: 1px solid #ddd; /* Light border for suggestions */
|
border: 1px solid var(--border);
|
||||||
z-index: 10; /* Ensure suggestions appear above other content */
|
z-index: 10;
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); /* Add shadow for better visibility */
|
box-shadow: var(--shadow-md);
|
||||||
|
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Style for individual suggestion items */
|
|
||||||
.editable-cell-suggestions li {
|
.editable-cell-suggestions li {
|
||||||
padding: 8px; /* Add padding for spacing */
|
padding: 8px;
|
||||||
cursor: pointer; /* Change cursor to pointer on hover */
|
cursor: pointer;
|
||||||
border-bottom: 1px solid #eee; /* Add separator between items */
|
border-bottom: 1px solid #eee;
|
||||||
|
margin: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editable-cell-suggestions li:hover {
|
.editable-cell-suggestions li:hover {
|
||||||
background-color: #f5f5f5; /* Light gray background on hover */
|
background-color: #f5f5f5;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-icon {
|
.editable-cell-suggestions li:last-child {
|
||||||
margin-left: 10px;
|
border-bottom: none;
|
||||||
padding: 5px;
|
|
||||||
border: none;
|
|
||||||
background-color: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-icon i {
|
|
||||||
font-size: 18px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-icon i.fas.fa-search {
|
|
||||||
margin-left: 5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Textarea */
|
||||||
.editable-cell-textarea {
|
.editable-cell-textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editable-cell-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typeahead */
|
||||||
|
.typeahead-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tt-menu {
|
||||||
|
width: 100%;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tt-suggestion {
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tt-suggestion:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tt-cursor {
|
||||||
|
background-color: #e9e9e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tt-hint {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-item {
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-item.tt-cursor {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Delete buttons */
|
||||||
|
.delete-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background-color: var(--danger);
|
||||||
|
color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button:hover {
|
||||||
|
background-color: var(--danger-hover);
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button:active {
|
||||||
|
background-color: var(--danger-active);
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-delete {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-delete {
|
||||||
|
vertical-align: middle;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button class for consistency */
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease, transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
background-color: var(--primary-dark);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
form {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
form input, form textarea, form button {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -2,10 +2,14 @@ services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3004:3004"
|
||||||
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,6 +28,8 @@ 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
|
||||||
|
@ -50,7 +52,8 @@ COPY assets /app/assets
|
||||||
|
|
||||||
# Configure container, expose port and set entrypoint
|
# Configure container, expose port and set entrypoint
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
EXPOSE 3000
|
EXPOSE 3004
|
||||||
ENV LEPTOS_SITE_ADDR=0.0.0.0:3000
|
ENV LEPTOS_SITE_ADDR="0.0.0.0:3004"
|
||||||
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:3000',
|
// baseURL: 'http://localhost:3004',
|
||||||
|
|
||||||
/* 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: 3000,
|
// port: 3004,
|
||||||
// },
|
// },
|
||||||
});
|
});
|
||||||
|
|
|
@ -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:3000/");
|
await page.goto("http://localhost:3004/");
|
||||||
|
|
||||||
await expect(page).toHaveTitle("Welcome to Leptos");
|
await expect(page).toHaveTitle("Welcome to Leptos");
|
||||||
|
|
||||||
|
|
|
@ -29,6 +29,10 @@ 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,12 +10,9 @@ 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;
|
||||||
#[derive(Deserialize, Clone, Debug)]
|
use crate::components::typeahead_input::TypeaheadInput;
|
||||||
struct WikidataSuggestion {
|
use crate::models::item::WikidataSuggestion;
|
||||||
id: String,
|
use leptos::html::Input;
|
||||||
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> {
|
||||||
|
@ -123,12 +120,16 @@ 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());
|
||||||
|
|
||||||
// State to track the currently focused cell
|
// State to track the currently focused cell
|
||||||
let (focused_cell, set_focused_cell) = create_signal(None::<String>);
|
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
|
// State to manage dynamic property names
|
||||||
let (custom_properties, set_custom_properties) = create_signal(Vec::<String>::new());
|
let (custom_properties, set_custom_properties) = create_signal(Vec::<String>::new());
|
||||||
|
|
||||||
|
@ -143,6 +144,20 @@ 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());
|
||||||
|
|
||||||
|
// 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")]
|
#[cfg(feature = "ssr")]
|
||||||
fn get_current_url() -> String {
|
fn get_current_url() -> String {
|
||||||
use leptos::use_context;
|
use leptos::use_context;
|
||||||
|
@ -206,12 +221,18 @@ 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;
|
||||||
let labels = fetch_property_labels(property_ids).await;
|
match 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());
|
||||||
}
|
}
|
||||||
|
@ -354,11 +375,16 @@ pub fn ItemsList(
|
||||||
let (wikidata_suggestions, set_wikidata_suggestions) = create_signal(HashMap::<String, Vec<WikidataSuggestion>>::new());
|
let (wikidata_suggestions, set_wikidata_suggestions) = create_signal(HashMap::<String, Vec<WikidataSuggestion>>::new());
|
||||||
|
|
||||||
// Function to fetch Wikidata suggestions
|
// Function to fetch Wikidata suggestions
|
||||||
let fetch_wikidata_suggestions = move |key: String, query: String| {
|
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);
|
log!("Fetching suggestions for key: {}, query: {}", key, query);
|
||||||
|
let set_wikidata_suggestions_clone = set_wikidata_suggestions.clone();
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
set_wikidata_suggestions.update(|suggestions| {
|
set_wikidata_suggestions_clone.update(|suggestions| {
|
||||||
suggestions.remove(&key);
|
suggestions.remove(&key);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
@ -372,7 +398,7 @@ pub fn ItemsList(
|
||||||
match gloo_net::http::Request::get(&url).send().await {
|
match gloo_net::http::Request::get(&url).send().await {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
if let Ok(data) = response.json::<WikidataResponse>().await {
|
if let Ok(data) = response.json::<WikidataResponse>().await {
|
||||||
set_wikidata_suggestions.update(|suggestions| {
|
set_wikidata_suggestions_clone.update(|suggestions| {
|
||||||
suggestions.insert(key, data.search);
|
suggestions.insert(key, data.search);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -380,6 +406,7 @@ pub fn ItemsList(
|
||||||
Err(_) => log!("Failed to fetch Wikidata suggestions"),
|
Err(_) => log!("Failed to fetch Wikidata suggestions"),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
//function to fetch properties
|
//function to fetch properties
|
||||||
|
@ -449,10 +476,16 @@ pub fn ItemsList(
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if !missing_ids.is_empty() {
|
if !missing_ids.is_empty() {
|
||||||
let new_labels = fetch_property_labels(missing_ids).await;
|
match 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
|
||||||
|
@ -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);
|
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
|
||||||
|
@ -529,7 +562,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 HashMap::new();
|
return Err(format!("Unexpected status code: {}", response.status()));
|
||||||
}
|
}
|
||||||
|
|
||||||
match response.text().await {
|
match response.text().await {
|
||||||
|
@ -557,23 +590,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());
|
||||||
result
|
Ok(result)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log!("Error parsing response from Wikidata: {:?}", e);
|
log!("Error parsing response from Wikidata: {:?}", e);
|
||||||
HashMap::new()
|
Err(format!("Error parsing response: {:?}", e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log!("Error reading response body: {:?}", e);
|
log!("Error reading response body: {:?}", e);
|
||||||
HashMap::new()
|
Err(format!("Error reading response body: {:?}", e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log!("Error fetching property labels from Wikidata: {:?}", 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 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.get().contains_key(&normalized_property) {
|
if !property_labels_snapshot.contains_key(&normalized_property) {
|
||||||
spawn_local({
|
let normalized_property_clone = normalized_property.clone();
|
||||||
let normalized_property = normalized_property.clone();
|
let set_property_labels_clone = set_property_labels.clone();
|
||||||
let set_property_labels = set_property_labels.clone();
|
|
||||||
async move {
|
// Add a placeholder label immediately
|
||||||
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.get().contains_key(&normalized_property) && !normalized_property.is_empty() {
|
if !selected_properties_snapshot.contains_key(&normalized_property) {
|
||||||
// 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
|
||||||
spawn_local({
|
let current_url_clone = Rc::clone(¤t_url);
|
||||||
let current_url = Rc::clone(¤t_url);
|
let normalized_property_clone = normalized_property.clone();
|
||||||
let normalized_property = normalized_property_clone.clone();
|
|
||||||
async move {
|
spawn_local(async move {
|
||||||
let response = gloo_net::http::Request::post(
|
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()
|
.unwrap()
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
@ -636,82 +693,84 @@ 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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ensure the grid updates reactively
|
// Update items with the new property
|
||||||
set_items.update(|items| {
|
set_items.update(|items| {
|
||||||
for item in items {
|
for item in items {
|
||||||
item.custom_properties.entry(normalized_property.clone()).or_insert_with(|| "".to_string());
|
// Only add if it doesn't exist
|
||||||
|
if !item.custom_properties.contains_key(&normalized_property) {
|
||||||
|
item.custom_properties.insert(normalized_property.clone(), "".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Save the updated item to the database
|
// Save each item to the database
|
||||||
|
let items_snapshot = items.get();
|
||||||
|
for item in items_snapshot {
|
||||||
let item_clone = item.clone();
|
let item_clone = item.clone();
|
||||||
spawn_local({
|
let current_url_clone = Rc::clone(¤t_url);
|
||||||
let current_url = Rc::clone(¤t_url);
|
let selected_properties_clone = selected_properties;
|
||||||
async move {
|
|
||||||
save_item_to_db(item_clone, selected_properties, current_url.to_string()).await;
|
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());
|
||||||
|
|
||||||
// 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());
|
|
||||||
log!("Added property with label: {}", property_label);
|
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 {
|
if let Some(wikidata_id) = &item.wikidata_id {
|
||||||
let wikidata_id = wikidata_id.clone();
|
let wikidata_id_clone = wikidata_id.clone();
|
||||||
let set_items = set_items.clone();
|
let normalized_property_clone = normalized_property.clone();
|
||||||
let set_fetched_properties = set_fetched_properties.clone();
|
let set_items_clone = set_items.clone();
|
||||||
let property_clone = normalized_property.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 {
|
spawn_local(async move {
|
||||||
let properties = fetch_item_properties(
|
let properties = fetch_item_properties(
|
||||||
&wikidata_id,
|
&wikidata_id_clone,
|
||||||
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;
|
||||||
|
|
||||||
// Update the specific property for this item
|
if let Some(value) = properties.get(&normalized_property_clone) {
|
||||||
if let Some(value) = properties.get(&property_clone) {
|
set_items_clone.update(|items| {
|
||||||
set_items.update(|items| {
|
for item in items {
|
||||||
if let Some(item) = items.iter_mut()
|
if item.wikidata_id.as_ref() == Some(&wikidata_id_clone) {
|
||||||
.find(|i| i.wikidata_id.as_ref() == Some(&wikidata_id))
|
|
||||||
{
|
|
||||||
item.custom_properties.insert(
|
item.custom_properties.insert(
|
||||||
property_clone.clone(),
|
normalized_property_clone.clone(),
|
||||||
value.clone()
|
value.clone()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
})};
|
};
|
||||||
|
|
||||||
// Update item fields
|
// Update item fields
|
||||||
let update_item = {
|
let update_item = {
|
||||||
|
@ -795,7 +854,11 @@ pub fn ItemsList(
|
||||||
view! {
|
view! {
|
||||||
<th>
|
<th>
|
||||||
{item.name.clone()}
|
{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>
|
</th>
|
||||||
}
|
}
|
||||||
}).collect::<Vec<_>>()}
|
}).collect::<Vec<_>>()}
|
||||||
|
@ -804,118 +867,272 @@ 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_signal = items;
|
||||||
|
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="editable-cell">
|
<div class="typeahead-container"
|
||||||
<EditableCell
|
// 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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TypeaheadInput
|
||||||
|
key=item.id.clone()
|
||||||
value=item.name.clone()
|
value=item.name.clone()
|
||||||
on_input=move |value| {
|
fetch_suggestions=Callback::new({
|
||||||
update_item_clone(index, "name", value.clone());
|
// Use the item's unique ID in the key to ensure uniqueness
|
||||||
fetch_wikidata_suggestions(format!("name-{}", index), value);
|
let key = format!("name-{}-{}", index, item.id);
|
||||||
}
|
let wikidata_suggestions_clone = wikidata_suggestions.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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}))
|
|
||||||
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
|
move |query: String| -> Vec<WikidataSuggestion> {
|
||||||
let wikidata_id = id.clone();
|
// Only fetch suggestions if the query is not empty
|
||||||
spawn_local(async move {
|
if !query.is_empty() {
|
||||||
let properties = fetch_item_properties(&wikidata_id, set_property_labels.clone(), property_cache.clone(), set_property_cache.clone(), property_labels.clone()).await;
|
// Fetch suggestions in a separate function to avoid capturing too much
|
||||||
// log!("Fetched properties for Wikidata ID {}: {:?}", wikidata_id, properties);
|
fetch_wikidata_suggestions(key.clone(), query.clone());
|
||||||
|
|
||||||
// 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 {
|
} else {
|
||||||
log!("Suggestions list hidden");
|
// Clear suggestions for this key if query is empty
|
||||||
view! {
|
set_wikidata_suggestions.update(|suggestions| {
|
||||||
<ul></ul>
|
suggestions.remove(&key);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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>
|
</div>
|
||||||
}.into_view(),
|
}.into_view(),
|
||||||
|
|
||||||
"Description" => view! {
|
"Description" => view! {
|
||||||
<EditableCell
|
<EditableCell
|
||||||
value=item.description.clone()
|
value=item.description.clone()
|
||||||
|
@ -938,7 +1155,9 @@ pub fn ItemsList(
|
||||||
}}
|
}}
|
||||||
</td>
|
</td>
|
||||||
}
|
}
|
||||||
}).collect::<Vec<_>>()}
|
}).collect::<Vec<_>>()
|
||||||
|
}
|
||||||
|
}}
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
}).collect::<Vec<_>>()}
|
}).collect::<Vec<_>>()}
|
||||||
|
@ -961,7 +1180,10 @@ pub fn ItemsList(
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
{ property_label }
|
{ property_label }
|
||||||
<button class="delete-property" on:click=move |_| {
|
<button
|
||||||
|
class="delete-button property-delete"
|
||||||
|
title="Delete property"
|
||||||
|
on:click=move |_| {
|
||||||
log!("Deleting property: {}", property_clone_for_button);
|
log!("Deleting property: {}", property_clone_for_button);
|
||||||
remove_property_clone(property_clone_for_button.clone());
|
remove_property_clone(property_clone_for_button.clone());
|
||||||
set_custom_properties.update(|props| {
|
set_custom_properties.update(|props| {
|
||||||
|
@ -975,12 +1197,14 @@ pub fn ItemsList(
|
||||||
item.custom_properties.remove(&property_clone_for_button);
|
item.custom_properties.remove(&property_clone_for_button);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}>{ "Delete" }</button>
|
}
|
||||||
|
>{ "x" }</button>
|
||||||
</td>
|
</td>
|
||||||
{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();
|
||||||
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 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! {
|
||||||
|
@ -1008,18 +1232,29 @@ pub fn ItemsList(
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div style="margin-bottom: 20px;">
|
<div style="margin-bottom: 20px;">
|
||||||
<input type="text" id="new-property" placeholder="Add New Property" list="properties" on:keydown=move |event| {
|
<input
|
||||||
|
type="text"
|
||||||
|
id="new-property"
|
||||||
|
placeholder="Add New Property"
|
||||||
|
list="properties"
|
||||||
|
on:keydown=move |event| {
|
||||||
if event.key() == "Enter" {
|
if event.key() == "Enter" {
|
||||||
let input_element = event.target().unwrap().dyn_into::<web_sys::HtmlInputElement>().unwrap();
|
// 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();
|
let input_value = input_element.value();
|
||||||
|
|
||||||
// Extract property ID from "Label (P123)" format
|
// Extract property ID from "Label (P123)" format
|
||||||
let property_id = input_value
|
let property_id = if input_value.contains(" (") && input_value.ends_with(')') {
|
||||||
.split(" (")
|
let parts: Vec<&str> = input_value.rsplitn(2, " (").collect();
|
||||||
.last()
|
if parts.len() == 2 {
|
||||||
.and_then(|s| s.strip_suffix(')'))
|
parts[0].trim_end_matches(')').to_string()
|
||||||
.unwrap_or(&input_value)
|
} else {
|
||||||
.to_string();
|
input_value.clone()
|
||||||
|
}
|
||||||
|
} 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
|
||||||
|
@ -1027,17 +1262,23 @@ pub fn ItemsList(
|
||||||
input_element.set_value("");
|
input_element.set_value("");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} />
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
<datalist id="properties">
|
<datalist id="properties">
|
||||||
{move || {
|
{move || {
|
||||||
let property_labels = property_labels.get().clone();
|
let property_labels_snapshot = property_labels.get();
|
||||||
property_labels.into_iter().map(|(property_id, label)| {
|
property_labels_snapshot.iter()
|
||||||
|
.map(|(property_id, label)| {
|
||||||
|
let option_value = format!("{} ({})", label, property_id);
|
||||||
view! {
|
view! {
|
||||||
<option value={format!("{} ({})", label, property_id)}>
|
<option value={option_value.clone()}>
|
||||||
{ format!("{} ({})", label, property_id) }
|
{option_value}
|
||||||
</option>
|
</option>
|
||||||
}
|
}
|
||||||
}).collect::<Vec<_>>()
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
}}
|
}}
|
||||||
</datalist>
|
</datalist>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,2 +1,3 @@
|
||||||
pub mod items_list;
|
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 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,10 +2,15 @@
|
||||||
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;
|
||||||
|
@ -18,6 +23,11 @@ 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
|
||||||
|
@ -25,10 +35,9 @@ async fn main() -> std::io::Result<()> {
|
||||||
println!("Schema created successfully!");
|
println!("Schema created successfully!");
|
||||||
|
|
||||||
// Load configuration
|
// Load configuration
|
||||||
let conf = get_configuration(None).await.unwrap();
|
let conf = get_configuration(Some("Cargo.toml")).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);
|
||||||
|
@ -79,6 +88,7 @@ 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>>>,
|
||||||
|
@ -87,6 +97,7 @@ 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>>>,
|
||||||
|
@ -125,11 +136,13 @@ 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 {
|
||||||
|
@ -160,12 +173,15 @@ 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,3 +10,40 @@ 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,
|
||||||
|
}
|
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