Compare commits
9 Commits
a9eb23ad76
...
AddingCont
| Author | SHA1 | Date | |
|---|---|---|---|
| ce3924b5ca | |||
| 8f2528787a | |||
| 7c701a9116 | |||
| e96ac5ddfd | |||
| 378775cdf9 | |||
| e34d675e38 | |||
| 93cb477c21 | |||
| 96ed447eae | |||
| 1be75263ad |
@@ -201,283 +201,190 @@ class TestControlRender:
|
||||
|
||||
### UTR-11: Required Reading for Control Render Tests
|
||||
|
||||
---
|
||||
**Before writing ANY render tests for Controls, you MUST:**
|
||||
|
||||
#### **UTR-11.0: Read the matcher documentation (MANDATORY PREREQUISITE)**
|
||||
|
||||
**Principle:** Before writing any render tests, you MUST read and understand the complete matcher documentation.
|
||||
|
||||
**Mandatory reading:** `docs/testing_rendered_components.md`
|
||||
|
||||
**What you must master:**
|
||||
- **`matches(actual, expected)`** - How to validate that an element matches your expectations
|
||||
- **`find(ft, expected)`** - How to search for elements within an HTML tree
|
||||
- **Predicates** - How to test patterns instead of exact values:
|
||||
- `Contains()`, `StartsWith()`, `DoesNotContain()`, `AnyValue()` for attributes
|
||||
- `Empty()`, `NoChildren()`, `AttributeForbidden()` for children
|
||||
- **Error messages** - How to read `^^^` markers to understand differences
|
||||
- **Key principle** - Test only what matters, ignore the rest
|
||||
|
||||
**Without this reading, you cannot write correct render tests.**
|
||||
1. **Read the matcher documentation**: `docs/testing_rendered_components.md`
|
||||
2. **Understand the key concepts**:
|
||||
- How `matches()` and `find()` work
|
||||
- When to use predicates (Contains, StartsWith, AnyValue, etc.)
|
||||
- How to test only what matters (not every detail)
|
||||
- How to read error messages with `^^^` markers
|
||||
3. **Apply the best practices** detailed below
|
||||
|
||||
---
|
||||
|
||||
### **TEST FILE STRUCTURE**
|
||||
#### **UTR-11.1 : Pattern de test en trois étapes (RÈGLE FONDAMENTALE)**
|
||||
|
||||
---
|
||||
**Principe :** C'est le pattern par défaut à appliquer pour tous les tests de rendu. Les autres règles sont des compléments à ce pattern.
|
||||
|
||||
#### **UTR-11.1: Always start with a global structure test (FUNDAMENTAL RULE)**
|
||||
**Les trois étapes :**
|
||||
1. **Extraire l'élément à tester** avec `find_one()` ou `find()` à partir du rendu global
|
||||
2. **Définir la structure attendue** avec `expected = ...`
|
||||
3. **Comparer** avec `assert matches(element, expected)`
|
||||
|
||||
**Principle:** The **first render test** must ALWAYS verify the global HTML structure of the component. This is the test that helps readers understand the general architecture.
|
||||
**Pourquoi :** Ce pattern permet des messages d'erreur clairs et sépare la recherche de l'élément de la validation de sa structure.
|
||||
|
||||
**Why:**
|
||||
- Gives immediate overview of the structure
|
||||
- Facilitates understanding for new contributors
|
||||
- Quickly detects major structural changes
|
||||
- Serves as living documentation of HTML architecture
|
||||
|
||||
**Test format:**
|
||||
```python
|
||||
def test_i_can_render_component_with_no_data(self, component):
|
||||
"""Test that Component renders with correct global structure."""
|
||||
html = component.render()
|
||||
expected = Div(
|
||||
Div(id=f"{component.get_id()}-controller"), # controller
|
||||
Div(id=f"{component.get_id()}-header"), # header
|
||||
Div(id=f"{component.get_id()}-content"), # content
|
||||
id=component.get_id(),
|
||||
)
|
||||
assert matches(html, expected)
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Simple test with only IDs of main sections
|
||||
- Inline comments to identify each section
|
||||
- No detailed verification of attributes (classes, content, etc.)
|
||||
- This test must be the first in the `TestComponentRender` class
|
||||
|
||||
**Test order:**
|
||||
1. **First test:** Global structure (UTR-11.1)
|
||||
2. **Following tests:** Details of each section (UTR-11.2 to UTR-11.10)
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.2: Break down complex tests into explicit steps**
|
||||
|
||||
**Principle:** When a test verifies multiple levels of HTML nesting, break it down into numbered steps with explicit comments.
|
||||
|
||||
**Why:**
|
||||
- Facilitates debugging (you know exactly which step fails)
|
||||
- Improves test readability
|
||||
- Allows validating structure level by level
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
def test_content_wrapper_when_tab_active(self, tabs_manager):
|
||||
"""Test that content wrapper shows active tab content."""
|
||||
tab_id = tabs_manager.create_tab("Tab1", Div("My Content"))
|
||||
wrapper = tabs_manager._mk_tab_content_wrapper()
|
||||
|
||||
# Step 1: Validate wrapper global structure
|
||||
expected = Div(
|
||||
Div(), # tab content, tested in step 2
|
||||
id=f"{tabs_manager.get_id()}-content-wrapper",
|
||||
cls=Contains("mf-tab-content-wrapper"),
|
||||
)
|
||||
assert matches(wrapper, expected)
|
||||
|
||||
# Step 2: Extract and validate specific content
|
||||
tab_content = find_one(wrapper, Div(id=f"{tabs_manager.get_id()}-{tab_id}-content"))
|
||||
expected = Div(
|
||||
Div("My Content"), # <= actual content
|
||||
cls=Contains("mf-tab-content"),
|
||||
)
|
||||
assert matches(tab_content, expected)
|
||||
```
|
||||
|
||||
**Pattern:**
|
||||
- Step 1: Global structure with empty `Div()` + comment for children tested after
|
||||
- Step 2+: Extraction with `find_one()` + detailed validation
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.3: Three-step pattern for simple tests**
|
||||
|
||||
**Principle:** For tests not requiring multi-level decomposition, use the standard three-step pattern.
|
||||
|
||||
**The three steps:**
|
||||
1. **Extract the element to test** with `find_one()` or `find()` from the global render
|
||||
2. **Define the expected structure** with `expected = ...`
|
||||
3. **Compare** with `assert matches(element, expected)`
|
||||
|
||||
**Example:**
|
||||
**Exemple :**
|
||||
```python
|
||||
# ✅ BON - Pattern en trois étapes
|
||||
def test_header_has_two_sides(self, layout):
|
||||
"""Test that there is a left and right header section."""
|
||||
# Step 1: Extract the element to test
|
||||
# Étape 1 : Extraire l'élément à tester
|
||||
header = find_one(layout.render(), Header(cls=Contains("mf-layout-header")))
|
||||
|
||||
# Step 2: Define the expected structure
|
||||
# Étape 2 : Définir la structure attendue
|
||||
expected = Header(
|
||||
Div(id=f"{layout._id}_hl"),
|
||||
Div(id=f"{layout._id}_hr"),
|
||||
)
|
||||
|
||||
# Step 3: Compare
|
||||
# Étape 3 : Comparer
|
||||
assert matches(header, expected)
|
||||
|
||||
# ❌ À ÉVITER - Tout imbriqué en une ligne
|
||||
def test_header_has_two_sides(self, layout):
|
||||
assert matches(
|
||||
find_one(layout.render(), Header(cls=Contains("mf-layout-header"))),
|
||||
Header(Div(id=f"{layout._id}_hl"), Div(id=f"{layout._id}_hr"))
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **HOW TO SEARCH FOR ELEMENTS**
|
||||
**Note :** Cette règle s'applique à presque tous les tests. Les autres règles ci-dessous complètent ce pattern fondamental.
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.4: Prefer searching by ID**
|
||||
#### **COMMENT CHERCHER LES ÉLÉMENTS**
|
||||
|
||||
**Principle:** Always search for an element by its `id` when it has one, rather than by class or other attribute.
|
||||
---
|
||||
|
||||
**Why:** More robust, faster, and targeted (an ID is unique).
|
||||
#### **UTR-11.2 : Privilégier la recherche par ID**
|
||||
|
||||
**Example:**
|
||||
**Principe :** Toujours chercher un élément par son `id` quand il en a un, plutôt que par classe ou autre attribut.
|
||||
|
||||
**Pourquoi :** Plus robuste, plus rapide, et ciblé (un ID est unique).
|
||||
|
||||
**Exemple :**
|
||||
```python
|
||||
# ✅ GOOD - search by ID
|
||||
# ✅ BON - recherche par ID
|
||||
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
|
||||
|
||||
# ❌ AVOID - search by class when an ID exists
|
||||
# ❌ À ÉVITER - recherche par classe quand un ID existe
|
||||
drawer = find_one(layout.render(), Div(cls=Contains("mf-layout-left-drawer")))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.5: Use `find_one()` vs `find()` based on context**
|
||||
#### **UTR-11.3 : Utiliser `find_one()` vs `find()` selon le contexte**
|
||||
|
||||
**Principle:**
|
||||
- `find_one()`: When you search for a unique element and want to test its complete structure
|
||||
- `find()`: When you search for multiple elements or want to count/verify their presence
|
||||
**Principe :**
|
||||
- `find_one()` : Quand vous cherchez un élément unique et voulez tester sa structure complète
|
||||
- `find()` : Quand vous cherchez plusieurs éléments ou voulez compter/vérifier leur présence
|
||||
|
||||
**Examples:**
|
||||
**Exemples :**
|
||||
```python
|
||||
# ✅ GOOD - find_one for unique structure
|
||||
# ✅ BON - find_one pour structure unique
|
||||
header = find_one(layout.render(), Header(cls=Contains("mf-layout-header")))
|
||||
expected = Header(...)
|
||||
assert matches(header, expected)
|
||||
|
||||
# ✅ GOOD - find for counting
|
||||
# ✅ BON - find pour compter
|
||||
resizers = find(drawer, Div(cls=Contains("mf-resizer-left")))
|
||||
assert len(resizers) == 1, "Left drawer should contain exactly one resizer element"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **HOW TO SPECIFY EXPECTED STRUCTURE**
|
||||
#### **COMMENT SPÉCIFIER LA STRUCTURE ATTENDUE**
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.6: Always use `Contains()` for `cls` and `style` attributes**
|
||||
#### **UTR-11.4 : Toujours utiliser `Contains()` pour les attributs `cls` et `style`**
|
||||
|
||||
**Principle:**
|
||||
- For `cls`: CSS classes can be in any order. Test only important classes with `Contains()`.
|
||||
- For `style`: CSS properties can be in any order. Test only important properties with `Contains()`.
|
||||
**Principe :**
|
||||
- Pour `cls` : Les classes CSS peuvent être dans n'importe quel ordre. Testez uniquement les classes importantes avec `Contains()`.
|
||||
- Pour `style` : Les propriétés CSS peuvent être dans n'importe quel ordre. Testez uniquement les propriétés importantes avec `Contains()`.
|
||||
|
||||
**Why:** Avoids false negatives due to class/property order or spacing.
|
||||
**Pourquoi :** Évite les faux négatifs dus à l'ordre des classes/propriétés ou aux espaces.
|
||||
|
||||
**Examples:**
|
||||
**Exemples :**
|
||||
```python
|
||||
# ✅ GOOD - Contains for cls (one or more classes)
|
||||
# ✅ BON - Contains pour cls (une ou plusieurs classes)
|
||||
expected = Div(cls=Contains("mf-layout-drawer"))
|
||||
expected = Div(cls=Contains("mf-layout-drawer", "mf-layout-left-drawer"))
|
||||
|
||||
# ✅ GOOD - Contains for style
|
||||
# ✅ BON - Contains pour style
|
||||
expected = Div(style=Contains("width: 250px"))
|
||||
|
||||
# ❌ AVOID - exact class test
|
||||
# ❌ À ÉVITER - test exact des classes
|
||||
expected = Div(cls="mf-layout-drawer mf-layout-left-drawer")
|
||||
|
||||
# ❌ AVOID - exact complete style test
|
||||
# ❌ À ÉVITER - test exact du style complet
|
||||
expected = Div(style="width: 250px; overflow: hidden; display: flex;")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.7: Use `TestIcon()` or `TestIconNotStr()` to test icon presence**
|
||||
#### **UTR-11.5 : Utiliser `TestIcon()` pour tester la présence d'une icône**
|
||||
|
||||
**Principle:** Use `TestIcon()` or `TestIconNotStr()` depending on how the icon is integrated in the code.
|
||||
**Principe :** Utilisez `TestIcon("icon_name")` pour tester la présence d'une icône SVG dans le rendu.
|
||||
|
||||
**Difference between the two:**
|
||||
- **`TestIcon("icon_name")`**: Searches for the pattern `<div><NotStr .../></div>` (icon wrapped in a Div)
|
||||
- **`TestIconNotStr("icon_name")`**: Searches only for `<NotStr .../>` (icon alone, without wrapper)
|
||||
|
||||
**How to choose:**
|
||||
1. **Read the source code** to see how the icon is rendered
|
||||
2. If `mk.icon()` or equivalent wraps the icon in a Div → use `TestIcon()`
|
||||
3. If the icon is directly included without wrapper → use `TestIconNotStr()`
|
||||
|
||||
**The `name` parameter:**
|
||||
- **Exact name**: Use the exact import name (e.g., `TestIcon("panel_right_expand20_regular")`) to validate a specific icon
|
||||
- **`name=""`** (empty string): Validates **any icon**
|
||||
|
||||
**Examples:**
|
||||
**Le paramètre `name` :**
|
||||
- **Nom exact** : Utilisez le nom exact de l'import (ex: `TestIcon("panel_right_expand20_regular")`) pour valider une icône spécifique
|
||||
- **`name=""`** (chaîne vide) : Valide **n'importe quelle icône**. Le test sera passant dès que la structure affichant une icône sera trouvée, peu importe laquelle.
|
||||
- **JAMAIS `name="svg"`** : Cela causera des échecs de test
|
||||
|
||||
**Exemples :**
|
||||
```python
|
||||
# Example 1: Wrapped icon (typically with mk.icon())
|
||||
# Source code: mk.icon(panel_right_expand20_regular, size=20)
|
||||
# Rendered: <div><NotStr .../></div>
|
||||
from myfasthtml.icons.fluent import panel_right_expand20_regular
|
||||
|
||||
# ✅ BON - Tester une icône spécifique
|
||||
expected = Header(
|
||||
Div(
|
||||
TestIcon("panel_right_expand20_regular"), # ✅ With wrapper
|
||||
TestIcon("panel_right_expand20_regular"),
|
||||
cls=Contains("flex", "gap-1")
|
||||
)
|
||||
)
|
||||
|
||||
# Example 2: Direct icon (used without helper)
|
||||
# Source code: Span(dismiss_circle16_regular, cls="icon")
|
||||
# Rendered: <span><NotStr .../></span>
|
||||
expected = Span(
|
||||
TestIconNotStr("dismiss_circle16_regular"), # ✅ Without wrapper
|
||||
cls=Contains("icon")
|
||||
)
|
||||
|
||||
# Example 3: Verify any wrapped icon
|
||||
# ✅ BON - Tester la présence de n'importe quelle icône
|
||||
expected = Div(
|
||||
TestIcon(""), # Accepts any wrapped icon
|
||||
TestIcon(""), # Accepte n'importe quelle icône
|
||||
cls=Contains("icon-wrapper")
|
||||
)
|
||||
```
|
||||
|
||||
**Debugging tip:**
|
||||
If your test fails with `TestIcon()`, try `TestIconNotStr()` and vice-versa. The error message will show you the actual structure.
|
||||
# ❌ À ÉVITER - name="svg"
|
||||
expected = Div(TestIcon("svg")) # ERREUR : causera un échec
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.8: Use `TestScript()` to test JavaScript scripts**
|
||||
#### **UTR-11.6 : Utiliser `TestScript()` pour tester les scripts JavaScript**
|
||||
|
||||
**Principle:** Use `TestScript(code_fragment)` to verify JavaScript code presence. Test only the important fragment, not the complete script.
|
||||
**Principe :** Utilisez `TestScript(code_fragment)` pour vérifier la présence de code JavaScript. Testez uniquement le fragment important, pas le script complet.
|
||||
|
||||
**Example:**
|
||||
**Exemple :**
|
||||
```python
|
||||
# ✅ GOOD - TestScript with important fragment
|
||||
# ✅ BON - TestScript avec fragment important
|
||||
script = find_one(layout.render(), Script())
|
||||
expected = TestScript(f"initResizer('{layout._id}');")
|
||||
assert matches(script, expected)
|
||||
|
||||
# ❌ AVOID - testing all script content
|
||||
# ❌ À ÉVITER - tester tout le contenu du script
|
||||
expected = Script("(function() { const id = '...'; initResizer(id); })()")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **HOW TO DOCUMENT TESTS**
|
||||
#### **COMMENT DOCUMENTER LES TESTS**
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.9: Justify the choice of tested elements**
|
||||
#### **UTR-11.7 : Justifier le choix des éléments testés**
|
||||
|
||||
**Principle:** In the test documentation section (after the description docstring), explain **why each tested element or attribute was chosen**. What makes it important for the functionality?
|
||||
**Principe :** Dans la section de documentation du test (après le docstring de description), expliquez **pourquoi chaque élément ou attribut testé a été choisi**. Qu'est-ce qui le rend important pour la fonctionnalité ?
|
||||
|
||||
**What matters:** Not the exact wording ("Why these elements matter" vs "Why this test matters"), but **the explanation of why what is tested is relevant**.
|
||||
**Ce qui compte :** Pas la formulation exacte ("Why these elements matter" vs "Why this test matters"), mais **l'explication de la pertinence de ce qui est testé**.
|
||||
|
||||
**Examples:**
|
||||
**Exemples :**
|
||||
```python
|
||||
def test_empty_layout_is_rendered(self, layout):
|
||||
"""Test that Layout renders with all main structural sections.
|
||||
@@ -511,33 +418,33 @@ def test_left_drawer_is_rendered_when_open(self, layout):
|
||||
assert matches(drawer, expected)
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- Explain why the attribute/element is important (functionality, HTMX, styling, etc.)
|
||||
- No need to follow rigid wording
|
||||
- What matters is the **justification of the choice**, not the format
|
||||
**Points clés :**
|
||||
- Expliquez pourquoi l'attribut/élément est important (fonctionnalité, HTMX, styling, etc.)
|
||||
- Pas besoin de suivre une formulation rigide
|
||||
- L'important est la **justification du choix**, pas le format
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.10: Count tests with explicit messages**
|
||||
#### **UTR-11.8 : Tests de comptage avec messages explicites**
|
||||
|
||||
**Principle:** When you count elements with `assert len()`, ALWAYS add an explicit message explaining why this number is expected.
|
||||
**Principe :** Quand vous comptez des éléments avec `assert len()`, ajoutez TOUJOURS un message explicite qui explique pourquoi ce nombre est attendu.
|
||||
|
||||
**Example:**
|
||||
**Exemple :**
|
||||
```python
|
||||
# ✅ GOOD - explanatory message
|
||||
# ✅ BON - message explicatif
|
||||
resizers = find(drawer, Div(cls=Contains("mf-resizer-left")))
|
||||
assert len(resizers) == 1, "Left drawer should contain exactly one resizer element"
|
||||
|
||||
dividers = find(content, Div(cls="divider"))
|
||||
assert len(dividers) >= 1, "Groups should be separated by dividers"
|
||||
|
||||
# ❌ AVOID - no message
|
||||
# ❌ À ÉVITER - pas de message
|
||||
assert len(resizers) == 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **OTHER IMPORTANT RULES**
|
||||
#### **AUTRES RÈGLES IMPORTANTES**
|
||||
|
||||
---
|
||||
|
||||
@@ -548,7 +455,7 @@ assert len(resizers) == 1
|
||||
2. **Documentation format**: Every render test MUST have a docstring with:
|
||||
- First line: Brief description of what is being tested
|
||||
- Blank line
|
||||
- Justification section explaining why tested elements matter (see UTR-11.9)
|
||||
- Justification section explaining why tested elements matter (see UTR-11.7)
|
||||
- List of important elements/attributes being tested with explanations (in English)
|
||||
|
||||
3. **No inline comments**: Do NOT add comments on each line of the expected structure (except for structural clarification in global layout tests like `# left drawer`)
|
||||
@@ -572,28 +479,23 @@ assert len(resizers) == 1
|
||||
|
||||
---
|
||||
|
||||
#### **Summary: The 11 UTR-11 sub-rules**
|
||||
#### **Résumé : Les 8 règles UTR-11**
|
||||
|
||||
**Prerequisite**
|
||||
- **UTR-11.0**: ⭐⭐⭐ Read `docs/testing_rendered_components.md` (MANDATORY)
|
||||
**Pattern fondamental**
|
||||
- **UTR-11.1** : Pattern en trois étapes (extraire → définir expected → comparer)
|
||||
|
||||
**Test file structure**
|
||||
- **UTR-11.1**: ⭐ Always start with a global structure test (FIRST TEST)
|
||||
- **UTR-11.2**: Break down complex tests into numbered steps
|
||||
- **UTR-11.3**: Three-step pattern for simple tests
|
||||
**Comment chercher**
|
||||
- **UTR-11.2** : Privilégier recherche par ID
|
||||
- **UTR-11.3** : `find_one()` vs `find()` selon contexte
|
||||
|
||||
**How to search**
|
||||
- **UTR-11.4**: Prefer search by ID
|
||||
- **UTR-11.5**: `find_one()` vs `find()` based on context
|
||||
**Comment spécifier**
|
||||
- **UTR-11.4** : Toujours `Contains()` pour `cls` et `style`
|
||||
- **UTR-11.5** : `TestIcon()` pour tester la présence d'icônes
|
||||
- **UTR-11.6** : `TestScript()` pour JavaScript
|
||||
|
||||
**How to specify**
|
||||
- **UTR-11.6**: Always `Contains()` for `cls` and `style`
|
||||
- **UTR-11.7**: `TestIcon()` or `TestIconNotStr()` to test icon presence
|
||||
- **UTR-11.8**: `TestScript()` for JavaScript
|
||||
|
||||
**How to document**
|
||||
- **UTR-11.9**: Justify the choice of tested elements
|
||||
- **UTR-11.10**: Explicit messages for `assert len()`
|
||||
**Comment documenter**
|
||||
- **UTR-11.7** : Justifier le choix des éléments testés
|
||||
- **UTR-11.8** : Messages explicites pour `assert len()`
|
||||
|
||||
---
|
||||
|
||||
@@ -601,89 +503,19 @@ assert len(resizers) == 1
|
||||
- Reference specific patterns from the documentation
|
||||
- Explain why you chose to test certain elements and not others
|
||||
- Justify the use of predicates vs exact values
|
||||
- Always include justification documentation (see UTR-11.9)
|
||||
- Always include justification documentation (see UTR-11.7)
|
||||
|
||||
---
|
||||
|
||||
### UTR-12: Analyze Execution Flow Before Writing Tests
|
||||
|
||||
**Rule:** Before writing a test, trace the complete execution flow to understand side effects.
|
||||
|
||||
**Why:** Prevents writing tests based on incorrect assumptions about behavior.
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Test: "content_is_cached_after_first_retrieval"
|
||||
Flow: create_tab() → _add_or_update_tab() → state.ns_tabs_content[tab_id] = component
|
||||
Conclusion: Cache is already filled after create_tab, test would be redundant
|
||||
```
|
||||
|
||||
**Process:**
|
||||
1. Identify the method being tested
|
||||
2. Trace all method calls it makes
|
||||
3. Identify state changes at each step
|
||||
4. Verify your assumptions about what the test should validate
|
||||
5. Only then write the test
|
||||
|
||||
---
|
||||
|
||||
### UTR-13: Prefer matches() for Content Verification
|
||||
|
||||
**Rule:** Even in behavior tests, use `matches()` to verify HTML content rather than `assert "text" in str(element)`.
|
||||
|
||||
**Why:** More robust, clearer error messages, consistent with render test patterns.
|
||||
|
||||
**Examples:**
|
||||
```python
|
||||
# ❌ FRAGILE - string matching
|
||||
result = component._dynamic_get_content("nonexistent_id")
|
||||
assert "Tab not found" in str(result)
|
||||
|
||||
# ✅ ROBUST - structural matching
|
||||
result = component._dynamic_get_content("nonexistent_id")
|
||||
assert matches(result, Div('Tab not found.'))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### UTR-14: Know FastHTML Attribute Names
|
||||
|
||||
**Rule:** FastHTML elements use HTML attribute names, not Python parameter names.
|
||||
|
||||
**Key differences:**
|
||||
- Use `attrs.get('class')` not `attrs.get('cls')`
|
||||
- Use `attrs.get('id')` for the ID
|
||||
- Prefer `matches()` with predicates to avoid direct attribute access
|
||||
|
||||
**Examples:**
|
||||
```python
|
||||
# ❌ WRONG - Python parameter name
|
||||
classes = element.attrs.get('cls', '') # Returns None or ''
|
||||
|
||||
# ✅ CORRECT - HTML attribute name
|
||||
classes = element.attrs.get('class', '') # Returns actual classes
|
||||
|
||||
# ✅ BETTER - Use predicates with matches()
|
||||
expected = Div(cls=Contains("active"))
|
||||
assert matches(element, expected)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### UTR-15: Test Workflow
|
||||
### UTR-12: Test Workflow
|
||||
|
||||
1. **Receive code to test** - User provides file path or code section
|
||||
2. **Check existing tests** - Look for corresponding test file and read it if it exists
|
||||
3. **Analyze code** - Read and understand implementation
|
||||
4. **Trace execution flow** - Apply UTR-12 to understand side effects
|
||||
5. **Gap analysis** - If tests exist, identify what's missing; otherwise identify all scenarios
|
||||
6. **Propose test plan** - List new/missing tests with brief explanations
|
||||
7. **Wait for approval** - User validates the test plan
|
||||
8. **Implement tests** - Write all approved tests
|
||||
9. **Verify** - Ensure tests follow naming conventions and structure
|
||||
10. **Ask before running** - Do NOT automatically run tests with pytest. Ask user first if they want to run the tests.
|
||||
|
||||
---
|
||||
4. **Gap analysis** - If tests exist, identify what's missing; otherwise identify all scenarios
|
||||
5. **Propose test plan** - List new/missing tests with brief explanations
|
||||
6. **Wait for approval** - User validates the test plan
|
||||
7. **Implement tests** - Write all approved tests
|
||||
8. **Verify** - Ensure tests follow naming conventions and structure
|
||||
9. **Ask before running** - Do NOT automatically run tests with pytest. Ask user first if they want to run the tests.
|
||||
|
||||
## Managing Rules
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -173,7 +173,7 @@ pip install -e .
|
||||
Commands abstract HTMX interactions by encapsulating server-side actions. Located in `src/myfasthtml/core/commands.py`.
|
||||
|
||||
**Key classes:**
|
||||
- `Command`: Base class for all commands with HTMX integration
|
||||
- `BaseCommand`: Base class for all commands with HTMX integration
|
||||
- `Command`: Standard command that executes a Python callable
|
||||
- `LambdaCommand`: Inline command for simple operations
|
||||
- `CommandsManager`: Global registry for command execution
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DataGrid Performance Profiling Script
|
||||
|
||||
Generates a 1000-row DataFrame and profiles the DataGrid.render() method
|
||||
to identify performance bottlenecks.
|
||||
|
||||
Usage:
|
||||
python benchmarks/profile_datagrid.py
|
||||
"""
|
||||
|
||||
import cProfile
|
||||
import pstats
|
||||
from io import StringIO
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from myfasthtml.controls.DataGrid import DataGrid
|
||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||
|
||||
|
||||
def generate_test_dataframe(rows=1000, cols=10):
|
||||
"""Generate a test DataFrame with mixed column types."""
|
||||
np.random.seed(42)
|
||||
|
||||
data = {
|
||||
'ID': range(rows),
|
||||
'Name': [f'Person_{i}' for i in range(rows)],
|
||||
'Email': [f'user{i}@example.com' for i in range(rows)],
|
||||
'Age': np.random.randint(18, 80, rows),
|
||||
'Salary': np.random.uniform(30000, 150000, rows),
|
||||
'Active': np.random.choice([True, False], rows),
|
||||
'Score': np.random.uniform(0, 100, rows),
|
||||
'Department': np.random.choice(['Sales', 'Engineering', 'Marketing', 'HR'], rows),
|
||||
'Country': np.random.choice(['France', 'USA', 'Germany', 'UK', 'Spain'], rows),
|
||||
'Rating': np.random.uniform(1.0, 5.0, rows),
|
||||
}
|
||||
|
||||
# Add extra columns if needed
|
||||
for i in range(cols - len(data)):
|
||||
data[f'Extra_Col_{i}'] = np.random.random(rows)
|
||||
|
||||
return pd.DataFrame(data)
|
||||
|
||||
|
||||
def profile_datagrid_render(df):
|
||||
"""Profile the DataGrid render method."""
|
||||
|
||||
# Clear instances to start fresh
|
||||
InstancesManager.instances.clear()
|
||||
|
||||
# Create a minimal session
|
||||
session = {
|
||||
"user_info": {
|
||||
"id": "test_tenant_id",
|
||||
"email": "test@email.com",
|
||||
"username": "test user",
|
||||
"role": [],
|
||||
}
|
||||
}
|
||||
|
||||
# Create root instance as parent
|
||||
root = SingleInstance(parent=None, session=session, _id="profile-root")
|
||||
|
||||
# Create DataGrid (parent, settings, save_state, _id)
|
||||
datagrid = DataGrid(root)
|
||||
datagrid.init_from_dataframe(df)
|
||||
|
||||
# Profile the render call
|
||||
profiler = cProfile.Profile()
|
||||
profiler.enable()
|
||||
|
||||
# Execute render
|
||||
html_output = datagrid.render()
|
||||
|
||||
profiler.disable()
|
||||
|
||||
return profiler, html_output
|
||||
|
||||
|
||||
def print_profile_stats(profiler, top_n=30):
|
||||
"""Print formatted profiling statistics."""
|
||||
s = StringIO()
|
||||
stats = pstats.Stats(profiler, stream=s)
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("PROFILING RESULTS - Top {} functions by cumulative time".format(top_n))
|
||||
print("=" * 80 + "\n")
|
||||
|
||||
stats.sort_stats('cumulative')
|
||||
stats.print_stats(top_n)
|
||||
|
||||
output = s.getvalue()
|
||||
print(output)
|
||||
|
||||
# Extract total time
|
||||
for line in output.split('\n'):
|
||||
if 'function calls' in line:
|
||||
print("\n" + "=" * 80)
|
||||
print("SUMMARY")
|
||||
print("=" * 80)
|
||||
print(line)
|
||||
break
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("Top 10 by total time spent (time * ncalls)")
|
||||
print("=" * 80 + "\n")
|
||||
|
||||
s = StringIO()
|
||||
stats = pstats.Stats(profiler, stream=s)
|
||||
stats.sort_stats('tottime')
|
||||
stats.print_stats(10)
|
||||
print(s.getvalue())
|
||||
|
||||
|
||||
def main():
|
||||
print("Generating test DataFrame (1000 rows × 10 columns)...")
|
||||
df = generate_test_dataframe(rows=1000, cols=10)
|
||||
print(f"DataFrame shape: {df.shape}")
|
||||
print(f"Memory usage: {df.memory_usage(deep=True).sum() / 1024:.2f} KB\n")
|
||||
|
||||
print("Profiling DataGrid.render()...")
|
||||
profiler, html_output = profile_datagrid_render(df)
|
||||
|
||||
print(f"\nHTML output length: {len(str(html_output))} characters")
|
||||
|
||||
print_profile_stats(profiler, top_n=30)
|
||||
|
||||
# Clean up instances
|
||||
InstancesManager.reset()
|
||||
|
||||
print("\n✅ Profiling complete!")
|
||||
print("\nNext steps:")
|
||||
print("1. Identify the slowest functions in the 'cumulative time' section")
|
||||
print("2. Look for functions called many times (high ncalls)")
|
||||
print("3. Focus optimization on high cumtime + high ncalls functions")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
832
docs/Panel.md
832
docs/Panel.md
@@ -1,832 +0,0 @@
|
||||
# Panel Component
|
||||
|
||||
## Introduction
|
||||
|
||||
The Panel component provides a flexible three-zone layout with optional collapsible side panels. It's designed to
|
||||
organize content into left panel, main area, and right panel sections, with smooth toggle animations and resizable
|
||||
panels.
|
||||
|
||||
**Key features:**
|
||||
|
||||
- Three customizable zones (left panel, main content, right panel)
|
||||
- Toggle visibility with hide/show icons
|
||||
- Resizable panels with drag handles
|
||||
- Smooth CSS animations for show/hide transitions
|
||||
- Automatic state persistence per session
|
||||
- Configurable panel presence (enable/disable left or right)
|
||||
- Session-based width and visibility state
|
||||
|
||||
**Common use cases:**
|
||||
|
||||
- Code editor with file explorer and properties panel
|
||||
- Data visualization with filters sidebar and details panel
|
||||
- Admin interface with navigation menu and tools panel
|
||||
- Documentation viewer with table of contents and metadata
|
||||
- Dashboard with configuration panel and information sidebar
|
||||
|
||||
## Quick Start
|
||||
|
||||
Here's a minimal example showing a three-panel layout for a code editor:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
|
||||
# Create the panel instance
|
||||
panel = Panel(parent=root_instance)
|
||||
|
||||
# Set content for each zone
|
||||
panel.set_left(
|
||||
Div(
|
||||
H3("Files"),
|
||||
Ul(
|
||||
Li("app.py"),
|
||||
Li("config.py"),
|
||||
Li("utils.py")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
panel.set_main(
|
||||
Div(
|
||||
H2("Editor"),
|
||||
Textarea("# Write your code here", rows=20, cls="w-full font-mono")
|
||||
)
|
||||
)
|
||||
|
||||
panel.set_right(
|
||||
Div(
|
||||
H3("Properties"),
|
||||
Div("Language: Python"),
|
||||
Div("Lines: 120"),
|
||||
Div("Size: 3.2 KB")
|
||||
)
|
||||
)
|
||||
|
||||
# Render the panel
|
||||
return panel
|
||||
```
|
||||
|
||||
This creates a complete panel layout with:
|
||||
|
||||
- A left panel displaying a file list with a hide icon (−) at the top right
|
||||
- A main content area with a code editor
|
||||
- A right panel showing file properties with a hide icon (−) at the top right
|
||||
- Show icons (⋯) that appear in the main area when panels are hidden
|
||||
- Drag handles between panels for manual resizing
|
||||
- Automatic state persistence (visibility and width)
|
||||
|
||||
**Note:** Users can hide panels by clicking the hide icon (−) inside each panel. When hidden, a show icon (⋯) appears in
|
||||
the main area (left side for left panel, right side for right panel). Panels can be resized by dragging the handles, and
|
||||
all state is automatically saved in the session.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Visual Structure
|
||||
|
||||
The Panel component consists of three zones with optional side panels:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌──────────┐ │ ┌──────────────────────┐ │ ┌──────────┐ │
|
||||
│ │ │ │ │ │ │ │ │ │
|
||||
│ │ Left │ ║ │ │ ║ │ Right │ │
|
||||
│ │ Panel │ │ │ Main Content │ │ │ Panel │ │
|
||||
│ │ │ │ │ │ │ │ │ │
|
||||
│ │ [−] │ │ │ [⋯] [⋯] │ │ │ [−] │ │
|
||||
│ └──────────┘ │ └──────────────────────┘ │ └──────────┘ │
|
||||
│ ║ ║ │
|
||||
│ Resizer Resizer │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Component details:**
|
||||
|
||||
| Element | Description |
|
||||
|---------------|-----------------------------------------------|
|
||||
| Left panel | Optional collapsible panel (default: visible) |
|
||||
| Main content | Always-visible central content area |
|
||||
| Right panel | Optional collapsible panel (default: visible) |
|
||||
| Hide icon (−) | Inside each panel, top right corner |
|
||||
| Show icon (⋯) | In main area when panel is hidden |
|
||||
| Resizer (║) | Drag handle to resize panels manually |
|
||||
|
||||
### Creating a Panel
|
||||
|
||||
The Panel is a `MultipleInstance`, meaning you can create multiple independent panels in your application. Create it by
|
||||
providing a parent instance:
|
||||
|
||||
```python
|
||||
panel = Panel(parent=root_instance)
|
||||
|
||||
# Or with a custom ID
|
||||
panel = Panel(parent=root_instance, _id="my-panel")
|
||||
|
||||
# Or with custom configuration
|
||||
from myfasthtml.controls.Panel import PanelConf
|
||||
|
||||
conf = PanelConf(left=True, right=False) # Only left panel enabled
|
||||
panel = Panel(parent=root_instance, conf=conf)
|
||||
```
|
||||
|
||||
### Content Zones
|
||||
|
||||
The Panel provides three content zones:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Left Panel │ Main Content │ Right Panel │
|
||||
│ (optional) │ (required) │ (optional) │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Zone details:**
|
||||
|
||||
| Zone | Typical Use | Required |
|
||||
|---------|-------------------------------------------------------|----------|
|
||||
| `left` | Navigation, file explorer, filters, table of contents | No |
|
||||
| `main` | Primary content, editor, visualization, results | Yes |
|
||||
| `right` | Properties, tools, metadata, debug info, settings | No |
|
||||
|
||||
### Setting Content
|
||||
|
||||
Use the `set_*()` methods to add content to each zone:
|
||||
|
||||
```python
|
||||
# Main content (always visible)
|
||||
panel.set_main(
|
||||
Div(
|
||||
H1("Dashboard"),
|
||||
P("This is the main content area")
|
||||
)
|
||||
)
|
||||
|
||||
# Left panel (optional)
|
||||
panel.set_left(
|
||||
Div(
|
||||
H3("Navigation"),
|
||||
Ul(
|
||||
Li("Home"),
|
||||
Li("Settings"),
|
||||
Li("About")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Right panel (optional)
|
||||
panel.set_right(
|
||||
Div(
|
||||
H3("Tools"),
|
||||
Button("Export"),
|
||||
Button("Refresh")
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
**Method chaining:**
|
||||
|
||||
The `set_main()` method returns `self`, enabling method chaining:
|
||||
|
||||
```python
|
||||
panel = Panel(parent=root_instance)
|
||||
.set_main(Div("Main content"))
|
||||
.set_left(Div("Left content"))
|
||||
```
|
||||
|
||||
### Panel Configuration
|
||||
|
||||
By default, both left and right panels are enabled. You can customize this with `PanelConf`:
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.Panel import PanelConf
|
||||
|
||||
# Only left panel enabled
|
||||
conf = PanelConf(left=True, right=False)
|
||||
panel = Panel(parent=root_instance, conf=conf)
|
||||
|
||||
# Only right panel enabled
|
||||
conf = PanelConf(left=False, right=True)
|
||||
panel = Panel(parent=root_instance, conf=conf)
|
||||
|
||||
# Both panels enabled (default)
|
||||
conf = PanelConf(left=True, right=True)
|
||||
panel = Panel(parent=root_instance, conf=conf)
|
||||
|
||||
# No side panels (main content only)
|
||||
conf = PanelConf(left=False, right=False)
|
||||
panel = Panel(parent=root_instance, conf=conf)
|
||||
```
|
||||
|
||||
**Note:** When a panel is disabled in configuration, it won't render at all. When a panel is hidden (via toggle), it
|
||||
renders but with zero width and overflow hidden.
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Toggling Panel Visibility
|
||||
|
||||
Each visible panel includes a hide icon (−) in its top-right corner. When hidden, a show icon (⋯) appears in the main
|
||||
area:
|
||||
|
||||
**User interaction:**
|
||||
|
||||
- **Hide panel**: Click the − icon inside the panel
|
||||
- **Show panel**: Click the ⋯ icon in the main area
|
||||
|
||||
**Icon positions:**
|
||||
|
||||
- Hide icons (−): Always at top-right of each panel
|
||||
- Show icon for left panel (⋯): Top-left of main area
|
||||
- Show icon for right panel (⋯): Top-right of main area
|
||||
|
||||
**Visual states:**
|
||||
|
||||
```
|
||||
Panel Visible:
|
||||
┌──────────┐
|
||||
│ Content │
|
||||
│ [−] │ ← Hide icon visible
|
||||
└──────────┘
|
||||
|
||||
Panel Hidden:
|
||||
┌──────────────────┐
|
||||
│ [⋯] Main │ ← Show icon visible in main
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
**Animation:**
|
||||
|
||||
When toggling visibility:
|
||||
|
||||
- **Hiding**: Panel width animates to 0px over 0.3s
|
||||
- **Showing**: Panel width animates to its saved width over 0.3s
|
||||
- Content remains in DOM (state preserved)
|
||||
- Smooth CSS transition with ease timing
|
||||
|
||||
**Note:** The animation only works when showing (panel appearing). When hiding, the transition currently doesn't apply
|
||||
due to HTMX swap timing. This is a known limitation.
|
||||
|
||||
### Resizable Panels
|
||||
|
||||
Both left and right panels can be resized by users via drag handles:
|
||||
|
||||
- **Drag handle location**:
|
||||
- Left panel: Right edge (vertical bar)
|
||||
- Right panel: Left edge (vertical bar)
|
||||
- **Width constraints**: 150px (minimum) to 500px (maximum)
|
||||
- **Persistence**: Resized width is automatically saved in session state
|
||||
- **No transition during resize**: CSS transitions are disabled during manual dragging for smooth performance
|
||||
|
||||
**How to resize:**
|
||||
|
||||
1. Hover over the panel edge (cursor changes to resize cursor)
|
||||
2. Click and drag left/right
|
||||
3. Release to set the new width
|
||||
4. Width is saved automatically and persists in the session
|
||||
|
||||
**Initial widths:**
|
||||
|
||||
- Left panel: 250px
|
||||
- Right panel: 250px
|
||||
|
||||
These defaults can be customized via state after creation if needed.
|
||||
|
||||
### State Persistence
|
||||
|
||||
The Panel automatically persists its state within the user's session:
|
||||
|
||||
| State Property | Description | Default |
|
||||
|-----------------|--------------------------------|---------|
|
||||
| `left_visible` | Whether left panel is visible | `True` |
|
||||
| `right_visible` | Whether right panel is visible | `True` |
|
||||
| `left_width` | Left panel width in pixels | `250` |
|
||||
| `right_width` | Right panel width in pixels | `250` |
|
||||
|
||||
State changes (toggle visibility, resize width) are automatically saved and restored within the session.
|
||||
|
||||
**Accessing state:**
|
||||
|
||||
```python
|
||||
# Check current state
|
||||
is_left_visible = panel._state.left_visible
|
||||
left_panel_width = panel._state.left_width
|
||||
|
||||
# Programmatically update state (not recommended - use commands instead)
|
||||
panel._state.left_visible = False # Better to use toggle_side command
|
||||
```
|
||||
|
||||
### Programmatic Control
|
||||
|
||||
You can control panels programmatically using commands:
|
||||
|
||||
```python
|
||||
# Toggle panel visibility
|
||||
toggle_left = panel.commands.toggle_side("left", visible=False) # Hide left
|
||||
toggle_right = panel.commands.toggle_side("right", visible=True) # Show right
|
||||
|
||||
# Update panel width
|
||||
update_left_width = panel.commands.update_side_width("left")
|
||||
update_right_width = panel.commands.update_side_width("right")
|
||||
```
|
||||
|
||||
These commands are typically used with buttons or other interactive elements:
|
||||
|
||||
```python
|
||||
from myfasthtml.controls.helpers import mk
|
||||
|
||||
# Add buttons to toggle panels
|
||||
hide_left_btn = mk.button("Hide Left", command=panel.commands.toggle_side("left", False))
|
||||
show_left_btn = mk.button("Show Left", command=panel.commands.toggle_side("left", True))
|
||||
|
||||
# Add to your layout
|
||||
panel.set_main(
|
||||
Div(
|
||||
hide_left_btn,
|
||||
show_left_btn,
|
||||
H1("Main Content")
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
**Command details:**
|
||||
|
||||
- `toggle_side(side, visible)`: Sets panel visibility explicitly
|
||||
- `side`: `"left"` or `"right"`
|
||||
- `visible`: `True` (show) or `False` (hide)
|
||||
- Returns: tuple of (panel_element, show_icon_element) for HTMX swap
|
||||
|
||||
- `update_side_width(side)`: Updates panel width from HTMX request
|
||||
- `side`: `"left"` or `"right"`
|
||||
- Width value comes from JavaScript resize handler
|
||||
- Returns: updated panel element for HTMX swap
|
||||
|
||||
### CSS Customization
|
||||
|
||||
The Panel uses CSS classes that you can customize:
|
||||
|
||||
| Class | Element |
|
||||
|----------------------------|------------------------------------------|
|
||||
| `mf-panel` | Root panel container |
|
||||
| `mf-panel-left` | Left panel container |
|
||||
| `mf-panel-right` | Right panel container |
|
||||
| `mf-panel-main` | Main content area |
|
||||
| `mf-panel-hide-icon` | Hide icon (−) inside panels |
|
||||
| `mf-panel-show-icon` | Show icon (⋯) in main area |
|
||||
| `mf-panel-show-icon-left` | Show icon for left panel |
|
||||
| `mf-panel-show-icon-right` | Show icon for right panel |
|
||||
| `mf-resizer` | Resize handle base class |
|
||||
| `mf-resizer-left` | Left panel resize handle |
|
||||
| `mf-resizer-right` | Right panel resize handle |
|
||||
| `mf-hidden` | Applied to hidden panels |
|
||||
| `no-transition` | Disables transition during manual resize |
|
||||
|
||||
**Example customization:**
|
||||
|
||||
```css
|
||||
/* Change panel background color */
|
||||
.mf-panel-left,
|
||||
.mf-panel-right {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
/* Customize hide icon appearance */
|
||||
.mf-panel-hide-icon:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Change transition timing */
|
||||
.mf-panel-left,
|
||||
.mf-panel-right {
|
||||
transition: width 0.5s ease-in-out; /* Slower animation */
|
||||
}
|
||||
|
||||
/* Style resizer handles */
|
||||
.mf-resizer {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.mf-resizer:hover {
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Code Editor Layout
|
||||
|
||||
A typical code editor with file explorer, editor, and properties panel:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
|
||||
# Create panel
|
||||
panel = Panel(parent=root_instance)
|
||||
|
||||
# Left panel: File Explorer
|
||||
panel.set_left(
|
||||
Div(
|
||||
H3("Explorer", cls="font-bold mb-2"),
|
||||
Div(
|
||||
Div("📁 src", cls="font-mono cursor-pointer"),
|
||||
Div(" 📄 app.py", cls="font-mono ml-4 cursor-pointer"),
|
||||
Div(" 📄 config.py", cls="font-mono ml-4 cursor-pointer"),
|
||||
Div("📁 tests", cls="font-mono cursor-pointer"),
|
||||
Div(" 📄 test_app.py", cls="font-mono ml-4 cursor-pointer"),
|
||||
cls="space-y-1"
|
||||
),
|
||||
cls="p-4"
|
||||
)
|
||||
)
|
||||
|
||||
# Main: Code Editor
|
||||
panel.set_main(
|
||||
Div(
|
||||
Div(
|
||||
Span("app.py", cls="font-bold"),
|
||||
Span("Python", cls="text-sm opacity-60 ml-2"),
|
||||
cls="border-b pb-2 mb-2"
|
||||
),
|
||||
Textarea(
|
||||
"""def main():
|
||||
print("Hello, World!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()""",
|
||||
rows=20,
|
||||
cls="w-full font-mono text-sm p-2 border rounded"
|
||||
),
|
||||
cls="p-4"
|
||||
)
|
||||
)
|
||||
|
||||
# Right panel: Properties and Tools
|
||||
panel.set_right(
|
||||
Div(
|
||||
H3("Properties", cls="font-bold mb-2"),
|
||||
Div("Language: Python", cls="text-sm mb-1"),
|
||||
Div("Lines: 5", cls="text-sm mb-1"),
|
||||
Div("Size: 87 bytes", cls="text-sm mb-4"),
|
||||
|
||||
H3("Tools", cls="font-bold mb-2 mt-4"),
|
||||
Button("Run", cls="btn btn-sm btn-primary w-full mb-2"),
|
||||
Button("Debug", cls="btn btn-sm w-full mb-2"),
|
||||
Button("Format", cls="btn btn-sm w-full"),
|
||||
cls="p-4"
|
||||
)
|
||||
)
|
||||
|
||||
return panel
|
||||
```
|
||||
|
||||
### Example 2: Dashboard with Filters
|
||||
|
||||
A data dashboard with filters sidebar and details panel:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
|
||||
# Create panel
|
||||
panel = Panel(parent=root_instance)
|
||||
|
||||
# Left panel: Filters
|
||||
panel.set_left(
|
||||
Div(
|
||||
H3("Filters", cls="font-bold mb-3"),
|
||||
|
||||
Div(
|
||||
Label("Date Range", cls="label"),
|
||||
Select(
|
||||
Option("Last 7 days"),
|
||||
Option("Last 30 days"),
|
||||
Option("Last 90 days"),
|
||||
cls="select select-bordered w-full"
|
||||
),
|
||||
cls="mb-3"
|
||||
),
|
||||
|
||||
Div(
|
||||
Label("Category", cls="label"),
|
||||
Div(
|
||||
Label(Input(type="checkbox", cls="checkbox"), " Sales", cls="label cursor-pointer"),
|
||||
Label(Input(type="checkbox", cls="checkbox"), " Marketing", cls="label cursor-pointer"),
|
||||
Label(Input(type="checkbox", cls="checkbox"), " Support", cls="label cursor-pointer"),
|
||||
cls="space-y-2"
|
||||
),
|
||||
cls="mb-3"
|
||||
),
|
||||
|
||||
Button("Apply Filters", cls="btn btn-primary w-full"),
|
||||
cls="p-4"
|
||||
)
|
||||
)
|
||||
|
||||
# Main: Dashboard Charts
|
||||
panel.set_main(
|
||||
Div(
|
||||
H1("Analytics Dashboard", cls="text-2xl font-bold mb-4"),
|
||||
|
||||
Div(
|
||||
Div(
|
||||
Div("Total Revenue", cls="stat-title"),
|
||||
Div("$45,231", cls="stat-value"),
|
||||
Div("+12% from last month", cls="stat-desc"),
|
||||
cls="stat"
|
||||
),
|
||||
Div(
|
||||
Div("Active Users", cls="stat-title"),
|
||||
Div("2,345", cls="stat-value"),
|
||||
Div("+8% from last month", cls="stat-desc"),
|
||||
cls="stat"
|
||||
),
|
||||
cls="stats shadow mb-4"
|
||||
),
|
||||
|
||||
Div("[Chart placeholder - Revenue over time]", cls="border rounded p-8 text-center"),
|
||||
cls="p-4"
|
||||
)
|
||||
)
|
||||
|
||||
# Right panel: Details and Insights
|
||||
panel.set_right(
|
||||
Div(
|
||||
H3("Key Insights", cls="font-bold mb-3"),
|
||||
|
||||
Div(
|
||||
Div("🎯 Top Performing", cls="font-bold mb-1"),
|
||||
Div("Product A: $12,450", cls="text-sm"),
|
||||
Div("Product B: $8,920", cls="text-sm mb-3")
|
||||
),
|
||||
|
||||
Div(
|
||||
Div("📊 Trending Up", cls="font-bold mb-1"),
|
||||
Div("Category: Electronics", cls="text-sm"),
|
||||
Div("+23% this week", cls="text-sm mb-3")
|
||||
),
|
||||
|
||||
Div(
|
||||
Div("⚠️ Needs Attention", cls="font-bold mb-1"),
|
||||
Div("Low stock: Item X", cls="text-sm"),
|
||||
Div("Response time: +15%", cls="text-sm")
|
||||
),
|
||||
cls="p-4"
|
||||
)
|
||||
)
|
||||
|
||||
return panel
|
||||
```
|
||||
|
||||
### Example 3: Simple Layout (Main Content Only)
|
||||
|
||||
A minimal panel with no side panels, focusing only on main content:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Panel import Panel, PanelConf
|
||||
|
||||
# Create panel with both side panels disabled
|
||||
conf = PanelConf(left=False, right=False)
|
||||
panel = Panel(parent=root_instance, conf=conf)
|
||||
|
||||
# Only main content
|
||||
panel.set_main(
|
||||
Article(
|
||||
H1("Welcome to My Blog", cls="text-3xl font-bold mb-4"),
|
||||
P("This is a simple layout focusing entirely on the main content."),
|
||||
P("No side panels distract from the reading experience."),
|
||||
P("The content takes up the full width of the container."),
|
||||
cls="prose max-w-none p-8"
|
||||
)
|
||||
)
|
||||
|
||||
return panel
|
||||
```
|
||||
|
||||
### Example 4: Dynamic Panel Updates
|
||||
|
||||
Controlling panels programmatically based on user interaction:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
|
||||
# Create panel
|
||||
panel = Panel(parent=root_instance)
|
||||
|
||||
# Set up content
|
||||
panel.set_left(
|
||||
Div(
|
||||
H3("Navigation"),
|
||||
Ul(
|
||||
Li("Dashboard"),
|
||||
Li("Reports"),
|
||||
Li("Settings")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
panel.set_right(
|
||||
Div(
|
||||
H3("Debug Info"),
|
||||
Div("Session ID: abc123"),
|
||||
Div("User: Admin"),
|
||||
Div("Timestamp: 2024-01-15")
|
||||
)
|
||||
)
|
||||
|
||||
# Create control buttons
|
||||
toggle_left_btn = mk.button(
|
||||
"Toggle Left Panel",
|
||||
command=panel.commands.toggle_side("left", False),
|
||||
cls="btn btn-sm"
|
||||
)
|
||||
|
||||
toggle_right_btn = mk.button(
|
||||
"Toggle Right Panel",
|
||||
command=panel.commands.toggle_side("right", False),
|
||||
cls="btn btn-sm"
|
||||
)
|
||||
|
||||
show_all_btn = mk.button(
|
||||
"Show All Panels",
|
||||
command=Command(
|
||||
"show_all",
|
||||
"Show all panels",
|
||||
lambda: (
|
||||
panel.toggle_side("left", True),
|
||||
panel.toggle_side("right", True)
|
||||
)
|
||||
),
|
||||
cls="btn btn-sm btn-primary"
|
||||
)
|
||||
|
||||
# Main content with controls
|
||||
panel.set_main(
|
||||
Div(
|
||||
H1("Panel Controls Demo", cls="text-2xl font-bold mb-4"),
|
||||
|
||||
Div(
|
||||
toggle_left_btn,
|
||||
toggle_right_btn,
|
||||
show_all_btn,
|
||||
cls="space-x-2 mb-4"
|
||||
),
|
||||
|
||||
P("Use the buttons above to toggle panels programmatically."),
|
||||
P("You can also use the hide (−) and show (⋯) icons."),
|
||||
|
||||
cls="p-4"
|
||||
)
|
||||
)
|
||||
|
||||
return panel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Developer Reference
|
||||
|
||||
This section contains technical details for developers working on the Panel component itself.
|
||||
|
||||
### Configuration
|
||||
|
||||
The Panel component uses `PanelConf` dataclass for configuration:
|
||||
|
||||
| Property | Type | Description | Default |
|
||||
|----------|---------|----------------------------|---------|
|
||||
| `left` | boolean | Enable/disable left panel | `True` |
|
||||
| `right` | boolean | Enable/disable right panel | `True` |
|
||||
|
||||
### State
|
||||
|
||||
The Panel component maintains the following state properties via `PanelState`:
|
||||
|
||||
| Name | Type | Description | Default |
|
||||
|-----------------|---------|------------------------------------|---------|
|
||||
| `left_visible` | boolean | True if the left panel is visible | `True` |
|
||||
| `right_visible` | boolean | True if the right panel is visible | `True` |
|
||||
| `left_width` | integer | Width of the left panel in pixels | `250` |
|
||||
| `right_width` | integer | Width of the right panel in pixels | `250` |
|
||||
|
||||
### Commands
|
||||
|
||||
Available commands for programmatic control:
|
||||
|
||||
| Name | Description |
|
||||
|------------------------------|-------------------------------------------------------------------|
|
||||
| `toggle_side(side, visible)` | Sets panel visibility (side: "left"/"right", visible: True/False) |
|
||||
| `update_side_width(side)` | Updates panel width from HTMX request (side: "left"/"right") |
|
||||
|
||||
**Note:** The old `toggle_side(side)` command without the `visible` parameter is deprecated but still available in the
|
||||
codebase.
|
||||
|
||||
### Public Methods
|
||||
|
||||
| Method | Description | Returns |
|
||||
|----------------------|------------------------------|---------|
|
||||
| `set_main(content)` | Sets the main content area | `self` |
|
||||
| `set_left(content)` | Sets the left panel content | `Div` |
|
||||
| `set_right(content)` | Sets the right panel content | `Div` |
|
||||
| `render()` | Renders the complete panel | `Div` |
|
||||
|
||||
### High Level Hierarchical Structure
|
||||
|
||||
```
|
||||
Div(id="{id}", cls="mf-panel")
|
||||
├── Div(id="{id}_pl", cls="mf-panel-left [mf-hidden]")
|
||||
│ ├── Div (hide icon)
|
||||
│ ├── Div(id="{id}_cl")
|
||||
│ │ └── [Left content]
|
||||
│ └── Div (resizer-left)
|
||||
├── Div(cls="mf-panel-main")
|
||||
│ ├── Div(id="{id}_show_left", cls="hidden|mf-panel-show-icon-left")
|
||||
│ ├── Div(id="{id}_m", cls="mf-panel-main")
|
||||
│ │ └── [Main content]
|
||||
│ └── Div(id="{id}_show_right", cls="hidden|mf-panel-show-icon-right")
|
||||
├── Div(id="{id}_pr", cls="mf-panel-right [mf-hidden]")
|
||||
│ ├── Div (resizer-right)
|
||||
│ ├── Div (hide icon)
|
||||
│ └── Div(id="{id}_cr")
|
||||
│ └── [Right content]
|
||||
└── Script # initResizer('{id}')
|
||||
```
|
||||
|
||||
**Note:**
|
||||
|
||||
- Left panel: hide icon, then content, then resizer (resizer on right edge)
|
||||
- Right panel: resizer, then hide icon, then content (resizer on left edge)
|
||||
- Hide icons are positioned at panel root level (not inside content div)
|
||||
- Main content has an outer wrapper and inner content div with ID
|
||||
- `[mf-hidden]` class is conditionally applied when panel is hidden
|
||||
|
||||
### Element IDs
|
||||
|
||||
| Name | Description |
|
||||
|------------------|-------------------------------------|
|
||||
| `{id}` | Root panel container |
|
||||
| `{id}_pl` | Left panel container |
|
||||
| `{id}_pr` | Right panel container |
|
||||
| `{id}_cl` | Left panel content wrapper |
|
||||
| `{id}_cr` | Right panel content wrapper |
|
||||
| `{id}_m` | Main content wrapper |
|
||||
| `{id}_show_left` | Show icon for left panel (in main) |
|
||||
| `{id}_show_right`| Show icon for right panel (in main) |
|
||||
|
||||
**Note:** `{id}` is the Panel instance ID (auto-generated UUID or custom `_id`).
|
||||
|
||||
**ID Management:**
|
||||
|
||||
The Panel component uses the `PanelIds` helper class to manage element IDs consistently. Access IDs programmatically:
|
||||
|
||||
```python
|
||||
panel = Panel(parent=root_instance)
|
||||
|
||||
# Access IDs via get_ids()
|
||||
panel.get_ids().panel("left") # Returns "{id}_pl"
|
||||
panel.get_ids().panel("right") # Returns "{id}_pr"
|
||||
panel.get_ids().left # Returns "{id}_cl"
|
||||
panel.get_ids().right # Returns "{id}_cr"
|
||||
panel.get_ids().main # Returns "{id}_m"
|
||||
panel.get_ids().content("left") # Returns "{id}_cl"
|
||||
```
|
||||
|
||||
### Internal Methods
|
||||
|
||||
These methods are used internally for rendering:
|
||||
|
||||
| Method | Description |
|
||||
|-----------------------|---------------------------------------------------|
|
||||
| `_mk_panel(side)` | Renders a panel (left or right) with all elements |
|
||||
| `_mk_show_icon(side)` | Renders the show icon for a panel |
|
||||
|
||||
**Method details:**
|
||||
|
||||
- `_mk_panel(side)`:
|
||||
- Checks if panel is enabled in config
|
||||
- Creates resizer with command and data attributes
|
||||
- Creates hide icon with toggle command
|
||||
- Applies `mf-hidden` class if panel is not visible
|
||||
- Returns None if panel is disabled
|
||||
|
||||
- `_mk_show_icon(side)`:
|
||||
- Checks if panel is enabled in config
|
||||
- Returns None if panel is disabled or visible
|
||||
- Applies `hidden` (Tailwind) class if panel is visible
|
||||
- Applies positioning class based on side
|
||||
|
||||
### JavaScript Integration
|
||||
|
||||
The Panel component uses JavaScript for manual resizing:
|
||||
|
||||
**initResizer(panelId):**
|
||||
|
||||
- Initializes drag-and-drop resize functionality
|
||||
- Adds/removes `no-transition` class during drag
|
||||
- Sends width updates to server via HTMX
|
||||
- Constrains width between 150px and 500px
|
||||
|
||||
**File:** `src/myfasthtml/assets/myfasthtml.js`
|
||||
@@ -1,648 +0,0 @@
|
||||
# TabsManager Component
|
||||
|
||||
## Introduction
|
||||
|
||||
The TabsManager component provides a dynamic tabbed interface for organizing multiple views within your FastHTML
|
||||
application. It handles tab creation, activation, closing, and content management with automatic state persistence and
|
||||
HTMX-powered interactions.
|
||||
|
||||
**Key features:**
|
||||
|
||||
- Dynamic tab creation and removal at runtime
|
||||
- Automatic content caching for performance
|
||||
- Session-based state persistence (tabs, order, active tab)
|
||||
- Duplicate tab detection based on component identity
|
||||
- Built-in search menu for quick tab navigation
|
||||
- Auto-increment labels for programmatic tab creation
|
||||
- HTMX-powered updates without page reload
|
||||
|
||||
**Common use cases:**
|
||||
|
||||
- Multi-document editor (code editor, text editor)
|
||||
- Dashboard with multiple data views
|
||||
- Settings interface with different configuration panels
|
||||
- Developer tools with console, inspector, network tabs
|
||||
- Application with dynamic content sections
|
||||
|
||||
## Quick Start
|
||||
|
||||
Here's a minimal example showing a tabbed interface with three views:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
|
||||
# Create root instance and tabs manager
|
||||
root = RootInstance(session)
|
||||
tabs = TabsManager(parent=root)
|
||||
|
||||
# Create three tabs with different content
|
||||
tabs.create_tab("Dashboard", Div(H1("Dashboard"), P("Overview of your data")))
|
||||
tabs.create_tab("Settings", Div(H1("Settings"), P("Configure your preferences")))
|
||||
tabs.create_tab("Profile", Div(H1("Profile"), P("Manage your profile")))
|
||||
|
||||
# Render the tabs manager
|
||||
return tabs
|
||||
```
|
||||
|
||||
This creates a complete tabbed interface with:
|
||||
|
||||
- A header bar displaying three clickable tab buttons ("Dashboard", "Settings", "Profile")
|
||||
- Close buttons (×) on each tab for dynamic removal
|
||||
- A main content area showing the active tab's content
|
||||
- A search menu (⊞ icon) for quick tab navigation when many tabs are open
|
||||
- Automatic HTMX updates when switching or closing tabs
|
||||
|
||||
**Note:** Tabs are interactive by default. Users can click tab labels to switch views, click close buttons to remove
|
||||
tabs, or use the search menu to find tabs quickly. All interactions update the UI without page reload thanks to HTMX
|
||||
integration.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Visual Structure
|
||||
|
||||
The TabsManager component consists of a header with tab buttons and a content area:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ Tab Header │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────┐ │
|
||||
│ │ Tab 1 × │ │ Tab 2 × │ │ Tab 3 × │ │ ⊞ │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └────┘ │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ │
|
||||
│ Active Tab Content │
|
||||
│ │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Component details:**
|
||||
|
||||
| Element | Description |
|
||||
|------------------|-----------------------------------------|
|
||||
| Tab buttons | Clickable labels to switch between tabs |
|
||||
| Close button (×) | Removes the tab and its content |
|
||||
| Search menu (⊞) | Dropdown menu to search and filter tabs |
|
||||
| Content area | Displays the active tab's content |
|
||||
|
||||
### Creating a TabsManager
|
||||
|
||||
The TabsManager is a `MultipleInstance`, meaning you can create multiple independent tab managers in your application.
|
||||
Create it by providing a parent instance:
|
||||
|
||||
```python
|
||||
tabs = TabsManager(parent=root_instance)
|
||||
|
||||
# Or with a custom ID
|
||||
tabs = TabsManager(parent=root_instance, _id="my-tabs")
|
||||
```
|
||||
|
||||
### Creating Tabs
|
||||
|
||||
Use the `create_tab()` method to add a new tab:
|
||||
|
||||
```python
|
||||
# Create a tab with custom content
|
||||
tab_id = tabs.create_tab(
|
||||
label="My Tab",
|
||||
component=Div(H1("Content"), P("Tab content here"))
|
||||
)
|
||||
|
||||
# Create with a MyFastHtml control
|
||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||
|
||||
network = VisNetwork(parent=tabs, nodes=nodes_data, edges=edges_data)
|
||||
tab_id = tabs.create_tab("Network View", network)
|
||||
|
||||
# Create without activating immediately
|
||||
tab_id = tabs.create_tab("Background Tab", content, activate=False)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `label` (str): Display text shown in the tab button
|
||||
- `component` (Any): Content to display in the tab (FastHTML elements or MyFastHtml controls)
|
||||
- `activate` (bool): Whether to make this tab active immediately (default: True)
|
||||
|
||||
**Returns:** A unique `tab_id` (UUID string) that identifies the tab
|
||||
|
||||
### Showing Tabs
|
||||
|
||||
Use the `show_tab()` method to activate and display a tab:
|
||||
|
||||
```python
|
||||
# Show a tab (makes it active and sends content to client if needed)
|
||||
tabs.show_tab(tab_id)
|
||||
|
||||
# Show without activating (just send content to client)
|
||||
tabs.show_tab(tab_id, activate=False)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `tab_id` (str): The UUID of the tab to show
|
||||
- `activate` (bool): Whether to make this tab active (default: True)
|
||||
|
||||
**Note:** The first time a tab is shown, its content is sent to the client and cached. Subsequent activations just
|
||||
toggle visibility without re-sending content.
|
||||
|
||||
### Closing Tabs
|
||||
|
||||
Use the `close_tab()` method to remove a tab:
|
||||
|
||||
```python
|
||||
# Close a specific tab
|
||||
tabs.close_tab(tab_id)
|
||||
```
|
||||
|
||||
**What happens when closing:**
|
||||
|
||||
1. Tab is removed from the tab list and order
|
||||
2. Content is removed from cache and client
|
||||
3. If the closed tab was active, the first remaining tab becomes active
|
||||
4. If no tabs remain, `active_tab` is set to `None`
|
||||
|
||||
### Changing Tab Content
|
||||
|
||||
Use the `change_tab_content()` method to update an existing tab's content and label:
|
||||
|
||||
```python
|
||||
# Update tab content and label
|
||||
new_content = Div(H1("Updated"), P("New content"))
|
||||
tabs.change_tab_content(
|
||||
tab_id=tab_id,
|
||||
label="Updated Tab",
|
||||
component=new_content,
|
||||
activate=True
|
||||
)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `tab_id` (str): The UUID of the tab to update
|
||||
- `label` (str): New label for the tab
|
||||
- `component` (Any): New content to display
|
||||
- `activate` (bool): Whether to activate the tab after updating (default: True)
|
||||
|
||||
**Note:** This method forces the new content to be sent to the client, even if the tab was already displayed.
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Auto-increment Labels
|
||||
|
||||
When creating multiple tabs programmatically, you can use auto-increment to generate unique labels:
|
||||
|
||||
```python
|
||||
# Using the on_new_tab method with auto_increment
|
||||
def create_multiple_tabs():
|
||||
# Creates "Untitled_0", "Untitled_1", "Untitled_2"
|
||||
tabs.on_new_tab("Untitled", content, auto_increment=True)
|
||||
tabs.on_new_tab("Untitled", content, auto_increment=True)
|
||||
tabs.on_new_tab("Untitled", content, auto_increment=True)
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
|
||||
- The TabsManager maintains an internal counter (`_tab_count`)
|
||||
- When `auto_increment=True`, the counter value is appended to the label
|
||||
- Counter increments with each auto-incremented tab creation
|
||||
- Useful for "New Tab 1", "New Tab 2" patterns in editors or tools
|
||||
|
||||
### Duplicate Detection
|
||||
|
||||
The TabsManager automatically detects and reuses tabs with identical content to prevent duplicates:
|
||||
|
||||
```python
|
||||
# Create a control instance
|
||||
network = VisNetwork(parent=tabs, nodes=data, edges=edges)
|
||||
|
||||
# First call creates a new tab
|
||||
tab_id_1 = tabs.create_tab("Network", network)
|
||||
|
||||
# Second call with same label and component returns existing tab_id
|
||||
tab_id_2 = tabs.create_tab("Network", network)
|
||||
|
||||
# tab_id_1 == tab_id_2 (True - same tab!)
|
||||
```
|
||||
|
||||
**Detection criteria:**
|
||||
A tab is considered a duplicate if all three match:
|
||||
|
||||
- Same `label`
|
||||
- Same `component_type` (component class prefix)
|
||||
- Same `component_id` (component instance ID)
|
||||
|
||||
**Note:** This only works with `BaseInstance` components (MyFastHtml controls). Plain FastHTML elements don't have IDs
|
||||
and will always create new tabs.
|
||||
|
||||
### Dynamic Content Updates
|
||||
|
||||
You can update tabs dynamically during the session:
|
||||
|
||||
```python
|
||||
# Initial tab creation
|
||||
tab_id = tabs.create_tab("Data View", Div("Loading..."))
|
||||
|
||||
|
||||
# Later, update with actual data
|
||||
def load_data():
|
||||
data_content = Div(H2("Data"), P("Loaded content"))
|
||||
tabs.change_tab_content(tab_id, "Data View", data_content)
|
||||
# Returns HTMX response to update the UI
|
||||
```
|
||||
|
||||
**Use cases:**
|
||||
|
||||
- Loading data asynchronously
|
||||
- Refreshing tab content based on user actions
|
||||
- Updating visualizations with new data
|
||||
- Switching between different views in the same tab
|
||||
|
||||
### Tab Search Menu
|
||||
|
||||
The built-in search menu helps users navigate when many tabs are open:
|
||||
|
||||
```python
|
||||
# The search menu is automatically created and includes:
|
||||
# - A Search control for filtering tabs by label
|
||||
# - Live filtering as you type
|
||||
# - Click to activate a tab from search results
|
||||
```
|
||||
|
||||
**How to access:**
|
||||
|
||||
- Click the ⊞ icon in the tab header
|
||||
- Start typing to filter tabs by label
|
||||
- Click a result to activate that tab
|
||||
|
||||
The search menu updates automatically when tabs are added or removed.
|
||||
|
||||
### HTMX Out-of-Band Swaps
|
||||
|
||||
For advanced HTMX control, you can customize swap behavior:
|
||||
|
||||
```python
|
||||
# Standard behavior (out-of-band swap enabled)
|
||||
tabs.show_tab(tab_id, oob=True) # Default
|
||||
|
||||
# Custom target behavior (disable out-of-band)
|
||||
tabs.show_tab(tab_id, oob=False) # Swap into HTMX target only
|
||||
```
|
||||
|
||||
**When to use `oob=False`:**
|
||||
|
||||
- When you want to control the exact HTMX target
|
||||
- When combining with other HTMX responses
|
||||
- When the tab activation is triggered by a command with a specific target
|
||||
|
||||
**When to use `oob=True` (default):**
|
||||
|
||||
- Most common use case
|
||||
- Allows other controls to trigger tab changes without caring about targets
|
||||
- Enables automatic UI updates across multiple elements
|
||||
|
||||
### CSS Customization
|
||||
|
||||
The TabsManager uses CSS classes that you can customize:
|
||||
|
||||
| Class | Element |
|
||||
|--------------------------|---------------------------------|
|
||||
| `mf-tabs-manager` | Root tabs manager container |
|
||||
| `mf-tabs-header-wrapper` | Header wrapper (buttons + menu) |
|
||||
| `mf-tabs-header` | Tab buttons container |
|
||||
| `mf-tab-button` | Individual tab button |
|
||||
| `mf-tab-active` | Active tab button (modifier) |
|
||||
| `mf-tab-label` | Tab label text |
|
||||
| `mf-tab-close-btn` | Close button (×) |
|
||||
| `mf-tab-content-wrapper` | Content area container |
|
||||
| `mf-tab-content` | Individual tab content |
|
||||
| `mf-empty-content` | Empty state when no tabs |
|
||||
|
||||
**Example customization:**
|
||||
|
||||
```css
|
||||
/* Change active tab color */
|
||||
.mf-tab-active {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Customize close button */
|
||||
.mf-tab-close-btn:hover {
|
||||
color: red;
|
||||
}
|
||||
|
||||
/* Style the content area */
|
||||
.mf-tab-content-wrapper {
|
||||
padding: 2rem;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Multi-view Application
|
||||
|
||||
A typical application with different views accessible through tabs:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
|
||||
# Create tabs manager
|
||||
root = RootInstance(session)
|
||||
tabs = TabsManager(parent=root, _id="app-tabs")
|
||||
|
||||
# Dashboard view
|
||||
dashboard = Div(
|
||||
H1("Dashboard"),
|
||||
Div(
|
||||
Div("Total Users: 1,234", cls="stat"),
|
||||
Div("Active Sessions: 56", cls="stat"),
|
||||
Div("Revenue: $12,345", cls="stat"),
|
||||
cls="stats-grid"
|
||||
)
|
||||
)
|
||||
|
||||
# Analytics view
|
||||
analytics = Div(
|
||||
H1("Analytics"),
|
||||
P("Detailed analytics and reports"),
|
||||
Div("Chart placeholder", cls="chart-container")
|
||||
)
|
||||
|
||||
# Settings view
|
||||
settings = Div(
|
||||
H1("Settings"),
|
||||
Form(
|
||||
Label("Username:", Input(name="username", value="admin")),
|
||||
Label("Email:", Input(name="email", value="admin@example.com")),
|
||||
Button("Save", type="submit"),
|
||||
)
|
||||
)
|
||||
|
||||
# Create tabs
|
||||
tabs.create_tab("Dashboard", dashboard)
|
||||
tabs.create_tab("Analytics", analytics)
|
||||
tabs.create_tab("Settings", settings)
|
||||
|
||||
# Render
|
||||
return tabs
|
||||
```
|
||||
|
||||
### Example 2: Dynamic Tabs with VisNetwork
|
||||
|
||||
Creating tabs dynamically with interactive network visualizations:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
|
||||
root = RootInstance(session)
|
||||
tabs = TabsManager(parent=root, _id="network-tabs")
|
||||
|
||||
# Create initial tab with welcome message
|
||||
tabs.create_tab("Welcome", Div(
|
||||
H1("Network Visualizer"),
|
||||
P("Click 'Add Network' to create a new network visualization")
|
||||
))
|
||||
|
||||
|
||||
# Function to create a new network tab
|
||||
def add_network_tab():
|
||||
# Define network data
|
||||
nodes = [
|
||||
{"id": 1, "label": "Node 1"},
|
||||
{"id": 2, "label": "Node 2"},
|
||||
{"id": 3, "label": "Node 3"}
|
||||
]
|
||||
edges = [
|
||||
{"from": 1, "to": 2},
|
||||
{"from": 2, "to": 3}
|
||||
]
|
||||
|
||||
# Create network instance
|
||||
network = VisNetwork(parent=tabs, nodes=nodes, edges=edges)
|
||||
|
||||
# Use auto-increment to create unique labels
|
||||
return tabs.on_new_tab("Network", network, auto_increment=True)
|
||||
|
||||
|
||||
# Create command for adding networks
|
||||
add_cmd = Command("add_network", "Add network tab", add_network_tab)
|
||||
|
||||
# Add button to create new network tabs
|
||||
add_button = mk.button("Add Network", command=add_cmd, cls="btn btn-primary")
|
||||
|
||||
# Return tabs and button
|
||||
return Div(add_button, tabs)
|
||||
```
|
||||
|
||||
### Example 3: Tab Management with Content Updates
|
||||
|
||||
An application that updates tab content based on user interaction:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
|
||||
root = RootInstance(session)
|
||||
tabs = TabsManager(parent=root, _id="editor-tabs")
|
||||
|
||||
# Create initial document tabs
|
||||
doc1_id = tabs.create_tab("Document 1", Textarea("Initial content 1", rows=10))
|
||||
doc2_id = tabs.create_tab("Document 2", Textarea("Initial content 2", rows=10))
|
||||
|
||||
|
||||
# Function to refresh a document's content
|
||||
def refresh_document(tab_id, doc_name):
|
||||
# Simulate loading new content
|
||||
new_content = Textarea(f"Refreshed content for {doc_name}\nTimestamp: {datetime.now()}", rows=10)
|
||||
tabs.change_tab_content(tab_id, doc_name, new_content)
|
||||
return tabs._mk_tabs_controller(oob=True), tabs._mk_tabs_header_wrapper(oob=True)
|
||||
|
||||
|
||||
# Create refresh commands
|
||||
refresh_doc1 = Command("refresh_1", "Refresh doc 1", refresh_document, doc1_id, "Document 1")
|
||||
refresh_doc2 = Command("refresh_2", "Refresh doc 2", refresh_document, doc2_id, "Document 2")
|
||||
|
||||
# Add refresh buttons
|
||||
controls = Div(
|
||||
mk.button("Refresh Document 1", command=refresh_doc1, cls="btn btn-sm"),
|
||||
mk.button("Refresh Document 2", command=refresh_doc2, cls="btn btn-sm"),
|
||||
cls="controls-bar"
|
||||
)
|
||||
|
||||
return Div(controls, tabs)
|
||||
```
|
||||
|
||||
### Example 4: Using Auto-increment for Dynamic Tabs
|
||||
|
||||
Creating multiple tabs programmatically with auto-generated labels:
|
||||
|
||||
```python
|
||||
from fasthtml.common import *
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
|
||||
root = RootInstance(session)
|
||||
tabs = TabsManager(parent=root, _id="dynamic-tabs")
|
||||
|
||||
# Create initial placeholder tab
|
||||
tabs.create_tab("Start", Div(
|
||||
H2("Welcome"),
|
||||
P("Click 'New Tab' to create numbered tabs")
|
||||
))
|
||||
|
||||
|
||||
# Function to create a new numbered tab
|
||||
def create_numbered_tab():
|
||||
content = Div(
|
||||
H2("New Tab Content"),
|
||||
P(f"This tab was created dynamically"),
|
||||
Input(placeholder="Enter some text...", cls="input")
|
||||
)
|
||||
# Auto-increment creates "Tab_0", "Tab_1", "Tab_2", etc.
|
||||
return tabs.on_new_tab("Tab", content, auto_increment=True)
|
||||
|
||||
|
||||
# Create command
|
||||
new_tab_cmd = Command("new_tab", "Create new tab", create_numbered_tab)
|
||||
|
||||
# Add button
|
||||
new_tab_button = mk.button("New Tab", command=new_tab_cmd, cls="btn btn-primary")
|
||||
|
||||
return Div(
|
||||
Div(new_tab_button, cls="toolbar"),
|
||||
tabs
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Developer Reference
|
||||
|
||||
This section contains technical details for developers working on the TabsManager component itself.
|
||||
|
||||
### State
|
||||
|
||||
The TabsManager component maintains the following state properties:
|
||||
|
||||
| Name | Type | Description | Default |
|
||||
|--------------------------|----------------|---------------------------------------------------|---------|
|
||||
| `tabs` | dict[str, Any] | Dictionary of tab metadata (id, label, component) | `{}` |
|
||||
| `tabs_order` | list[str] | Ordered list of tab IDs | `[]` |
|
||||
| `active_tab` | str \| None | ID of the currently active tab | `None` |
|
||||
| `ns_tabs_content` | dict[str, Any] | Cache of tab content (raw, not wrapped) | `{}` |
|
||||
| `ns_tabs_sent_to_client` | set | Set of tab IDs already sent to client | `set()` |
|
||||
|
||||
**Note:** Properties prefixed with `ns_` are not persisted in the database and exist only for the session.
|
||||
|
||||
### Commands
|
||||
|
||||
Available commands for programmatic control:
|
||||
|
||||
| Name | Description |
|
||||
|---------------------------------------------|--------------------------------------------|
|
||||
| `show_tab(tab_id)` | Activate or show a specific tab |
|
||||
| `close_tab(tab_id)` | Close a specific tab |
|
||||
| `add_tab(label, component, auto_increment)` | Add a new tab with optional auto-increment |
|
||||
|
||||
### Public Methods
|
||||
|
||||
| Method | Description |
|
||||
|---------------------------------------------------------------|-------------------------------------------------|
|
||||
| `create_tab(label, component, activate=True)` | Create a new tab or reuse existing duplicate |
|
||||
| `show_tab(tab_id, activate=True, oob=True)` | Send tab to client and/or activate it |
|
||||
| `close_tab(tab_id)` | Close and remove a tab |
|
||||
| `change_tab_content(tab_id, label, component, activate=True)` | Update existing tab's label and content |
|
||||
| `on_new_tab(label, component, auto_increment=False)` | Create and show tab with auto-increment support |
|
||||
| `add_tab_btn()` | Returns add tab button element |
|
||||
| `get_state()` | Returns the TabsManagerState object |
|
||||
| `render()` | Renders the complete TabsManager component |
|
||||
|
||||
### High Level Hierarchical Structure
|
||||
|
||||
```
|
||||
Div(id="{id}", cls="mf-tabs-manager")
|
||||
├── Div(id="{id}-controller") # Controller (hidden, manages active state)
|
||||
├── Div(id="{id}-header-wrapper") # Header wrapper
|
||||
│ ├── Div(id="{id}-header") # Tab buttons container
|
||||
│ │ ├── Div (mf-tab-button) # Tab button 1
|
||||
│ │ │ ├── Span (mf-tab-label) # Label (clickable)
|
||||
│ │ │ └── Span (mf-tab-close-btn) # Close button
|
||||
│ │ ├── Div (mf-tab-button) # Tab button 2
|
||||
│ │ └── ...
|
||||
│ └── Div (dropdown) # Search menu
|
||||
│ ├── Icon (tabs24_regular) # Menu toggle button
|
||||
│ └── Div (dropdown-content) # Search component
|
||||
├── Div(id="{id}-content-wrapper") # Content wrapper
|
||||
│ ├── Div(id="{id}-{tab_id_1}-content") # Tab 1 content
|
||||
│ ├── Div(id="{id}-{tab_id_2}-content") # Tab 2 content
|
||||
│ └── ...
|
||||
└── Script # Initialization script
|
||||
```
|
||||
|
||||
### Element IDs
|
||||
|
||||
| Name | Description |
|
||||
|-------------------------|-----------------------------------------|
|
||||
| `{id}` | Root tabs manager container |
|
||||
| `{id}-controller` | Hidden controller managing active state |
|
||||
| `{id}-header-wrapper` | Header wrapper (buttons + search) |
|
||||
| `{id}-header` | Tab buttons container |
|
||||
| `{id}-content-wrapper` | Content area wrapper |
|
||||
| `{id}-{tab_id}-content` | Individual tab content |
|
||||
| `{id}-search` | Search component ID |
|
||||
|
||||
**Note:** `{id}` is the TabsManager instance ID, `{tab_id}` is the UUID of each tab.
|
||||
|
||||
### Internal Methods
|
||||
|
||||
These methods are used internally for rendering:
|
||||
|
||||
| Method | Description |
|
||||
|-----------------------------------------|-----------------------------------------------------|
|
||||
| `_mk_tabs_controller(oob=False)` | Renders the hidden controller element |
|
||||
| `_mk_tabs_header_wrapper(oob=False)` | Renders the header wrapper with buttons and search |
|
||||
| `_mk_tab_button(tab_data)` | Renders a single tab button |
|
||||
| `_mk_tab_content_wrapper()` | Renders the content wrapper with active tab content |
|
||||
| `_mk_tab_content(tab_id, content)` | Renders individual tab content div |
|
||||
| `_mk_show_tabs_menu()` | Renders the search dropdown menu |
|
||||
| `_wrap_tab_content(tab_content)` | Wraps tab content for HTMX out-of-band insertion |
|
||||
| `_get_or_create_tab_content(tab_id)` | Gets tab content from cache or creates it |
|
||||
| `_dynamic_get_content(tab_id)` | Retrieves component from InstancesManager |
|
||||
| `_tab_already_exists(label, component)` | Checks if duplicate tab exists |
|
||||
| `_add_or_update_tab(...)` | Internal method to add/update tab in state |
|
||||
| `_get_ordered_tabs()` | Returns tabs ordered by tabs_order list |
|
||||
| `_get_tab_list()` | Returns list of tab dictionaries in order |
|
||||
| `_get_tab_count()` | Returns and increments internal tab counter |
|
||||
|
||||
### Tab Metadata Structure
|
||||
|
||||
Each tab in the `tabs` dictionary has the following structure:
|
||||
|
||||
```python
|
||||
{
|
||||
'id': 'uuid-string', # Unique tab identifier
|
||||
'label': 'Tab Label', # Display label
|
||||
'component_type': 'prefix', # Component class prefix (or None)
|
||||
'component_id': 'instance-id' # Component instance ID (or None)
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** `component_type` and `component_id` are `None` for plain FastHTML elements that don't inherit from
|
||||
`BaseInstance`.
|
||||
@@ -37,20 +37,17 @@ markdown-it-py==4.0.0
|
||||
mdurl==0.1.2
|
||||
more-itertools==10.8.0
|
||||
myauth==0.2.1
|
||||
mydbengine==0.2.1
|
||||
-e git+ssh://git@sheerka.synology.me:1010/kodjo/MyFastHtml.git@2f808ed226e98738a1cf476e1f1dda8a1d9118b0#egg=myfasthtml
|
||||
myutils==0.5.1
|
||||
mydbengine==0.1.0
|
||||
myutils==0.5.0
|
||||
nh3==0.3.1
|
||||
numpy==2.3.5
|
||||
oauthlib==3.3.1
|
||||
openpyxl==3.1.5
|
||||
packaging==25.0
|
||||
pandas==2.3.3
|
||||
pandas-stubs==2.3.3.251201
|
||||
passlib==1.7.4
|
||||
pipdeptree==2.29.0
|
||||
pluggy==1.6.0
|
||||
pyarrow==22.0.0
|
||||
pyasn1==0.6.1
|
||||
pycparser==2.23
|
||||
pydantic==2.12.3
|
||||
|
||||
35
src/app.py
35
src/app.py
@@ -1,11 +1,7 @@
|
||||
import json
|
||||
import logging.config
|
||||
|
||||
import pandas as pd
|
||||
import yaml
|
||||
from dbengine.handlers import BaseRefHandler, handlers
|
||||
from fasthtml import serve
|
||||
from fasthtml.components import Div
|
||||
|
||||
from myfasthtml.controls.CommandsDebugger import CommandsDebugger
|
||||
from myfasthtml.controls.DataGridsManager import DataGridsManager
|
||||
@@ -38,23 +34,6 @@ app, rt = create_app(protect_routes=True,
|
||||
base_url="http://localhost:5003")
|
||||
|
||||
|
||||
class DataFrameHandler(BaseRefHandler):
|
||||
def is_eligible_for(self, obj):
|
||||
return isinstance(obj, pd.DataFrame)
|
||||
|
||||
def tag(self):
|
||||
return "DataFrame"
|
||||
|
||||
def serialize_to_bytes(self, df) -> bytes:
|
||||
from io import BytesIO
|
||||
import pickle
|
||||
return pickle.dumps(df)
|
||||
|
||||
def deserialize_from_bytes(self, data: bytes):
|
||||
import pickle
|
||||
return pickle.loads(data)
|
||||
|
||||
|
||||
def create_sample_treeview(parent):
|
||||
"""
|
||||
Create a sample TreeView with a file structure for testing.
|
||||
@@ -103,9 +82,7 @@ def create_sample_treeview(parent):
|
||||
|
||||
@rt("/")
|
||||
def index(session):
|
||||
session_instance = UniqueInstance(session=session,
|
||||
_id=Ids.UserSession,
|
||||
on_init=lambda: handlers.register_handler(DataFrameHandler()))
|
||||
session_instance = UniqueInstance(session=session, _id=Ids.UserSession)
|
||||
layout = Layout(session_instance, "Testing Layout")
|
||||
layout.footer_left.add("Goodbye World")
|
||||
|
||||
@@ -145,16 +122,8 @@ def index(session):
|
||||
layout.left_drawer.add(btn_file_upload, "Test")
|
||||
layout.left_drawer.add(btn_popup, "Test")
|
||||
layout.left_drawer.add(tree_view, "TreeView")
|
||||
|
||||
# data grids
|
||||
dgs_manager = DataGridsManager(layout, _id="-datagrids")
|
||||
layout.left_drawer.add_group("Documents", Div("Documents",
|
||||
dgs_manager.mk_main_icons(),
|
||||
cls="mf-layout-group flex gap-3"))
|
||||
layout.left_drawer.add(dgs_manager, "Documents")
|
||||
layout.left_drawer.add(DataGridsManager(layout, _id="-datagrids"), "Documents")
|
||||
layout.set_main(tabs_manager)
|
||||
|
||||
# keyboard shortcuts
|
||||
keyboard = Keyboard(layout, _id="-keyboard").add("ctrl+o",
|
||||
add_tab("File Open", FileUpload(layout, _id="-file_upload")))
|
||||
keyboard.add("ctrl+n", add_tab("File Open", FileUpload(layout, _id="-file_upload")))
|
||||
|
||||
@@ -439,12 +439,13 @@
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background-color: var(--color-base-100);
|
||||
padding: 0.5rem;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
/* Empty Content State */
|
||||
.mf-empty-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
@@ -662,31 +663,17 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Common properties for side panels */
|
||||
.mf-panel-left,
|
||||
.mf-panel-right {
|
||||
.mf-panel-left {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 250px;
|
||||
min-width: 150px;
|
||||
max-width: 500px;
|
||||
max-width: 400px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
transition: width 0.3s ease, min-width 0.3s ease, max-width 0.3s ease;
|
||||
padding-top: 25px;
|
||||
}
|
||||
|
||||
/* Left panel specific */
|
||||
.mf-panel-left {
|
||||
border-right: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
/* Right panel specific */
|
||||
.mf-panel-right {
|
||||
border-left: 1px solid var(--color-border-primary);
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.mf-panel-main {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
@@ -694,76 +681,39 @@
|
||||
min-width: 0; /* Important to allow the shrinking of flexbox */
|
||||
}
|
||||
|
||||
/* Hidden state - common for both panels */
|
||||
.mf-panel-left.mf-hidden,
|
||||
.mf-panel-right.mf-hidden {
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* No transition during manual resize - common for both panels */
|
||||
.mf-panel-left.no-transition,
|
||||
.mf-panel-right.no-transition {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Common properties for panel toggle icons */
|
||||
.mf-panel-hide-icon,
|
||||
.mf-panel-show-icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.mf-panel-hide-icon:hover,
|
||||
.mf-panel-show-icon:hover {
|
||||
background-color: var(--color-bg-hover, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
/* Show icon positioning */
|
||||
.mf-panel-show-icon-left {
|
||||
left: 0.5rem;
|
||||
}
|
||||
|
||||
.mf-panel-show-icon-right {
|
||||
right: 0.5rem;
|
||||
.mf-panel-right {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 300px;
|
||||
min-width: 150px;
|
||||
max-width: 500px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
border-left: 1px solid var(--color-border-primary);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
/* ************* Properties Component ************ */
|
||||
/* *********************************************** */
|
||||
|
||||
/*!* Properties container *!*/
|
||||
/* Properties container */
|
||||
.mf-properties {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/*!* Group card - using DaisyUI card styling *!*/
|
||||
/* Group card - using DaisyUI card styling */
|
||||
.mf-properties-group-card {
|
||||
background-color: var(--color-base-100);
|
||||
border: 1px solid color-mix(in oklab, var(--color-base-content) 10%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.mf-properties-group-container {
|
||||
display: inline-block; /* important */
|
||||
min-width: max-content; /* important */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
/*!* Group header - gradient using DaisyUI primary color *!*/
|
||||
/* Group header - gradient using DaisyUI primary color */
|
||||
.mf-properties-group-header {
|
||||
background: linear-gradient(135deg, var(--color-primary) 0%, color-mix(in oklab, var(--color-primary) 80%, black) 100%);
|
||||
color: var(--color-primary-content);
|
||||
@@ -772,24 +722,20 @@
|
||||
font-size: var(--properties-font-size);
|
||||
}
|
||||
|
||||
/*!* Group content area *!*/
|
||||
/* Group content area */
|
||||
.mf-properties-group-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
/*!* Property row *!*/
|
||||
/* Property row */
|
||||
.mf-properties-row {
|
||||
display: grid;
|
||||
grid-template-columns: 6rem 1fr;
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: calc(var(--properties-font-size) * 0.4) calc(var(--properties-font-size) * 0.75);
|
||||
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 5%, transparent);
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
gap: calc(var(--properties-font-size) * 0.75);
|
||||
}
|
||||
|
||||
@@ -801,365 +747,22 @@
|
||||
background-color: color-mix(in oklab, var(--color-base-content) 3%, transparent);
|
||||
}
|
||||
|
||||
/*!* Property key - normal font *!*/
|
||||
/* Property key - normal font */
|
||||
.mf-properties-key {
|
||||
align-items: start;
|
||||
font-weight: 600;
|
||||
color: color-mix(in oklab, var(--color-base-content) 70%, transparent);
|
||||
flex: 0 0 40%;
|
||||
font-size: var(--properties-font-size);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/*!* Property value - monospace font *!*/
|
||||
/* Property value - monospace font */
|
||||
.mf-properties-value {
|
||||
font-family: var(--default-mono-font-family);
|
||||
color: var(--color-base-content);
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
text-align: right;
|
||||
font-size: var(--properties-font-size);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
/* ********************************************* */
|
||||
/* ************* Datagrid Component ************ */
|
||||
/* ********************************************* */
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dt2-drag-drop {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
z-index: var(--datagrid-drag-drop-zindex);
|
||||
width: 100px;
|
||||
border: 1px solid var(--color-base-300);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
box-shadow: 0 0 40px rgba(0, 0, 0, 0.3);
|
||||
background: var(--color-base-100);
|
||||
box-sizing: border-box;
|
||||
overflow-x: auto;
|
||||
pointer-events: none; /* Prevent interfering with mouse events */
|
||||
|
||||
}
|
||||
|
||||
.dt2-main {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dt2-sidebar {
|
||||
opacity: 0; /* Default to invisible */
|
||||
visibility: hidden; /* Prevent interaction when invisible */
|
||||
transition: opacity 0.3s ease, visibility 0s linear 0.3s; /* Delay visibility removal */
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 75%;
|
||||
max-height: 710px;
|
||||
overflow-y: auto;
|
||||
background-color: var(--color-base-100);
|
||||
z-index: var(--datagrid-sidebar-zindex);
|
||||
box-shadow: -5px 0 15px rgba(0, 0, 0, 0.5); /* Stronger shadow */
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.dt2-sidebar.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.dt2-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dt2-scrollbars {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
bottom: 0px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
pointer-events: none; /* Ensures parents don't intercept pointer events */
|
||||
z-index: var(--datagrid-scrollbars-zindex);
|
||||
}
|
||||
|
||||
/* Scrollbar Wrappers common attributes*/
|
||||
.dt2-scrollbars-vertical-wrapper,
|
||||
.dt2-scrollbars-horizontal-wrapper {
|
||||
position: absolute;
|
||||
background-color: var(--color-base-200);
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease-in-out; /* Smooth fade in/out */
|
||||
pointer-events: auto; /* Allow interaction */
|
||||
}
|
||||
|
||||
/* Scrollbar Wrappers */
|
||||
.dt2-scrollbars-vertical-wrapper {
|
||||
left: auto;
|
||||
right: 3px;
|
||||
top: 3px;
|
||||
bottom: 3px;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.dt2-scrollbars-horizontal-wrapper {
|
||||
left: 3px;
|
||||
right: 3px;
|
||||
top: auto;
|
||||
bottom: -12px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
/* Scrollbars */
|
||||
.dt2-scrollbars-vertical,
|
||||
.dt2-scrollbars-horizontal {
|
||||
background-color: var(--color-base-300);
|
||||
border-radius: 3px;
|
||||
pointer-events: auto; /* Allow interaction with the scrollbar */
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
border-radius: 3px; /* Rounded corners */
|
||||
pointer-events: auto; /* Enable interaction */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
/* Vertical Scrollbar */
|
||||
.dt2-scrollbars-vertical {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: auto;
|
||||
bottom: auto;
|
||||
width: 100%; /* Fits inside its wrapper */
|
||||
}
|
||||
|
||||
/* Horizontal Scrollbar */
|
||||
.dt2-scrollbars-horizontal {
|
||||
left: auto;
|
||||
right: auto;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
height: 100%; /* Fits inside its wrapper */
|
||||
}
|
||||
|
||||
/* Scrollbar hover effects */
|
||||
.dt2-scrollbars-vertical:hover,
|
||||
.dt2-scrollbars-horizontal:hover,
|
||||
.dt2-scrollbars-vertical.dt2-dragging,
|
||||
.dt2-scrollbars-horizontal.dt2-dragging {
|
||||
background-color: var(--color-base-content);
|
||||
}
|
||||
|
||||
.dt2-table {
|
||||
--color-border: color-mix(in oklab, var(--color-base-content) 20%, #0000);
|
||||
--color-resize: color-mix(in oklab, var(--color-base-content) 50%, #0000);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dt2-table:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dt2-header,
|
||||
.dt2-footer {
|
||||
background-color: var(--color-base-200);
|
||||
border-radius: 10px 10px 0 0;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.dt2-body {
|
||||
overflow: hidden; /* You can change this to auto if horizontal scrolling is required */
|
||||
font-size: 14px;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.dt2-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.dt2-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 2px 8px;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 100px;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
box-sizing: border-box; /* to include the borders in the computations */
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.dt2-cell-content-text {
|
||||
text-align: inherit;
|
||||
width: 100%;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.dt2-cell-content-checkbox {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center; /* Horizontally center the icon */
|
||||
align-items: center; /* Vertically center the icon */
|
||||
}
|
||||
|
||||
.dt2-cell-content-number {
|
||||
text-align: right;
|
||||
width: 100%;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.dt2-footer-cell {
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
.dt2-footer-menu {
|
||||
position: absolute;
|
||||
display: None;
|
||||
z-index: var(--datagrid-menu-zindex);
|
||||
border: 1px solid oklch(var(--b3));
|
||||
box-sizing: border-box;
|
||||
width: 80px;
|
||||
background-color: var(--color-base-100); /* Add background color */
|
||||
opacity: 1; /* Ensure full opacity */
|
||||
}
|
||||
|
||||
.dt2-footer-menu.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dt2-footer-menu-item {
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-base-100); /* Add background color */
|
||||
}
|
||||
|
||||
.dt2-footer-menu-item:hover {
|
||||
background: color-mix(in oklab, var(--color-base-100, var(--color-base-200)), #000 7%);
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
.dt2-resize-handle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 8px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.dt2-resize-handle::after {
|
||||
content: ''; /* This is required */
|
||||
position: absolute; /* Position as needed */
|
||||
z-index: var(--datagrid-resize-zindex);
|
||||
display: block; /* Makes it a block element */
|
||||
width: 3px;
|
||||
height: 60%;
|
||||
top: calc(50% - 60% * 0.5);
|
||||
background-color: var(--color-resize);
|
||||
}
|
||||
|
||||
.dt2-header-hidden {
|
||||
width: 5px;
|
||||
background: var(--color-neutral-content);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dt2-col-hidden {
|
||||
width: 5px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.dt2-highlight-1 {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.dt2-item-handle {
|
||||
background-image: radial-gradient(var(--color-primary-content) 40%, transparent 0);
|
||||
background-repeat: repeat;
|
||||
background-size: 4px 4px;
|
||||
cursor: grab;
|
||||
display: inline-block;
|
||||
height: 16px;
|
||||
margin: auto;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
/* **************************************************************************** */
|
||||
/* COLUMNS SETTINGS */
|
||||
/* **************************************************************************** */
|
||||
|
||||
.dt2-cs-header {
|
||||
background-color: var(--color-base-200);
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.dt2-cs-columns {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr 0.5fr 0.5fr 0.5fr 0.5fr;
|
||||
}
|
||||
|
||||
.dt2-cs-body input {
|
||||
outline: none;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.dt2-cs-body input[type="checkbox"],
|
||||
.dt2-cs-body input.checkbox {
|
||||
outline: initial;
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
|
||||
.dt2-cs-cell {
|
||||
padding: 0 6px 0 6px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.dt2-cs-checkbox-cell {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.dt2-cs-number-cell {
|
||||
padding: 0 6px 0 6px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dt2-cs-select-cell {
|
||||
padding: 0 6px;
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.dt2-cs-body input:hover {
|
||||
border: 1px solid #ccc; /* Provide a subtle border on focus */
|
||||
}
|
||||
|
||||
|
||||
.dt2-views-container-select {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.dt2-views-container-create {
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
@@ -72,8 +72,6 @@ function initResizer(containerId, options = {}) {
|
||||
// Add resizing class for visual feedback
|
||||
document.body.classList.add('mf-resizing');
|
||||
currentItem.classList.add('mf-item-resizing');
|
||||
// Disable transition during manual resize
|
||||
currentItem.classList.add('no-transition');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,8 +114,6 @@ function initResizer(containerId, options = {}) {
|
||||
// Remove resizing classes
|
||||
document.body.classList.remove('mf-resizing');
|
||||
currentItem.classList.remove('mf-item-resizing');
|
||||
// Re-enable transition after manual resize
|
||||
currentItem.classList.remove('no-transition');
|
||||
|
||||
// Get final width
|
||||
const finalWidth = currentItem.offsetWidth;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.helpers import Ids
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import SingleInstance
|
||||
|
||||
@@ -16,7 +17,6 @@ class Commands(BaseCommands):
|
||||
def update_boundaries(self):
|
||||
return Command(f"{self._prefix}UpdateBoundaries",
|
||||
"Update component boundaries",
|
||||
self._owner,
|
||||
self._owner.update_boundaries).htmx(target=f"{self._owner.get_id()}")
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||
from myfasthtml.core.commands import CommandsManager
|
||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||
from myfasthtml.core.instances import SingleInstance
|
||||
from myfasthtml.core.network_utils import from_parent_child_list
|
||||
|
||||
|
||||
@@ -9,18 +9,22 @@ class CommandsDebugger(SingleInstance):
|
||||
Represents a debugger designed for visualizing and managing commands in a parent-child
|
||||
hierarchical structure.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
|
||||
def render(self):
|
||||
nodes, edges = self._get_nodes_and_edges()
|
||||
commands = self._get_commands()
|
||||
nodes, edges = from_parent_child_list(commands,
|
||||
id_getter=lambda x: str(x.id),
|
||||
label_getter=lambda x: x.name,
|
||||
parent_getter=lambda x: str(self.get_command_parent(x))
|
||||
)
|
||||
|
||||
vis_network = VisNetwork(self, nodes=nodes, edges=edges)
|
||||
return vis_network
|
||||
|
||||
@staticmethod
|
||||
def get_command_parent_from_ft(command):
|
||||
def get_command_parent(command):
|
||||
if (ft := command.get_ft()) is None:
|
||||
return None
|
||||
if hasattr(ft, "get_id") and callable(ft.get_id):
|
||||
@@ -32,30 +36,6 @@ class CommandsDebugger(SingleInstance):
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_command_parent_from_instance(command):
|
||||
if command.owner is None:
|
||||
return None
|
||||
|
||||
return command.owner.get_full_id()
|
||||
|
||||
def _get_nodes_and_edges(self):
|
||||
commands = self._get_commands()
|
||||
nodes, edges = from_parent_child_list(commands,
|
||||
id_getter=lambda x: str(x.id),
|
||||
label_getter=lambda x: x.name,
|
||||
parent_getter=lambda x: str(self.get_command_parent_from_instance(x)),
|
||||
ghost_label_getter=lambda x: InstancesManager.get(*x.split("#")).get_id()
|
||||
)
|
||||
for edge in edges:
|
||||
edge["color"] = "blue"
|
||||
edge["arrows"] = {"to": {"enabled": False, "type": "circle"}}
|
||||
|
||||
for node in nodes:
|
||||
node["shape"] = "box"
|
||||
|
||||
return nodes, edges
|
||||
|
||||
def _get_commands(self):
|
||||
return list(CommandsManager.commands.values())
|
||||
|
||||
|
||||
@@ -1,43 +1,17 @@
|
||||
import html
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
from fasthtml.common import NotStr
|
||||
from fasthtml.components import *
|
||||
from fasthtml.components import Div
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
|
||||
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, DataGridFooterConf, \
|
||||
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.constants import ColumnType, ROW_INDEX_ID, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
from myfasthtml.core.optimized_ft import OptimizedDiv
|
||||
from myfasthtml.core.utils import make_safe_id
|
||||
from myfasthtml.icons.fluent import checkbox_unchecked16_regular
|
||||
from myfasthtml.icons.fluent_p2 import checkbox_checked16_regular
|
||||
|
||||
# OPTIMIZATION: Pre-compiled regex to detect HTML special characters
|
||||
_HTML_SPECIAL_CHARS_REGEX = re.compile(r'[<>&"\']')
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def _mk_bool_cached(_value):
|
||||
"""
|
||||
OPTIMIZED: Cached boolean checkbox HTML generator.
|
||||
Since there are only 2 possible values (True/False), this will only generate HTML twice.
|
||||
"""
|
||||
return NotStr(str(
|
||||
Div(mk.icon(checkbox_checked16_regular if _value else checkbox_unchecked16_regular, can_select=False),
|
||||
cls="dt2-cell-content-checkbox")
|
||||
))
|
||||
|
||||
|
||||
class DatagridState(DbObject):
|
||||
def __init__(self, owner, save_state):
|
||||
super().__init__(owner, name=f"{owner.get_full_id()}-state", save_state=save_state)
|
||||
def __init__(self, owner):
|
||||
super().__init__(owner)
|
||||
with self.initializing():
|
||||
self.sidebar_visible: bool = False
|
||||
self.selected_view: str = None
|
||||
@@ -50,15 +24,11 @@ class DatagridState(DbObject):
|
||||
self.filtered: dict = {}
|
||||
self.edition: DatagridEditionState = DatagridEditionState()
|
||||
self.selection: DatagridSelectionState = DatagridSelectionState()
|
||||
self.ne_df = None
|
||||
|
||||
self.ns_fast_access = None
|
||||
self.ns_total_rows = None
|
||||
|
||||
|
||||
class DatagridSettings(DbObject):
|
||||
def __init__(self, owner, save_state):
|
||||
super().__init__(owner, name=f"{owner.get_full_id()}-settings", save_state=save_state)
|
||||
def __init__(self, owner):
|
||||
super().__init__(owner)
|
||||
with self.initializing():
|
||||
self.file_name: Optional[str] = None
|
||||
self.selected_sheet_name: Optional[str] = None
|
||||
@@ -74,297 +44,15 @@ class Commands(BaseCommands):
|
||||
|
||||
|
||||
class DataGrid(MultipleInstance):
|
||||
def __init__(self, parent, settings=None, save_state=False, _id=None):
|
||||
def __init__(self, parent, settings=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self._settings = settings or DatagridSettings(self, save_state=save_state)
|
||||
self._state = DatagridState(self, save_state=save_state)
|
||||
self._settings = DatagridSettings(self).update(settings)
|
||||
self._state = DatagridState(self)
|
||||
self.commands = Commands(self)
|
||||
self.init_from_dataframe(self._state.ne_df)
|
||||
|
||||
@property
|
||||
def _df(self):
|
||||
return self._state.ne_df
|
||||
|
||||
def init_from_dataframe(self, df):
|
||||
|
||||
def _get_column_type(dtype):
|
||||
if pd.api.types.is_integer_dtype(dtype):
|
||||
return ColumnType.Number
|
||||
elif pd.api.types.is_float_dtype(dtype):
|
||||
return ColumnType.Number
|
||||
elif pd.api.types.is_bool_dtype(dtype):
|
||||
return ColumnType.Bool
|
||||
elif pd.api.types.is_datetime64_any_dtype(dtype):
|
||||
return ColumnType.Datetime
|
||||
else:
|
||||
return ColumnType.Text # Default to Text if no match
|
||||
|
||||
def _init_columns(_df):
|
||||
columns = [DataGridColumnState(make_safe_id(col_id),
|
||||
col_index,
|
||||
col_id,
|
||||
_get_column_type(self._df[make_safe_id(col_id)].dtype))
|
||||
for col_index, col_id in enumerate(_df.columns)]
|
||||
if self._state.row_index:
|
||||
columns.insert(0, DataGridColumnState(make_safe_id(ROW_INDEX_ID), -1, " ", ColumnType.RowIndex))
|
||||
|
||||
return columns
|
||||
|
||||
def _init_fast_access(_df):
|
||||
"""
|
||||
Generates a fast-access dictionary for a DataFrame.
|
||||
|
||||
This method converts the columns of the provided DataFrame into NumPy arrays
|
||||
and stores them as values in a dictionary, using the column names as keys.
|
||||
This allows for efficient access to the data stored in the DataFrame.
|
||||
|
||||
Args:
|
||||
_df (DataFrame): The input pandas DataFrame whose columns are to be converted
|
||||
into a dictionary of NumPy arrays.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary where the keys are the column names of the input DataFrame
|
||||
and the values are the corresponding column values as NumPy arrays.
|
||||
"""
|
||||
if _df is None:
|
||||
return {}
|
||||
|
||||
res = {col: _df[col].to_numpy() for col in _df.columns}
|
||||
res[ROW_INDEX_ID] = _df.index.to_numpy()
|
||||
return res
|
||||
|
||||
if df is not None:
|
||||
self._state.ne_df = df
|
||||
self._df.columns = self._df.columns.map(make_safe_id) # make sure column names are trimmed
|
||||
self._state.rows = [DataGridRowState(row_id) for row_id in self._df.index]
|
||||
self._state.columns = _init_columns(df) # use df not self._df to keep the original title
|
||||
self._state.ns_fast_access = _init_fast_access(self._df)
|
||||
self._state.ns_total_rows = len(self._df) if self._df is not None else 0
|
||||
|
||||
return self
|
||||
|
||||
def mk_headers(self):
|
||||
def _mk_header_name(col_def: DataGridColumnState):
|
||||
return Div(
|
||||
mk.label(col_def.title, name="dt2-header-title"),
|
||||
cls="flex truncate cursor-default",
|
||||
)
|
||||
|
||||
def _mk_header(col_def: DataGridColumnState):
|
||||
return Div(
|
||||
_mk_header_name(col_def),
|
||||
Div(cls="dt2-resize-handle"),
|
||||
style=f"width:{col_def.width}px;",
|
||||
data_col=col_def.col_id,
|
||||
data_tooltip=col_def.title,
|
||||
cls="dt2-cell dt2-resizable flex",
|
||||
)
|
||||
|
||||
header_class = "dt2-row dt2-header" + "" if self._settings.header_visible else " hidden"
|
||||
return Div(
|
||||
*[_mk_header(col_def) for col_def in self._state.columns],
|
||||
cls=header_class,
|
||||
id=f"th_{self._id}"
|
||||
)
|
||||
|
||||
def mk_body_cell_content(self, col_pos, row_index, col_def: DataGridColumnState, filter_keyword_lower=None):
|
||||
"""
|
||||
OPTIMIZED: Generate cell content with minimal object creation.
|
||||
- Uses plain strings instead of Label objects when possible
|
||||
- Accepts pre-computed filter_keyword_lower to avoid repeated dict lookups
|
||||
- Avoids html.escape when not necessary
|
||||
- Uses cached boolean HTML (_mk_bool_cached)
|
||||
"""
|
||||
|
||||
def mk_highlighted_text(value_str, css_class):
|
||||
"""Return highlighted text as raw HTML string or tuple of Spans."""
|
||||
if not filter_keyword_lower:
|
||||
# OPTIMIZATION: Return plain HTML string instead of Label object
|
||||
# Include "truncate text-sm" to match mk.label() behavior (ellipsis + font size)
|
||||
return NotStr(f'<span class="{css_class} truncate text-sm">{value_str}</span>')
|
||||
|
||||
index = value_str.lower().find(filter_keyword_lower)
|
||||
if index < 0:
|
||||
return NotStr(f'<span class="{css_class} truncate text-sm">{value_str}</span>')
|
||||
|
||||
# Has highlighting - need to use Span objects
|
||||
# Add "truncate text-sm" to match mk.label() behavior
|
||||
len_keyword = len(filter_keyword_lower)
|
||||
res = []
|
||||
if index > 0:
|
||||
res.append(Span(value_str[:index], cls=f"{css_class} text-sm"))
|
||||
res.append(Span(value_str[index:index + len_keyword], cls=f"{css_class} text-sm dt2-highlight-1"))
|
||||
if index + len_keyword < len(value_str):
|
||||
res.append(Span(value_str[index + len_keyword:], cls=f"{css_class} text-sm"))
|
||||
return Span(*res, cls=f"{css_class} truncate") if len(res) > 1 else res[0]
|
||||
|
||||
column_type = col_def.type
|
||||
value = self._state.ns_fast_access[col_def.col_id][row_index]
|
||||
|
||||
# Boolean type - uses cached HTML (only 2 possible values)
|
||||
if column_type == ColumnType.Bool:
|
||||
return _mk_bool_cached(value)
|
||||
|
||||
# RowIndex - simplest case, just return the number as plain HTML
|
||||
if column_type == ColumnType.RowIndex:
|
||||
return NotStr(f'<span class="dt2-cell-content-number truncate text-sm">{row_index}</span>')
|
||||
|
||||
# Convert value to string
|
||||
value_str = str(value)
|
||||
|
||||
# OPTIMIZATION: Only escape if necessary (check for HTML special chars with pre-compiled regex)
|
||||
if _HTML_SPECIAL_CHARS_REGEX.search(value_str):
|
||||
value_str = html.escape(value_str)
|
||||
|
||||
# Number or Text type
|
||||
if column_type == ColumnType.Number:
|
||||
return mk_highlighted_text(value_str, "dt2-cell-content-number")
|
||||
else:
|
||||
return mk_highlighted_text(value_str, "dt2-cell-content-text")
|
||||
|
||||
def mk_body_cell(self, col_pos, row_index, col_def: DataGridColumnState, filter_keyword_lower=None):
|
||||
"""
|
||||
OPTIMIZED: Accepts pre-computed filter_keyword_lower to avoid repeated dict lookups.
|
||||
OPTIMIZED: Uses OptimizedDiv instead of Div for faster rendering.
|
||||
"""
|
||||
if not col_def.usable:
|
||||
return None
|
||||
|
||||
if not col_def.visible:
|
||||
return OptimizedDiv(cls="dt2-col-hidden")
|
||||
|
||||
content = self.mk_body_cell_content(col_pos, row_index, col_def, filter_keyword_lower)
|
||||
|
||||
return OptimizedDiv(content,
|
||||
data_col=col_def.col_id,
|
||||
style=f"width:{col_def.width}px;",
|
||||
cls="dt2-cell")
|
||||
|
||||
def mk_body_content_page(self, page_index: int):
|
||||
"""
|
||||
OPTIMIZED: Extract filter keyword once instead of 10,000 times.
|
||||
OPTIMIZED: Uses OptimizedDiv for rows instead of Div for faster rendering.
|
||||
"""
|
||||
df = self._df # self._get_filtered_df()
|
||||
start = page_index * DATAGRID_PAGE_SIZE
|
||||
end = start + DATAGRID_PAGE_SIZE
|
||||
if self._state.ns_total_rows > end:
|
||||
last_row = df.index[end - 1]
|
||||
else:
|
||||
last_row = None
|
||||
|
||||
# OPTIMIZATION: Extract filter keyword once (was being checked 10,000 times)
|
||||
filter_keyword = self._state.filtered.get(FILTER_INPUT_CID)
|
||||
filter_keyword_lower = filter_keyword.lower() if filter_keyword else None
|
||||
|
||||
rows = [OptimizedDiv(
|
||||
*[self.mk_body_cell(col_pos, row_index, col_def, filter_keyword_lower)
|
||||
for col_pos, col_def in enumerate(self._state.columns)],
|
||||
cls="dt2-row",
|
||||
data_row=f"{row_index}",
|
||||
_id=f"tr_{self._id}-{row_index}",
|
||||
) for row_index in df.index[start:end]]
|
||||
|
||||
return rows
|
||||
|
||||
def mk_body(self):
|
||||
return Div(
|
||||
*self.mk_body_content_page(0),
|
||||
cls="dt2-body",
|
||||
id=f"tb_{self._id}",
|
||||
)
|
||||
|
||||
def mk_footers(self):
|
||||
return Div(
|
||||
*[Div(
|
||||
*[self.mk_aggregation_cell(col_def, row_index, footer) for col_def in self._state.columns],
|
||||
id=f"tf_{self._id}",
|
||||
data_row=f"{row_index}",
|
||||
cls="dt2-row dt2-row-footer",
|
||||
) for row_index, footer in enumerate(self._state.footers)],
|
||||
cls="dt2-footer",
|
||||
id=f"tf_{self._id}"
|
||||
)
|
||||
|
||||
def mk_table(self):
|
||||
return Div(
|
||||
self.mk_headers(),
|
||||
self.mk_body(),
|
||||
self.mk_footers()
|
||||
)
|
||||
|
||||
def mk_aggregation_cell(self, col_def, row_index: int, footer_conf, oob=False):
|
||||
"""
|
||||
Generates a footer cell for a data table based on the provided column definition,
|
||||
row index, footer configuration, and optional out-of-bound setting. This method
|
||||
applies appropriate aggregation functions, determines visibility, and structures
|
||||
the cell's elements accordingly.
|
||||
|
||||
:param col_def: Details of the column state, including its usability, visibility,
|
||||
and column ID, which are necessary to determine how the footer
|
||||
cell should be created.
|
||||
:type col_def: DataGridColumnState
|
||||
:param row_index: The specific index of the footer row where this cell will be
|
||||
added. This parameter is used to uniquely identify the cell
|
||||
within the footer.
|
||||
:type row_index: int
|
||||
:param footer_conf: Configuration for the footer that contains mapping of column
|
||||
IDs to their corresponding aggregation functions. This is
|
||||
critical for calculating aggregated values for the cell content.
|
||||
:type footer_conf: DataGridFooterConf
|
||||
:param oob: A boolean flag indicating whether the configuration involves any
|
||||
out-of-bound parameters that must be handled specifically. This
|
||||
parameter is optional and defaults to False.
|
||||
:type oob: bool
|
||||
:return: Returns an instance of `Div`, containing the visually structured footer
|
||||
cell content, including the calculated aggregation if applicable. If
|
||||
the column is not usable, it returns None. For non-visible columns, it
|
||||
returns a hidden cell `Div`. The aggregation value is displayed for valid
|
||||
aggregations. If none is applicable or the configuration is invalid,
|
||||
appropriate default content or styling is applied.
|
||||
:rtype: Div | None
|
||||
"""
|
||||
if not col_def.usable:
|
||||
return None
|
||||
|
||||
if not col_def.visible:
|
||||
return Div(cls="dt2-col-hidden")
|
||||
|
||||
if col_def.col_id in footer_conf.conf:
|
||||
agg_function = footer_conf.conf[col_def.col_id]
|
||||
if agg_function == FooterAggregation.Sum.value:
|
||||
value = self._df[col_def.col_id].sum()
|
||||
elif agg_function == FooterAggregation.Min.value:
|
||||
value = self._df[col_def.col_id].min()
|
||||
elif agg_function == FooterAggregation.Max.value:
|
||||
value = self._df[col_def.col_id].max()
|
||||
elif agg_function == FooterAggregation.Mean.value:
|
||||
value = self._df[col_def.col_id].mean()
|
||||
elif agg_function == FooterAggregation.Count.value:
|
||||
value = self._df[col_def.col_id].count()
|
||||
else:
|
||||
value = "** Invalid aggregation function **"
|
||||
else:
|
||||
value = None
|
||||
|
||||
return Div(mk.label(value, cls="dt2-cell-content-number"),
|
||||
data_col=col_def.col_id,
|
||||
style=f"width:{col_def.width}px;",
|
||||
cls="dt2-cell dt2-footer-cell",
|
||||
id=f"tf_{self._id}-{col_def.col_id}-{row_index}",
|
||||
hx_swap_oob='true' if oob else None,
|
||||
)
|
||||
|
||||
def render(self):
|
||||
if self._state.ne_df is None:
|
||||
return Div("No data to display !")
|
||||
|
||||
return Div(
|
||||
Div(
|
||||
self.mk_table(),
|
||||
# Script(f"bindDatagrid('{self._id}', false);"),
|
||||
),
|
||||
id=self._id
|
||||
self._id
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
|
||||
@@ -1,171 +1,56 @@
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pandas as pd
|
||||
from fasthtml.components import Div
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.DataGrid import DataGrid
|
||||
from myfasthtml.controls.FileUpload import FileUpload
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||
from myfasthtml.controls.TreeView import TreeView
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.instances import MultipleInstance, InstancesManager
|
||||
from myfasthtml.icons.fluent_p1 import table_add20_regular
|
||||
from myfasthtml.icons.fluent_p3 import folder_open20_regular
|
||||
|
||||
|
||||
@dataclass
|
||||
class DocumentDefinition:
|
||||
document_id: str
|
||||
namespace: str
|
||||
name: str
|
||||
type: str # table, card,
|
||||
tab_id: str
|
||||
datagrid_id: str
|
||||
|
||||
|
||||
class DataGridsState(DbObject):
|
||||
def __init__(self, owner, name=None):
|
||||
super().__init__(owner, name=name)
|
||||
with self.initializing():
|
||||
self.elements: list[DocumentDefinition] = []
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def upload_from_source(self):
|
||||
return Command("UploadFromSource",
|
||||
"Upload from source",
|
||||
self._owner,
|
||||
self._owner.upload_from_source).htmx(target=None)
|
||||
return Command("UploadFromSource", "Upload from source", self._owner.upload_from_source)
|
||||
|
||||
def new_grid(self):
|
||||
return Command("NewGrid",
|
||||
"New grid",
|
||||
self._owner,
|
||||
self._owner.new_grid)
|
||||
return Command("NewGrid", "New grid", self._owner.new_grid)
|
||||
|
||||
def open_from_excel(self, tab_id, file_upload):
|
||||
return Command("OpenFromExcel",
|
||||
"Open from Excel",
|
||||
self._owner,
|
||||
self._owner.open_from_excel,
|
||||
args=[tab_id,
|
||||
file_upload]).htmx(target=f"#{self._owner._tree.get_id()}")
|
||||
|
||||
def clear_tree(self):
|
||||
return Command("ClearTree",
|
||||
"Clear tree",
|
||||
self._owner,
|
||||
self._owner.clear_tree).htmx(target=f"#{self._owner._tree.get_id()}")
|
||||
|
||||
def show_document(self):
|
||||
return Command("ShowDocument",
|
||||
"Show document",
|
||||
self._owner,
|
||||
self._owner.select_document,
|
||||
key="SelectNode")
|
||||
def open_from_excel(self, tab_id, get_content_callback):
|
||||
excel_content = get_content_callback()
|
||||
return Command("OpenFromExcel", "Open from Excel", self._owner.open_from_excel, tab_id, excel_content)
|
||||
|
||||
|
||||
class DataGridsManager(MultipleInstance):
|
||||
def __init__(self, parent, _id=None):
|
||||
if not getattr(self, "_is_new_instance", False):
|
||||
# Skip __init__ if instance already existed
|
||||
return
|
||||
super().__init__(parent, _id=_id)
|
||||
self.tree = TreeView(self, _id="-treeview")
|
||||
self.commands = Commands(self)
|
||||
self._state = DataGridsState(self)
|
||||
self._tree = self._mk_tree()
|
||||
self._tree.bind_command("SelectNode", self.commands.show_document())
|
||||
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager)
|
||||
|
||||
def upload_from_source(self):
|
||||
file_upload = FileUpload(self)
|
||||
tab_id = self._tabs_manager.create_tab("Upload Datagrid", file_upload)
|
||||
file_upload.set_on_ok(self.commands.open_from_excel(tab_id, file_upload))
|
||||
from myfasthtml.controls.FileUpload import FileUpload
|
||||
file_upload = FileUpload(self, _id="-file-upload", auto_register=False)
|
||||
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager)
|
||||
tab_id = self._tabs_manager.add_tab("Upload Datagrid", file_upload)
|
||||
file_upload.on_ok = self.commands.open_from_excel(tab_id, file_upload.get_content)
|
||||
return self._tabs_manager.show_tab(tab_id)
|
||||
|
||||
def open_from_excel(self, tab_id, file_upload: FileUpload):
|
||||
excel_content = file_upload.get_content()
|
||||
df = pd.read_excel(excel_content, file_upload.get_sheet_name())
|
||||
dg = DataGrid(self._tabs_manager, save_state=True)
|
||||
dg.init_from_dataframe(df)
|
||||
document = DocumentDefinition(
|
||||
document_id=str(uuid.uuid4()),
|
||||
namespace=file_upload.get_file_basename(),
|
||||
name=file_upload.get_sheet_name(),
|
||||
type="excel",
|
||||
tab_id=tab_id,
|
||||
datagrid_id=dg.get_id()
|
||||
)
|
||||
self._state.elements = self._state.elements + [document] # do not use append() other it won't be saved
|
||||
parent_id = self._tree.ensure_path(document.namespace)
|
||||
tree_node = TreeNode(label=document.name, type="excel", parent=parent_id)
|
||||
self._tree.add_node(tree_node, parent_id=parent_id)
|
||||
return self._mk_tree(), self._tabs_manager.change_tab_content(tab_id, document.name, Panel(self).set_main(dg))
|
||||
|
||||
def select_document(self, node_id):
|
||||
document_id = self._tree.get_bag(node_id)
|
||||
try:
|
||||
document = next(filter(lambda x: x.document_id == document_id, self._state.elements))
|
||||
dg = DataGrid(self._tabs_manager, _id=document.datagrid_id)
|
||||
return self._tabs_manager.show_or_create_tab(document.tab_id, document.name, dg)
|
||||
except StopIteration:
|
||||
# the selected node is not a document (it's a folder)
|
||||
return None
|
||||
|
||||
def create_tab_content(self, tab_id):
|
||||
"""
|
||||
Recreate the content for a tab managed by this DataGridsManager.
|
||||
Called by TabsManager when the content is not in cache (e.g., after restart).
|
||||
|
||||
Args:
|
||||
tab_id: ID of the tab to recreate content for
|
||||
|
||||
Returns:
|
||||
The recreated component (Panel with DataGrid)
|
||||
"""
|
||||
# Find the document associated with this tab
|
||||
document = next((d for d in self._state.elements if d.tab_id == tab_id), None)
|
||||
|
||||
if document is None:
|
||||
raise ValueError(f"No document found for tab {tab_id}")
|
||||
|
||||
# Recreate the DataGrid with its saved state
|
||||
dg = DataGrid(self._tabs_manager, _id=document.datagrid_id)
|
||||
|
||||
# Wrap in Panel
|
||||
return Panel(self).set_main(dg)
|
||||
|
||||
def clear_tree(self):
|
||||
self._state.elements = []
|
||||
self._tree.clear()
|
||||
return self._tree
|
||||
|
||||
def mk_main_icons(self):
|
||||
return Div(
|
||||
mk.icon(folder_open20_regular, tooltip="Upload from source", command=self.commands.upload_from_source()),
|
||||
mk.icon(table_add20_regular, tooltip="New grid", command=self.commands.clear_tree()),
|
||||
cls="flex"
|
||||
)
|
||||
|
||||
def _mk_tree(self):
|
||||
tree = TreeView(self, _id="-treeview")
|
||||
for element in self._state.elements:
|
||||
parent_id = tree.ensure_path(element.namespace)
|
||||
tree.add_node(TreeNode(id=element.document_id,
|
||||
label=element.name,
|
||||
type=element.type,
|
||||
parent=parent_id,
|
||||
bag=element.document_id))
|
||||
return tree
|
||||
def open_from_excel(self, tab_id, excel_content):
|
||||
df = pd.read_excel(excel_content)
|
||||
content = df.to_html(index=False)
|
||||
self._tabs_manager.switch(tab_id, content)
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
self._tree,
|
||||
Div(
|
||||
mk.icon(folder_open20_regular, tooltip="Upload from source", command=self.commands.upload_from_source()),
|
||||
mk.icon(table_add20_regular, tooltip="New grid"),
|
||||
cls="flex"
|
||||
),
|
||||
self.tree,
|
||||
id=self._id,
|
||||
)
|
||||
|
||||
|
||||
@@ -10,16 +10,10 @@ from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def close(self):
|
||||
return Command("Close",
|
||||
"Close Dropdown",
|
||||
self._owner,
|
||||
self._owner.close).htmx(target=f"#{self._owner.get_id()}-content")
|
||||
return Command("Close", "Close Dropdown", self._owner.close).htmx(target=f"#{self._owner.get_id()}-content")
|
||||
|
||||
def click(self):
|
||||
return Command("Click",
|
||||
"Click on Dropdown",
|
||||
self._owner,
|
||||
self._owner.on_click).htmx(target=f"#{self._owner.get_id()}-content")
|
||||
return Command("Click", "Click on Dropdown", self._owner.on_click).htmx(target=f"#{self._owner.get_id()}-content")
|
||||
|
||||
|
||||
class DropdownState:
|
||||
@@ -34,7 +28,6 @@ class Dropdown(MultipleInstance):
|
||||
The dropdown provides functionality to manage its state, including opening, closing, and
|
||||
handling user interactions.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, content=None, button=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.button = Div(button) if not isinstance(button, FT) else button
|
||||
|
||||
@@ -25,25 +25,14 @@ class FileUploadState(DbObject):
|
||||
self.ns_sheets_names: list | None = None
|
||||
self.ns_selected_sheet_name: str | None = None
|
||||
self.ns_file_content: bytes | None = None
|
||||
self.ns_on_ok = None
|
||||
self.ns_on_cancel = None
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def __init__(self, owner):
|
||||
super().__init__(owner)
|
||||
|
||||
def on_file_uploaded(self):
|
||||
return Command("UploadFile",
|
||||
"Upload file",
|
||||
self._owner,
|
||||
self._owner.upload_file).htmx(target=f"#sn_{self._id}")
|
||||
|
||||
def on_sheet_selected(self):
|
||||
return Command("SheetSelected",
|
||||
"Sheet selected",
|
||||
self._owner,
|
||||
self._owner.select_sheet).htmx(target=f"#sn_{self._id}")
|
||||
def upload_file(self):
|
||||
return Command("UploadFile", "Upload file", self._owner.upload_file).htmx(target=f"#sn_{self._id}")
|
||||
|
||||
|
||||
class FileUpload(MultipleInstance):
|
||||
@@ -60,26 +49,16 @@ class FileUpload(MultipleInstance):
|
||||
super().__init__(parent, _id=_id, **kwargs)
|
||||
self.commands = Commands(self)
|
||||
self._state = FileUploadState(self)
|
||||
self._state.ns_on_ok = None
|
||||
|
||||
def set_on_ok(self, callback):
|
||||
self._state.ns_on_ok = callback
|
||||
|
||||
def upload_file(self, file: UploadFile):
|
||||
logger.debug(f"upload_file: {file=}")
|
||||
if file:
|
||||
self._state.ns_file_content = file.file.read()
|
||||
self._state.ns_file_name = file.filename
|
||||
self._state.ns_sheets_names = self.get_sheets_names(self._state.ns_file_content)
|
||||
self._state.ns_selected_sheet_name = self._state.ns_sheets_names[0] if len(self._state.ns_sheets_names) > 0 else 0
|
||||
|
||||
return self.mk_sheet_selector()
|
||||
|
||||
def select_sheet(self, sheet_name: str):
|
||||
logger.debug(f"select_sheet: {sheet_name=}")
|
||||
self._state.ns_selected_sheet_name = sheet_name
|
||||
return self.mk_sheet_selector()
|
||||
|
||||
def mk_sheet_selector(self):
|
||||
options = [Option("Choose a file...", selected=True, disabled=True)] if self._state.ns_sheets_names is None else \
|
||||
[Option(
|
||||
@@ -87,27 +66,16 @@ class FileUpload(MultipleInstance):
|
||||
selected=True if name == self._state.ns_selected_sheet_name else None,
|
||||
) for name in self._state.ns_sheets_names]
|
||||
|
||||
return mk.mk(Select(
|
||||
return Select(
|
||||
*options,
|
||||
name="sheet_name",
|
||||
id=f"sn_{self._id}", # sn stands for 'sheet name'
|
||||
cls="select select-bordered select-sm w-full ml-2"
|
||||
), command=self.commands.on_sheet_selected())
|
||||
)
|
||||
|
||||
def get_content(self):
|
||||
return self._state.ns_file_content
|
||||
|
||||
def get_file_name(self):
|
||||
return self._state.ns_file_name
|
||||
|
||||
def get_file_basename(self):
|
||||
if self._state.ns_file_name is None:
|
||||
return None
|
||||
|
||||
return self._state.ns_file_name.split(".")[0]
|
||||
|
||||
def get_sheet_name(self):
|
||||
return self._state.ns_selected_sheet_name
|
||||
|
||||
@staticmethod
|
||||
def get_sheets_names(file_content):
|
||||
@@ -131,12 +99,12 @@ class FileUpload(MultipleInstance):
|
||||
hx_encoding='multipart/form-data',
|
||||
cls="file-input file-input-bordered file-input-sm w-full",
|
||||
),
|
||||
command=self.commands.on_file_uploaded()
|
||||
command=self.commands.upload_file()
|
||||
),
|
||||
self.mk_sheet_selector(),
|
||||
cls="flex"
|
||||
),
|
||||
mk.dialog_buttons(on_ok=self._state.ns_on_ok, on_cancel=self._state.ns_on_cancel),
|
||||
mk.dialog_buttons(),
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
|
||||
@@ -12,8 +12,7 @@ class InstancesDebugger(SingleInstance):
|
||||
self._panel = Panel(self, _id="-panel")
|
||||
self._command = Command("ShowInstance",
|
||||
"Display selected Instance",
|
||||
self,
|
||||
self.on_network_event).htmx(target=f"#{self._panel.get_ids().right}")
|
||||
self.on_network_event).htmx(target=f"#{self._panel.get_id()}_r")
|
||||
|
||||
def render(self):
|
||||
nodes, edges = self._get_nodes_and_edges()
|
||||
@@ -37,7 +36,7 @@ class InstancesDebugger(SingleInstance):
|
||||
instances,
|
||||
id_getter=lambda x: x.get_full_id(),
|
||||
label_getter=lambda x: f"{x.get_id()}",
|
||||
parent_getter=lambda x: x.get_parent_full_id()
|
||||
parent_getter=lambda x: x.get_full_parent_id()
|
||||
)
|
||||
for edge in edges:
|
||||
edge["color"] = "green"
|
||||
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.commands import BaseCommand
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class Keyboard(MultipleInstance):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.combinations = combinations or {}
|
||||
|
||||
def add(self, sequence: str, command: Command):
|
||||
def add(self, sequence: str, command: BaseCommand):
|
||||
self.combinations[sequence] = command
|
||||
return self
|
||||
|
||||
|
||||
@@ -37,11 +37,7 @@ class LayoutState(DbObject):
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def toggle_drawer(self, side: Literal["left", "right"]):
|
||||
return Command("ToggleDrawer",
|
||||
f"Toggle {side} layout drawer",
|
||||
self._owner,
|
||||
self._owner.toggle_drawer,
|
||||
args=[side])
|
||||
return Command("ToggleDrawer", f"Toggle {side} layout drawer", self._owner.toggle_drawer, side)
|
||||
|
||||
def update_drawer_width(self, side: Literal["left", "right"], width: int = None):
|
||||
"""
|
||||
@@ -54,11 +50,12 @@ class Commands(BaseCommands):
|
||||
Returns:
|
||||
Command: Command object for updating drawer width
|
||||
"""
|
||||
return Command(f"UpdateDrawerWidth_{side}",
|
||||
f"Update {side} drawer width",
|
||||
self._owner,
|
||||
self._owner.update_drawer_width,
|
||||
args=[side])
|
||||
return Command(
|
||||
f"UpdateDrawerWidth_{side}",
|
||||
f"Update {side} drawer width",
|
||||
self._owner.update_drawer_width,
|
||||
side
|
||||
)
|
||||
|
||||
|
||||
class Layout(SingleInstance):
|
||||
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.commands import BaseCommand
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class Mouse(MultipleInstance):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.combinations = combinations or {}
|
||||
|
||||
def add(self, sequence: str, command: Command):
|
||||
def add(self, sequence: str, command: BaseCommand):
|
||||
self.combinations[sequence] = command
|
||||
return self
|
||||
|
||||
|
||||
@@ -5,37 +5,8 @@ from fasthtml.components import Div
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
from myfasthtml.icons.fluent_p1 import more_horizontal20_regular
|
||||
from myfasthtml.icons.fluent_p2 import subtract20_regular
|
||||
|
||||
|
||||
class PanelIds:
|
||||
def __init__(self, owner):
|
||||
self._owner = owner
|
||||
|
||||
@property
|
||||
def main(self):
|
||||
return f"{self._owner.get_id()}_m"
|
||||
|
||||
@property
|
||||
def right(self):
|
||||
""" Right panel's content"""
|
||||
return f"{self._owner.get_id()}_cr"
|
||||
|
||||
@property
|
||||
def left(self):
|
||||
""" Left panel's content"""
|
||||
return f"{self._owner.get_id()}_cl"
|
||||
|
||||
def panel(self, side: Literal["left", "right"]):
|
||||
return f"{self._owner.get_id()}_pl" if side == "left" else f"{self._owner.get_id()}_pr"
|
||||
|
||||
def content(self, side: Literal["left", "right"]):
|
||||
return self.left if side == "left" else self.right
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -44,23 +15,9 @@ class PanelConf:
|
||||
right: bool = True
|
||||
|
||||
|
||||
class PanelState(DbObject):
|
||||
def __init__(self, owner, name=None):
|
||||
super().__init__(owner, name=name)
|
||||
with self.initializing():
|
||||
self.left_visible: bool = True
|
||||
self.right_visible: bool = True
|
||||
self.left_width: int = 250
|
||||
self.right_width: int = 250
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def toggle_side(self, side: Literal["left", "right"], visible: bool = None):
|
||||
return Command("TogglePanelSide",
|
||||
f"Toggle {side} side panel",
|
||||
self._owner,
|
||||
self._owner.toggle_side,
|
||||
args=[side, visible]).htmx(target=f"#{self._owner.get_ids().panel(side)}")
|
||||
def toggle_side(self, side: Literal["left", "right"]):
|
||||
return Command("TogglePanelSide", f"Toggle {side} side panel", self._owner.toggle_side, side)
|
||||
|
||||
def update_side_width(self, side: Literal["left", "right"]):
|
||||
"""
|
||||
@@ -72,11 +29,12 @@ class Commands(BaseCommands):
|
||||
Returns:
|
||||
Command: Command object for updating panel's side width
|
||||
"""
|
||||
return Command(f"UpdatePanelSideWidth_{side}",
|
||||
f"Update {side} side panel width",
|
||||
self._owner,
|
||||
self._owner.update_side_width,
|
||||
args=[side]).htmx(target=f"#{self._owner.get_ids().panel(side)}")
|
||||
return Command(
|
||||
f"UpdatePanelSideWidth_{side}",
|
||||
f"Update {side} side panel width",
|
||||
self._owner.update_side_width,
|
||||
side
|
||||
)
|
||||
|
||||
|
||||
class Panel(MultipleInstance):
|
||||
@@ -93,30 +51,15 @@ class Panel(MultipleInstance):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.conf = conf or PanelConf()
|
||||
self.commands = Commands(self)
|
||||
self._state = PanelState(self)
|
||||
self._main = None
|
||||
self._right = None
|
||||
self._left = None
|
||||
self._ids = PanelIds(self)
|
||||
|
||||
def get_ids(self):
|
||||
return self._ids
|
||||
|
||||
def update_side_width(self, side, width):
|
||||
if side == "left":
|
||||
self._state.left_width = width
|
||||
else:
|
||||
self._state.right_width = width
|
||||
|
||||
return self._mk_panel(side)
|
||||
pass
|
||||
|
||||
def toggle_side(self, side, visible):
|
||||
if side == "left":
|
||||
self._state.left_visible = visible
|
||||
else:
|
||||
self._state.right_visible = visible
|
||||
|
||||
return self._mk_panel(side), self._mk_show_icon(side)
|
||||
def toggle_side(self, side):
|
||||
pass
|
||||
|
||||
def set_main(self, main):
|
||||
self._main = main
|
||||
@@ -124,93 +67,41 @@ class Panel(MultipleInstance):
|
||||
|
||||
def set_right(self, right):
|
||||
self._right = right
|
||||
return Div(self._right, id=self._ids.right)
|
||||
return Div(self._right, id=f"{self._id}_r")
|
||||
|
||||
def set_left(self, left):
|
||||
self._left = left
|
||||
return Div(self._left, id=self._ids.left)
|
||||
return Div(self._left, id=f"{self._id}_l")
|
||||
|
||||
def _mk_panel(self, side: Literal["left", "right"]):
|
||||
enabled = self.conf.left if side == "left" else self.conf.right
|
||||
if not enabled:
|
||||
def _mk_right(self):
|
||||
if not self.conf.right:
|
||||
return None
|
||||
|
||||
visible = self._state.left_visible if side == "left" else self._state.right_visible
|
||||
content = self._right if side == "right" else self._left
|
||||
|
||||
resizer = Div(
|
||||
cls=f"mf-resizer mf-resizer-{side}",
|
||||
data_command_id=self.commands.update_side_width(side).id,
|
||||
data_side=side
|
||||
cls="mf-resizer mf-resizer-right",
|
||||
data_command_id=self.commands.update_side_width("right").id,
|
||||
data_side="right"
|
||||
)
|
||||
|
||||
hide_icon = mk.icon(
|
||||
subtract20_regular,
|
||||
size=20,
|
||||
command=self.commands.toggle_side(side, False),
|
||||
cls="mf-panel-hide-icon"
|
||||
)
|
||||
|
||||
panel_cls = f"mf-panel-{side}"
|
||||
if not visible:
|
||||
panel_cls += " mf-hidden"
|
||||
|
||||
# Left panel: content then resizer (resizer on the right)
|
||||
# Right panel: resizer then content (resizer on the left)
|
||||
if side == "left":
|
||||
return Div(
|
||||
hide_icon,
|
||||
Div(content, id=self._ids.content(side)),
|
||||
resizer,
|
||||
cls=panel_cls,
|
||||
id=self._ids.panel(side)
|
||||
)
|
||||
else:
|
||||
return Div(
|
||||
resizer,
|
||||
hide_icon,
|
||||
Div(content, id=self._ids.content(side)),
|
||||
cls=panel_cls,
|
||||
id=self._ids.panel(side)
|
||||
)
|
||||
return Div(resizer, Div(self._right, id=f"{self._id}_r"), cls="mf-panel-right")
|
||||
|
||||
def _mk_main(self):
|
||||
return Div(
|
||||
self._mk_show_icon("left"),
|
||||
Div(self._main, id=self._ids.main, cls="mf-panel-main"),
|
||||
self._mk_show_icon("right"),
|
||||
cls="mf-panel-main"
|
||||
),
|
||||
|
||||
def _mk_show_icon(self, side: Literal["left", "right"]):
|
||||
"""
|
||||
Create show icon for a panel side if it's hidden.
|
||||
|
||||
Args:
|
||||
side: Which panel side ("left" or "right")
|
||||
|
||||
Returns:
|
||||
Div with icon if panel is hidden, None otherwise
|
||||
"""
|
||||
enabled = self.conf.left if side == "left" else self.conf.right
|
||||
if not enabled:
|
||||
def _mk_left(self):
|
||||
if not self.conf.left:
|
||||
return None
|
||||
|
||||
is_visible = self._state.left_visible if side == "left" else self._state.right_visible
|
||||
icon_cls = "hidden" if is_visible else f"mf-panel-show-icon mf-panel-show-icon-{side}"
|
||||
|
||||
return mk.icon(
|
||||
more_horizontal20_regular,
|
||||
command=self.commands.toggle_side(side, True),
|
||||
cls=icon_cls,
|
||||
id=f"{self._id}_show_{side}"
|
||||
resizer = Div(
|
||||
cls="mf-resizer mf-resizer-left",
|
||||
data_command_id=self.commands.update_side_width("left").id,
|
||||
data_side="left"
|
||||
)
|
||||
|
||||
return Div(Div(self._left, id=f"{self._id}_l"), resizer, cls="mf-panel-left")
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
self._mk_panel("left"),
|
||||
self._mk_main(),
|
||||
self._mk_panel("right"),
|
||||
self._mk_left(),
|
||||
Div(self._main, cls="mf-panel-main"),
|
||||
self._mk_right(),
|
||||
Script(f"initResizer('{self._id}');"),
|
||||
cls="mf-panel",
|
||||
id=self._id,
|
||||
|
||||
@@ -16,38 +16,21 @@ class Properties(MultipleInstance):
|
||||
self.groups = groups
|
||||
self.properties_by_group = self._create_properties_by_group()
|
||||
|
||||
def _mk_group_content(self, properties: dict):
|
||||
return Div(
|
||||
*[
|
||||
Div(
|
||||
Div(k, cls="mf-properties-key", data_tooltip=f"{k}"),
|
||||
self._mk_property_value(v),
|
||||
cls="mf-properties-row"
|
||||
)
|
||||
for k, v in properties.items()
|
||||
],
|
||||
cls="mf-properties-group-content"
|
||||
)
|
||||
|
||||
def _mk_property_value(self, value):
|
||||
if isinstance(value, dict):
|
||||
return self._mk_group_content(value)
|
||||
|
||||
if isinstance(value, (list, tuple)):
|
||||
return self._mk_group_content({i: item for i, item in enumerate(value)})
|
||||
|
||||
return Div(str(value),
|
||||
cls="mf-properties-value",
|
||||
title=str(value))
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
*[
|
||||
Div(
|
||||
Div(group_name if group_name is not None else "", cls="mf-properties-group-header"),
|
||||
Div(
|
||||
Div(group_name if group_name is not None else "", cls="mf-properties-group-header"),
|
||||
self._mk_group_content(proxy.as_dict()),
|
||||
cls="mf-properties-group-container"
|
||||
*[
|
||||
Div(
|
||||
Div(k, cls="mf-properties-key"),
|
||||
Div(str(v), cls="mf-properties-value", title=str(v)),
|
||||
cls="mf-properties-row"
|
||||
)
|
||||
for k, v in proxy.as_dict().items()
|
||||
],
|
||||
cls="mf-properties-group-content"
|
||||
),
|
||||
cls="mf-properties-group-card"
|
||||
)
|
||||
|
||||
@@ -14,12 +14,10 @@ logger = logging.getLogger("Search")
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def search(self):
|
||||
return (Command("Search",
|
||||
f"Search {self._owner.items_names}",
|
||||
self._owner,
|
||||
self._owner.on_search).htmx(target=f"#{self._owner.get_id()}-results",
|
||||
trigger="keyup changed delay:300ms",
|
||||
swap="innerHTML"))
|
||||
return (Command("Search", f"Search {self._owner.items_names}", self._owner.on_search).
|
||||
htmx(target=f"#{self._owner.get_id()}-results",
|
||||
trigger="keyup changed delay:300ms",
|
||||
swap="innerHTML"))
|
||||
|
||||
|
||||
class Search(MultipleInstance):
|
||||
@@ -38,7 +36,6 @@ class Search(MultipleInstance):
|
||||
:ivar template: Callable function to define how filtered items are rendered.
|
||||
:type template: Callable[[Any], Any]
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
parent: BaseInstance,
|
||||
_id=None,
|
||||
@@ -72,12 +69,6 @@ class Search(MultipleInstance):
|
||||
self.filtered = self.items.copy()
|
||||
return self
|
||||
|
||||
def get_items(self):
|
||||
return self.items
|
||||
|
||||
def get_filtered(self):
|
||||
return self.filtered
|
||||
|
||||
def on_search(self, query):
|
||||
logger.debug(f"on_search {query=}")
|
||||
self.search(query)
|
||||
|
||||
@@ -52,47 +52,32 @@ class TabsManagerState(DbObject):
|
||||
self.active_tab: str | None = None
|
||||
|
||||
# must not be persisted in DB
|
||||
self.ns_tabs_content: dict[str, Any] = {} # Cache: always stores raw content (not wrapped)
|
||||
self.ns_tabs_sent_to_client: set = set() # for tabs created, but not yet displayed
|
||||
self._tabs_content: dict[str, Any] = {}
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def show_tab(self, tab_id):
|
||||
return Command(f"ShowTab",
|
||||
return Command(f"{self._prefix}ShowTab",
|
||||
"Activate or show a specific tab",
|
||||
self._owner,
|
||||
self._owner.show_tab,
|
||||
args=[tab_id,
|
||||
True,
|
||||
False],
|
||||
key=f"{self._owner.get_full_id()}-ShowTab-{tab_id}",
|
||||
).htmx(target=f"#{self._id}-controller", swap="outerHTML")
|
||||
self._owner.show_tab, tab_id).htmx(target=f"#{self._id}-controller", swap="outerHTML")
|
||||
|
||||
def close_tab(self, tab_id):
|
||||
return Command(f"CloseTab",
|
||||
return Command(f"{self._prefix}CloseTab",
|
||||
"Close a specific tab",
|
||||
self._owner,
|
||||
self._owner.close_tab,
|
||||
kwargs={"tab_id": tab_id},
|
||||
).htmx(target=f"#{self._id}-controller", swap="outerHTML")
|
||||
self._owner.close_tab, tab_id).htmx(target=f"#{self._id}", swap="outerHTML")
|
||||
|
||||
def add_tab(self, label: str, component: Any, auto_increment=False):
|
||||
return Command(f"AddTab",
|
||||
"Add a new tab",
|
||||
self._owner,
|
||||
self._owner.on_new_tab,
|
||||
args=[label,
|
||||
component,
|
||||
auto_increment],
|
||||
key="#{id-name-args}",
|
||||
).htmx(target=f"#{self._id}-controller", swap="outerHTML")
|
||||
return (Command(f"{self._prefix}AddTab",
|
||||
"Add a new tab",
|
||||
self._owner.on_new_tab, label, component, auto_increment).
|
||||
htmx(target=f"#{self._id}-controller"))
|
||||
|
||||
|
||||
class TabsManager(MultipleInstance):
|
||||
_tab_count = 0
|
||||
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self._tab_count = 0
|
||||
self._state = TabsManagerState(self)
|
||||
self.commands = Commands(self)
|
||||
self._boundaries = Boundaries()
|
||||
@@ -101,7 +86,6 @@ class TabsManager(MultipleInstance):
|
||||
get_attr=lambda x: x["label"],
|
||||
template=self._mk_tab_button,
|
||||
_id="-search")
|
||||
|
||||
logger.debug(f"TabsManager created with id: {self._id}")
|
||||
logger.debug(f" tabs : {self._get_ordered_tabs()}")
|
||||
logger.debug(f" active tab : {self._state.active_tab}")
|
||||
@@ -112,64 +96,22 @@ class TabsManager(MultipleInstance):
|
||||
def _get_ordered_tabs(self):
|
||||
return {tab_id: self._state.tabs.get(tab_id, None) for tab_id in self._state.tabs_order}
|
||||
|
||||
def _dynamic_get_content(self, tab_id):
|
||||
def _get_tab_content(self, tab_id):
|
||||
if tab_id not in self._state.tabs:
|
||||
return Div("Tab not found.")
|
||||
|
||||
return None
|
||||
tab_config = self._state.tabs[tab_id]
|
||||
if tab_config["component"] is None:
|
||||
return Div("Tab content does not support serialization.")
|
||||
|
||||
# 1. Try to get existing component instance
|
||||
res = InstancesManager.get(self._session, tab_config["component"][1], None)
|
||||
if res is not None:
|
||||
logger.debug(f"Component {tab_config['component'][1]} already exists")
|
||||
return res
|
||||
|
||||
# 2. Get or create parent
|
||||
if tab_config["component_parent"] is None:
|
||||
logger.error(f"No parent defined for tab {tab_id}")
|
||||
return Div("Failed to retrieve tab content.")
|
||||
|
||||
parent = InstancesManager.get(self._session, tab_config["component_parent"][1], None)
|
||||
if parent is None:
|
||||
logger.error(f"Parent {tab_config['component_parent'][1]} not found for tab {tab_id}")
|
||||
return Div("Parent component not available")
|
||||
|
||||
# 3. If parent supports create_tab_content, use it
|
||||
if hasattr(parent, 'create_tab_content'):
|
||||
try:
|
||||
logger.debug(f"Asking parent {tab_config['component_parent'][1]} to create tab content for {tab_id}")
|
||||
content = parent.create_tab_content(tab_id)
|
||||
# Store in cache
|
||||
self._state.ns_tabs_content[tab_id] = content
|
||||
return content
|
||||
except Exception as e:
|
||||
logger.error(f"Error while parent creating tab content: {e}")
|
||||
return Div("Failed to retrieve tab content.")
|
||||
else:
|
||||
# Parent doesn't support create_tab_content, fallback to error
|
||||
logger.error(f"Parent {tab_config['component_parent'][1]} doesn't support create_tab_content")
|
||||
return Div("Failed to retrieve tab content.")
|
||||
if tab_config["component_type"] is None:
|
||||
return None
|
||||
try:
|
||||
return InstancesManager.get(self._session, tab_config["component_id"])
|
||||
except Exception as e:
|
||||
logger.error(f"Error while retrieving tab content: {e}")
|
||||
return Div("Tab not found.")
|
||||
|
||||
def _get_or_create_tab_content(self, tab_id):
|
||||
"""
|
||||
Get tab content from cache or create it.
|
||||
This method ensures content is always stored in raw form (not wrapped).
|
||||
|
||||
Args:
|
||||
tab_id: ID of the tab
|
||||
|
||||
Returns:
|
||||
Raw content component (not wrapped in Div)
|
||||
"""
|
||||
if tab_id not in self._state.ns_tabs_content:
|
||||
self._state.ns_tabs_content[tab_id] = self._dynamic_get_content(tab_id)
|
||||
return self._state.ns_tabs_content[tab_id]
|
||||
|
||||
def _get_tab_count(self):
|
||||
res = self._tab_count
|
||||
self._tab_count += 1
|
||||
@staticmethod
|
||||
def _get_tab_count():
|
||||
res = TabsManager._tab_count
|
||||
TabsManager._tab_count += 1
|
||||
return res
|
||||
|
||||
def on_new_tab(self, label: str, component: Any, auto_increment=False):
|
||||
@@ -178,20 +120,20 @@ class TabsManager(MultipleInstance):
|
||||
label = f"{label}_{self._get_tab_count()}"
|
||||
component = component or VisNetwork(self, nodes=vis_nodes, edges=vis_edges)
|
||||
|
||||
tab_id = self.create_tab(label, component)
|
||||
return self.show_tab(tab_id, oob=False)
|
||||
|
||||
def show_or_create_tab(self, tab_id, label, component, activate=True):
|
||||
logger.debug(f"show_or_create_tab {tab_id=}, {label=}, {component=}, {activate=}")
|
||||
if tab_id not in self._state.tabs:
|
||||
self._add_or_update_tab(tab_id, label, component, activate)
|
||||
tab_id = self._tab_already_exists(label, component)
|
||||
if tab_id:
|
||||
return self.show_tab(tab_id)
|
||||
|
||||
return self.show_tab(tab_id, activate=activate, oob=True)
|
||||
tab_id = self.add_tab(label, component)
|
||||
return (
|
||||
self._mk_tabs_controller(),
|
||||
self._wrap_tab_content(self._mk_tab_content(tab_id, component)),
|
||||
self._mk_tabs_header_wrapper(True),
|
||||
)
|
||||
|
||||
def create_tab(self, label: str, component: Any, activate: bool = True) -> str:
|
||||
def add_tab(self, label: str, component: Any, activate: bool = True) -> str:
|
||||
"""
|
||||
Add a new tab or update an existing one with the same component type, ID and label.
|
||||
The tab is not yet sent to the client.
|
||||
|
||||
Args:
|
||||
label: Display label for the tab
|
||||
@@ -202,55 +144,73 @@ class TabsManager(MultipleInstance):
|
||||
tab_id: The UUID of the tab (new or existing)
|
||||
"""
|
||||
logger.debug(f"add_tab {label=}, component={component}, activate={activate}")
|
||||
# copy the state to avoid multiple database call
|
||||
state = self._state.copy()
|
||||
|
||||
# Extract component ID if the component has a get_id() method
|
||||
component_type, component_id = None, None
|
||||
if isinstance(component, BaseInstance):
|
||||
component_type = component.get_prefix() if isinstance(component, BaseInstance) else type(component).__name__
|
||||
component_id = component.get_id()
|
||||
|
||||
# Check if a tab with the same component_type, component_id AND label already exists
|
||||
existing_tab_id = self._tab_already_exists(label, component)
|
||||
|
||||
if existing_tab_id:
|
||||
# Update existing tab (only the component instance in memory)
|
||||
tab_id = existing_tab_id
|
||||
state._tabs_content[tab_id] = component
|
||||
else:
|
||||
# Create new tab
|
||||
tab_id = str(uuid.uuid4())
|
||||
|
||||
# Add tab metadata to state
|
||||
state.tabs[tab_id] = {
|
||||
'id': tab_id,
|
||||
'label': label,
|
||||
'component_type': component_type,
|
||||
'component_id': component_id
|
||||
}
|
||||
|
||||
# Add tab to order
|
||||
state.tabs_order.append(tab_id)
|
||||
|
||||
# Store component in memory
|
||||
state._tabs_content[tab_id] = component
|
||||
|
||||
# Activate tab if requested
|
||||
if activate:
|
||||
state.active_tab = tab_id
|
||||
|
||||
# finally, update the state
|
||||
self._state.update(state)
|
||||
self._search.set_items(self._get_tab_list())
|
||||
|
||||
tab_id = self._tab_already_exists(label, component) or str(uuid.uuid4())
|
||||
self._add_or_update_tab(tab_id, label, component, activate)
|
||||
return tab_id
|
||||
|
||||
def show_tab(self, tab_id, activate: bool = True, oob=True, is_new=True):
|
||||
"""
|
||||
Send the tab to the client if needed.
|
||||
If the tab was already sent, just update the active tab.
|
||||
:param tab_id:
|
||||
:param activate:
|
||||
:param oob: default=True so other control will not care of the target
|
||||
:param is_new: is it a new tab or an existing one?
|
||||
:return:
|
||||
"""
|
||||
def show_tab(self, tab_id):
|
||||
logger.debug(f"show_tab {tab_id=}")
|
||||
if tab_id not in self._state.tabs:
|
||||
logger.debug(f" Tab not found.")
|
||||
return None
|
||||
|
||||
logger.debug(f" Tab label is: {self._state.tabs[tab_id]['label']}")
|
||||
self._state.active_tab = tab_id
|
||||
|
||||
if activate:
|
||||
self._state.active_tab = tab_id
|
||||
|
||||
# Get or create content (always stored in raw form)
|
||||
content = self._get_or_create_tab_content(tab_id)
|
||||
|
||||
if tab_id not in self._state.ns_tabs_sent_to_client:
|
||||
logger.debug(f" Content not in client memory. Sending it.")
|
||||
self._state.ns_tabs_sent_to_client.add(tab_id)
|
||||
if tab_id not in self._state._tabs_content:
|
||||
logger.debug(f" Content does not exist. Creating it.")
|
||||
content = self._get_tab_content(tab_id)
|
||||
tab_content = self._mk_tab_content(tab_id, content)
|
||||
return (self._mk_tabs_controller(oob),
|
||||
self._mk_tabs_header_wrapper(oob),
|
||||
self._wrap_tab_content(tab_content, is_new))
|
||||
self._state._tabs_content[tab_id] = tab_content
|
||||
return self._mk_tabs_controller(), self._wrap_tab_content(tab_content)
|
||||
else:
|
||||
logger.debug(f" Content already in client memory. Just switch.")
|
||||
return self._mk_tabs_controller(oob) # no new tab_id => header is already up to date
|
||||
logger.debug(f" Content already exists. Just switch.")
|
||||
return self._mk_tabs_controller()
|
||||
|
||||
def change_tab_content(self, tab_id, label, component, activate=True):
|
||||
def switch_tab(self, tab_id, label, component, activate=True):
|
||||
logger.debug(f"switch_tab {label=}, component={component}, activate={activate}")
|
||||
|
||||
if tab_id not in self._state.tabs:
|
||||
logger.error(f" Tab {tab_id} not found. Cannot change its content.")
|
||||
return None
|
||||
|
||||
self._add_or_update_tab(tab_id, label, component, activate)
|
||||
self._state.ns_tabs_sent_to_client.discard(tab_id) # to make sure that the new content will be sent to the client
|
||||
return self.show_tab(tab_id, activate=activate, oob=True, is_new=False)
|
||||
return self.show_tab(tab_id) #
|
||||
|
||||
def close_tab(self, tab_id: str):
|
||||
"""
|
||||
@@ -260,12 +220,10 @@ class TabsManager(MultipleInstance):
|
||||
tab_id: ID of the tab to close
|
||||
|
||||
Returns:
|
||||
tuple: (controller, header_wrapper, content_to_remove) for HTMX swapping,
|
||||
or self if tab not found
|
||||
Self for chaining
|
||||
"""
|
||||
logger.debug(f"close_tab {tab_id=}")
|
||||
if tab_id not in self._state.tabs:
|
||||
logger.debug(f" Tab not found.")
|
||||
return self
|
||||
|
||||
# Copy state
|
||||
@@ -276,12 +234,8 @@ class TabsManager(MultipleInstance):
|
||||
state.tabs_order.remove(tab_id)
|
||||
|
||||
# Remove from content
|
||||
if tab_id in state.ns_tabs_content:
|
||||
del state.ns_tabs_content[tab_id]
|
||||
|
||||
# Remove from content sent
|
||||
if tab_id in state.ns_tabs_sent_to_client:
|
||||
state.ns_tabs_sent_to_client.remove(tab_id)
|
||||
if tab_id in state._tabs_content:
|
||||
del state._tabs_content[tab_id]
|
||||
|
||||
# If closing active tab, activate another one
|
||||
if state.active_tab == tab_id:
|
||||
@@ -295,8 +249,7 @@ class TabsManager(MultipleInstance):
|
||||
self._state.update(state)
|
||||
self._search.set_items(self._get_tab_list())
|
||||
|
||||
content_to_remove = Div(id=f"{self._id}-{tab_id}-content", hx_swap_oob=f"delete")
|
||||
return self._mk_tabs_controller(), self._mk_tabs_header_wrapper(), content_to_remove
|
||||
return self
|
||||
|
||||
def add_tab_btn(self):
|
||||
return mk.icon(tab_add24_regular,
|
||||
@@ -306,12 +259,11 @@ class TabsManager(MultipleInstance):
|
||||
None,
|
||||
True))
|
||||
|
||||
def _mk_tabs_controller(self, oob=False):
|
||||
return Div(id=f"{self._id}-controller",
|
||||
data_active_tab=f"{self._state.active_tab}",
|
||||
hx_on__after_settle=f'updateTabs("{self._id}-controller");',
|
||||
hx_swap_oob="true" if oob else None,
|
||||
)
|
||||
def _mk_tabs_controller(self):
|
||||
return Div(
|
||||
Div(id=f"{self._id}-controller", data_active_tab=f"{self._state.active_tab}"),
|
||||
Script(f'updateTabs("{self._id}-controller");'),
|
||||
)
|
||||
|
||||
def _mk_tabs_header_wrapper(self, oob=False):
|
||||
# Create visible tab buttons
|
||||
@@ -321,20 +273,24 @@ class TabsManager(MultipleInstance):
|
||||
if tab_id in self._state.tabs
|
||||
]
|
||||
|
||||
header_content = [*visible_tab_buttons]
|
||||
|
||||
return Div(
|
||||
Div(*visible_tab_buttons, cls="mf-tabs-header", id=f"{self._id}-header"),
|
||||
Div(*header_content, cls="mf-tabs-header", id=f"{self._id}-header"),
|
||||
self._mk_show_tabs_menu(),
|
||||
id=f"{self._id}-header-wrapper",
|
||||
cls="mf-tabs-header-wrapper",
|
||||
hx_swap_oob="true" if oob else None
|
||||
)
|
||||
|
||||
def _mk_tab_button(self, tab_data: dict):
|
||||
def _mk_tab_button(self, tab_data: dict, in_dropdown: bool = False):
|
||||
"""
|
||||
Create a single tab button with its label and close button.
|
||||
|
||||
Args:
|
||||
tab_id: Unique identifier for the tab
|
||||
tab_data: Dictionary containing tab information (label, component_type, etc.)
|
||||
in_dropdown: Whether this tab is rendered in the dropdown menu
|
||||
|
||||
Returns:
|
||||
Button element representing the tab
|
||||
@@ -352,10 +308,12 @@ class TabsManager(MultipleInstance):
|
||||
command=self.commands.show_tab(tab_id)
|
||||
)
|
||||
|
||||
extra_cls = "mf-tab-in-dropdown" if in_dropdown else ""
|
||||
|
||||
return Div(
|
||||
tab_label,
|
||||
close_btn,
|
||||
cls=f"mf-tab-button {'mf-tab-active' if is_active else ''}",
|
||||
cls=f"mf-tab-button {extra_cls} {'mf-tab-active' if is_active else ''}",
|
||||
data_tab_id=tab_id,
|
||||
data_manager_id=self._id
|
||||
)
|
||||
@@ -367,9 +325,15 @@ class TabsManager(MultipleInstance):
|
||||
Returns:
|
||||
Div element containing the active tab content or empty container
|
||||
"""
|
||||
|
||||
if self._state.active_tab:
|
||||
content = self._get_or_create_tab_content(self._state.active_tab)
|
||||
tab_content = self._mk_tab_content(self._state.active_tab, content)
|
||||
active_tab = self._state.active_tab
|
||||
if active_tab in self._state._tabs_content:
|
||||
tab_content = self._state._tabs_content[active_tab]
|
||||
else:
|
||||
content = self._get_tab_content(active_tab)
|
||||
tab_content = self._mk_tab_content(active_tab, content)
|
||||
self._state._tabs_content[active_tab] = tab_content
|
||||
else:
|
||||
tab_content = self._mk_tab_content(None, None)
|
||||
|
||||
@@ -380,13 +344,10 @@ class TabsManager(MultipleInstance):
|
||||
)
|
||||
|
||||
def _mk_tab_content(self, tab_id: str, content):
|
||||
if tab_id is None:
|
||||
return Div("No Content", cls="mf-empty-content mf-tab-content hidden")
|
||||
|
||||
is_active = tab_id == self._state.active_tab
|
||||
return Div(
|
||||
content if content else Div("No Content", cls="mf-empty-content"),
|
||||
cls=f"mf-tab-content {'hidden' if not is_active else ''}",
|
||||
cls=f"mf-tab-content {'hidden' if not is_active else ''}", # ← ici
|
||||
id=f"{self._id}-{tab_id}-content",
|
||||
)
|
||||
|
||||
@@ -405,26 +366,23 @@ class TabsManager(MultipleInstance):
|
||||
cls="dropdown dropdown-end"
|
||||
)
|
||||
|
||||
def _wrap_tab_content(self, tab_content, is_new=True):
|
||||
if is_new:
|
||||
return Div(
|
||||
tab_content,
|
||||
hx_swap_oob=f"beforeend:#{self._id}-content-wrapper"
|
||||
)
|
||||
else:
|
||||
tab_content.attrs["hx-swap-oob"] = "outerHTML"
|
||||
return tab_content
|
||||
def _wrap_tab_content(self, tab_content):
|
||||
return Div(
|
||||
tab_content,
|
||||
hx_swap_oob=f"beforeend:#{self._id}-content-wrapper",
|
||||
)
|
||||
|
||||
def _tab_already_exists(self, label, component):
|
||||
if not isinstance(component, BaseInstance):
|
||||
return None
|
||||
|
||||
component_type = component.get_prefix()
|
||||
component_type = component.get_prefix() if isinstance(component, BaseInstance) else type(component).__name__
|
||||
component_id = component.get_id()
|
||||
|
||||
if component_id is not None:
|
||||
for tab_id, tab_data in self._state.tabs.items():
|
||||
if (tab_data.get('component') == (component_type, component_id) and
|
||||
if (tab_data.get('component_type') == component_type and
|
||||
tab_data.get('component_id') == component_id and
|
||||
tab_data.get('label') == label):
|
||||
return tab_id
|
||||
|
||||
@@ -438,29 +396,20 @@ class TabsManager(MultipleInstance):
|
||||
|
||||
# Extract component ID if the component has a get_id() method
|
||||
component_type, component_id = None, None
|
||||
parent_type, parent_id = None, None
|
||||
if isinstance(component, BaseInstance):
|
||||
component_type = component.get_prefix()
|
||||
component_type = component.get_prefix() if isinstance(component, BaseInstance) else type(component).__name__
|
||||
component_id = component.get_id()
|
||||
parent = component.get_parent()
|
||||
if parent:
|
||||
parent_type = parent.get_prefix()
|
||||
parent_id = parent.get_id()
|
||||
|
||||
# Add tab metadata to state
|
||||
state.tabs[tab_id] = {
|
||||
'id': tab_id,
|
||||
'label': label,
|
||||
'component': (component_type, component_id) if component_type else None,
|
||||
'component_parent': (parent_type, parent_id) if parent_type else None
|
||||
'component_type': component_type,
|
||||
'component_id': component_id
|
||||
}
|
||||
|
||||
# Add tab to order list
|
||||
if tab_id not in state.tabs_order:
|
||||
state.tabs_order.append(tab_id)
|
||||
|
||||
# Add the content
|
||||
state.ns_tabs_content[tab_id] = component
|
||||
state._tabs_content[tab_id] = component
|
||||
|
||||
# Activate tab if requested
|
||||
if activate:
|
||||
@@ -484,7 +433,6 @@ class TabsManager(MultipleInstance):
|
||||
self._mk_tabs_controller(),
|
||||
self._mk_tabs_header_wrapper(),
|
||||
self._mk_tab_content_wrapper(),
|
||||
Script(f'updateTabs("{self._id}-controller");'), # first time, run the script to initialize the tabs
|
||||
cls="mf-tabs-manager",
|
||||
id=self._id,
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ from fasthtml.components import Div, Input, Span
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.Keyboard import Keyboard
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command, CommandTemplate
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
from myfasthtml.icons.fluent_p1 import chevron_right20_regular, edit20_regular
|
||||
@@ -37,7 +37,6 @@ class TreeNode:
|
||||
type: str = "default"
|
||||
parent: Optional[str] = None
|
||||
children: list[str] = field(default_factory=list)
|
||||
bag: Optional[dict] = None # to keep extra info
|
||||
|
||||
|
||||
class TreeViewState(DbObject):
|
||||
@@ -67,82 +66,74 @@ class Commands(BaseCommands):
|
||||
|
||||
def toggle_node(self, node_id: str):
|
||||
"""Create command to expand/collapse a node."""
|
||||
return Command("ToggleNode",
|
||||
f"Toggle node {node_id}",
|
||||
self._owner,
|
||||
self._owner._toggle_node,
|
||||
kwargs={"node_id": node_id},
|
||||
key=f"{self._owner.get_safe_parent_key()}-ToggleNode"
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
return Command(
|
||||
"ToggleNode",
|
||||
f"Toggle node {node_id}",
|
||||
self._owner._toggle_node,
|
||||
node_id
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
def add_child(self, parent_id: str):
|
||||
"""Create command to add a child node."""
|
||||
return Command("AddChild",
|
||||
f"Add child to {parent_id}",
|
||||
self._owner,
|
||||
self._owner._add_child,
|
||||
kwargs={"parent_id": parent_id},
|
||||
key=f"{self._owner.get_safe_parent_key()}-AddChild"
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
return Command(
|
||||
"AddChild",
|
||||
f"Add child to {parent_id}",
|
||||
self._owner._add_child,
|
||||
parent_id
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
def add_sibling(self, node_id: str):
|
||||
"""Create command to add a sibling node."""
|
||||
return Command("AddSibling",
|
||||
f"Add sibling to {node_id}",
|
||||
self._owner,
|
||||
self._owner._add_sibling,
|
||||
kwargs={"node_id": node_id},
|
||||
key=f"{self._owner.get_safe_parent_key()}-AddSibling"
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
return Command(
|
||||
"AddSibling",
|
||||
f"Add sibling to {node_id}",
|
||||
self._owner._add_sibling,
|
||||
node_id
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
def start_rename(self, node_id: str):
|
||||
"""Create command to start renaming a node."""
|
||||
return Command("StartRename",
|
||||
f"Start renaming {node_id}",
|
||||
self._owner,
|
||||
self._owner._start_rename,
|
||||
kwargs={"node_id": node_id},
|
||||
key=f"{self._owner.get_safe_parent_key()}-StartRename"
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
return Command(
|
||||
"StartRename",
|
||||
f"Start renaming {node_id}",
|
||||
self._owner._start_rename,
|
||||
node_id
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
def save_rename(self, node_id: str):
|
||||
"""Create command to save renamed node."""
|
||||
return Command("SaveRename",
|
||||
f"Save rename for {node_id}",
|
||||
self._owner,
|
||||
self._owner._save_rename,
|
||||
kwargs={"node_id": node_id},
|
||||
key=f"{self._owner.get_safe_parent_key()}-SaveRename"
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
return Command(
|
||||
"SaveRename",
|
||||
f"Save rename for {node_id}",
|
||||
self._owner._save_rename,
|
||||
node_id
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
def cancel_rename(self):
|
||||
"""Create command to cancel renaming."""
|
||||
return Command("CancelRename",
|
||||
"Cancel rename",
|
||||
self._owner,
|
||||
self._owner._cancel_rename,
|
||||
key=f"{self._owner.get_safe_parent_key()}-CancelRename"
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
return Command(
|
||||
"CancelRename",
|
||||
"Cancel rename",
|
||||
self._owner._cancel_rename
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
def delete_node(self, node_id: str):
|
||||
"""Create command to delete a node."""
|
||||
return Command("DeleteNode",
|
||||
f"Delete node {node_id}",
|
||||
self._owner,
|
||||
self._owner._delete_node,
|
||||
kwargs={"node_id": node_id},
|
||||
key=f"{self._owner.get_safe_parent_key()}-DeleteNode"
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
return Command(
|
||||
"DeleteNode",
|
||||
f"Delete node {node_id}",
|
||||
self._owner._delete_node,
|
||||
node_id
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
def select_node(self, node_id: str):
|
||||
"""Create command to select a node."""
|
||||
return Command("SelectNode",
|
||||
f"Select node {node_id}",
|
||||
self._owner,
|
||||
self._owner._select_node,
|
||||
kwargs={"node_id": node_id},
|
||||
key=f"{self._owner.get_safe_parent_key()}-SelectNode"
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
return Command(
|
||||
"SelectNode",
|
||||
f"Select node {node_id}",
|
||||
self._owner._select_node,
|
||||
node_id
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
|
||||
class TreeView(MultipleInstance):
|
||||
@@ -182,7 +173,7 @@ class TreeView(MultipleInstance):
|
||||
Format: {type: "provider.icon_name"}
|
||||
"""
|
||||
self._state.icon_config = config
|
||||
|
||||
|
||||
def add_node(self, node: TreeNode, parent_id: Optional[str] = None, insert_index: Optional[int] = None):
|
||||
"""
|
||||
Add a node to the tree.
|
||||
@@ -194,9 +185,6 @@ class TreeView(MultipleInstance):
|
||||
If None, appends to end. If provided, inserts at that position.
|
||||
"""
|
||||
self._state.items[node.id] = node
|
||||
if parent_id is None and node.parent is not None:
|
||||
parent_id = node.parent
|
||||
|
||||
node.parent = parent_id
|
||||
|
||||
if parent_id and parent_id in self._state.items:
|
||||
@@ -207,72 +195,12 @@ class TreeView(MultipleInstance):
|
||||
else:
|
||||
parent.children.append(node.id)
|
||||
|
||||
def ensure_path(self, path: str):
|
||||
"""Add a node to the tree based on a path string.
|
||||
|
||||
Args:
|
||||
path: Dot-separated path string (e.g., "folder1.folder2.file")
|
||||
|
||||
Raises:
|
||||
ValueError: If path contains empty parts after stripping
|
||||
"""
|
||||
if path is None:
|
||||
raise ValueError(f"Invalid path: path is None")
|
||||
|
||||
path = path.strip().strip(".")
|
||||
if path == "":
|
||||
raise ValueError(f"Invalid path: path is empty")
|
||||
|
||||
parent_id = None
|
||||
current_nodes = [node for node in self._state.items.values() if node.parent is None]
|
||||
|
||||
path_parts = path.split(".")
|
||||
for part in path_parts:
|
||||
part = part.strip()
|
||||
|
||||
# Validate that part is not empty after stripping
|
||||
if part == "":
|
||||
raise ValueError(f"Invalid path: path contains empty parts")
|
||||
|
||||
node = [node for node in current_nodes if node.label == part]
|
||||
if len(node) == 0:
|
||||
# create the node
|
||||
node = TreeNode(label=part, type="folder")
|
||||
self.add_node(node, parent_id=parent_id)
|
||||
else:
|
||||
node = node[0]
|
||||
|
||||
current_nodes = [self._state.items[node_id] for node_id in node.children]
|
||||
parent_id = node.id
|
||||
|
||||
return parent_id
|
||||
|
||||
def get_selected_id(self):
|
||||
if self._state.selected is None:
|
||||
return None
|
||||
return self._state.items[self._state.selected].id
|
||||
|
||||
def expand_all(self):
|
||||
"""Expand all nodes that have children."""
|
||||
for node_id, node in self._state.items.items():
|
||||
if node.children and node_id not in self._state.opened:
|
||||
self._state.opened.append(node_id)
|
||||
|
||||
def clear(self):
|
||||
state = self._state.copy()
|
||||
state.items = {}
|
||||
state.opened = []
|
||||
state.selected = None
|
||||
state.editing = None
|
||||
self._state.update(state)
|
||||
return self
|
||||
|
||||
def get_bag(self, node_id: str):
|
||||
try:
|
||||
return self._state.items[node_id].bag
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
def _toggle_node(self, node_id: str):
|
||||
"""Toggle expand/collapse state of a node."""
|
||||
if node_id in self._state.opened:
|
||||
@@ -415,7 +343,7 @@ class TreeView(MultipleInstance):
|
||||
name="node_label",
|
||||
value=node.label,
|
||||
cls="mf-treenode-input input input-sm"
|
||||
), command=CommandTemplate("TreeView.SaveRename", self.commands.save_rename, args=[node_id]))
|
||||
), command=self.commands.save_rename(node_id))
|
||||
else:
|
||||
label_element = mk.mk(
|
||||
Span(node.label, cls="mf-treenode-label text-sm"),
|
||||
|
||||
@@ -33,10 +33,7 @@ class UserProfileState:
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def update_dark_mode(self):
|
||||
return Command("UpdateDarkMode",
|
||||
"Set the dark mode",
|
||||
self._owner,
|
||||
self._owner.update_dark_mode).htmx(target=None)
|
||||
return Command("UpdateDarkMode", "Set the dark mode", self._owner.update_dark_mode).htmx(target=None)
|
||||
|
||||
|
||||
class UserProfile(SingleInstance):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.core.bindings import Binding
|
||||
from myfasthtml.core.commands import Command, CommandTemplate
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.utils import merge_classes
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ class Ids:
|
||||
class mk:
|
||||
|
||||
@staticmethod
|
||||
def button(element, command: Command | CommandTemplate = None, binding: Binding = None, **kwargs):
|
||||
def button(element, command: Command = None, binding: Binding = None, **kwargs):
|
||||
"""
|
||||
Defines a static method for creating a Button object with specific configurations.
|
||||
|
||||
@@ -33,7 +33,7 @@ class mk:
|
||||
@staticmethod
|
||||
def dialog_buttons(ok_title: str = "OK",
|
||||
cancel_title: str = "Cancel",
|
||||
on_ok: Command | CommandTemplate = None,
|
||||
on_ok: Command = None,
|
||||
on_cancel: Command = None,
|
||||
cls=None):
|
||||
return Div(
|
||||
@@ -52,7 +52,7 @@ class mk:
|
||||
can_hover=False,
|
||||
tooltip=None,
|
||||
cls='',
|
||||
command: Command | CommandTemplate = None,
|
||||
command: Command = None,
|
||||
binding: Binding = None,
|
||||
**kwargs):
|
||||
"""
|
||||
@@ -92,10 +92,10 @@ class mk:
|
||||
icon=None,
|
||||
size: str = "sm",
|
||||
cls='',
|
||||
command: Command | CommandTemplate = None,
|
||||
command: Command = None,
|
||||
binding: Binding = None,
|
||||
**kwargs):
|
||||
merged_cls = merge_classes("flex truncate", cls, kwargs)
|
||||
merged_cls = merge_classes("flex", cls, kwargs)
|
||||
icon_part = Span(icon, cls=f"mf-icon-{mk.convert_size(size)} mr-1") if icon else None
|
||||
text_part = Span(text, cls=f"text-{size}")
|
||||
return mk.mk(Label(icon_part, text_part, cls=merged_cls, **kwargs), command=command, binding=binding)
|
||||
@@ -109,10 +109,7 @@ class mk:
|
||||
replace("xl", "32"))
|
||||
|
||||
@staticmethod
|
||||
def manage_command(ft, command: Command | CommandTemplate):
|
||||
if isinstance(command, CommandTemplate):
|
||||
command = command.command
|
||||
|
||||
def manage_command(ft, command: Command):
|
||||
if command:
|
||||
ft = command.bind_ft(ft)
|
||||
|
||||
@@ -133,7 +130,7 @@ class mk:
|
||||
return ft
|
||||
|
||||
@staticmethod
|
||||
def mk(ft, command: Command | CommandTemplate = None, binding: Binding = None, init_binding=True):
|
||||
def mk(ft, command: Command = None, binding: Binding = None, init_binding=True):
|
||||
ft = mk.manage_command(ft, command) if command else ft
|
||||
ft = mk.manage_binding(ft, binding, init_binding=init_binding) if binding else ft
|
||||
return ft
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from myutils.observable import NotObservableError, ObservableResultCollector
|
||||
from myutils.observable import NotObservableError, ObservableEvent, add_event_listener, remove_event_listener
|
||||
|
||||
from myfasthtml.core.constants import Routes, ROUTE_ROOT
|
||||
from myfasthtml.core.utils import flatten
|
||||
|
||||
logger = logging.getLogger("Commands")
|
||||
|
||||
|
||||
class Command:
|
||||
class BaseCommand:
|
||||
"""
|
||||
Represents the base command class for defining executable actions.
|
||||
|
||||
@@ -29,119 +25,26 @@ class Command:
|
||||
:type description: str
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def process_key(key, name, owner, args, kwargs):
|
||||
def _compute_from_args():
|
||||
res = []
|
||||
for arg in args:
|
||||
if hasattr(arg, "get_full_id"):
|
||||
res.append(arg.get_full_id())
|
||||
else:
|
||||
res.append(str(arg))
|
||||
return "-".join(res)
|
||||
|
||||
# special management when kwargs are provided
|
||||
# In this situation,
|
||||
# either there is no parameter (so one single instance of the command is enough)
|
||||
# or the parameter is a kwargs (so the parameters are provided when the command is called)
|
||||
if (key is None
|
||||
and owner is not None
|
||||
and args is None # args is not provided
|
||||
):
|
||||
key = f"{owner.get_full_id()}-{name}"
|
||||
|
||||
key = key.replace("#{args}", _compute_from_args())
|
||||
key = key.replace("#{id}", owner.get_full_id())
|
||||
key = key.replace("#{id-name-args}", f"{owner.get_full_id()}-{name}-{_compute_from_args()}")
|
||||
|
||||
return key
|
||||
|
||||
def __init__(self, name,
|
||||
description,
|
||||
owner=None,
|
||||
callback=None,
|
||||
args: list = None,
|
||||
kwargs: dict = None,
|
||||
key=None,
|
||||
auto_register=True):
|
||||
def __init__(self, name, description):
|
||||
self.id = uuid.uuid4()
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.owner = owner
|
||||
self.callback = callback
|
||||
self.default_args = args or []
|
||||
self.default_kwargs = kwargs or {}
|
||||
self._htmx_extra = {}
|
||||
self._bindings = []
|
||||
self._ft = None
|
||||
self._callback_parameters = dict(inspect.signature(callback).parameters) if callback else {}
|
||||
self._key = key
|
||||
|
||||
# special management when kwargs are provided
|
||||
# In this situation,
|
||||
# either there is no parameter (so one single instance of the command is enough)
|
||||
# or the parameter is a kwargs (so the parameters are provided when the command is called)
|
||||
if (self._key is None
|
||||
and self.owner is not None
|
||||
and args is None # args is not provided
|
||||
):
|
||||
self._key = f"{owner.get_full_id()}-{name}"
|
||||
|
||||
# register the command
|
||||
if auto_register:
|
||||
if self._key in CommandsManager.commands_by_key:
|
||||
self.id = CommandsManager.commands_by_key[self._key].id
|
||||
else:
|
||||
CommandsManager.register(self)
|
||||
|
||||
def get_key(self):
|
||||
return self._key
|
||||
CommandsManager.register(self)
|
||||
|
||||
def get_htmx_params(self):
|
||||
res = {
|
||||
return {
|
||||
"hx-post": f"{ROUTE_ROOT}{Routes.Commands}",
|
||||
"hx-swap": "outerHTML",
|
||||
"hx-vals": {"c_id": f"{self.id}"},
|
||||
}
|
||||
|
||||
for k, v in self._htmx_extra.items():
|
||||
if k == "hx-post":
|
||||
continue # cannot override this one
|
||||
elif k == "hx-vals":
|
||||
res["hx-vals"] |= v
|
||||
else:
|
||||
res[k] = v
|
||||
|
||||
# kwarg are given to the callback as values
|
||||
res["hx-vals"] |= self.default_kwargs
|
||||
|
||||
return res
|
||||
} | self._htmx_extra
|
||||
|
||||
def execute(self, client_response: dict = None):
|
||||
logger.debug(f"Executing command {self.name}")
|
||||
with ObservableResultCollector(self._bindings) as collector:
|
||||
kwargs = self._create_kwargs(self.default_kwargs,
|
||||
client_response,
|
||||
{"client_response": client_response or {}})
|
||||
ret = self.callback(*self.default_args, **kwargs)
|
||||
|
||||
ret_from_bound_commands = []
|
||||
if self.owner:
|
||||
for command in self.owner.get_bound_commands(self.name):
|
||||
logger.debug(f" will execute bound command {command.name}...")
|
||||
r = command.execute(client_response)
|
||||
ret_from_bound_commands.append(r) # it will be flatten if needed later
|
||||
|
||||
all_ret = flatten(ret, ret_from_bound_commands, collector.results)
|
||||
|
||||
# Set the hx-swap-oob attribute on all elements returned by the callback
|
||||
for r in all_ret[1:]:
|
||||
if (hasattr(r, 'attrs')
|
||||
and "hx-swap-oob" not in r.attrs
|
||||
and r.get("id", None) is not None):
|
||||
r.attrs["hx-swap-oob"] = r.attrs.get("hx-swap-oob", "true")
|
||||
|
||||
return all_ret[0] if len(all_ret) == 1 else all_ret
|
||||
raise NotImplementedError
|
||||
|
||||
def htmx(self, target: Optional[str] = "this", swap="outerHTML", trigger=None):
|
||||
# Note that the default value is the same than in get_htmx_params()
|
||||
@@ -196,22 +99,49 @@ class Command:
|
||||
return f"{ROUTE_ROOT}{Routes.Commands}?c_id={self.id}"
|
||||
|
||||
def ajax_htmx_options(self):
|
||||
res = {
|
||||
return {
|
||||
"url": self.url,
|
||||
"target": self._htmx_extra.get("hx-target", "this"),
|
||||
"swap": self._htmx_extra.get("hx-swap", "outerHTML"),
|
||||
"values": self.default_kwargs
|
||||
"values": {}
|
||||
}
|
||||
res["values"]["c_id"] = f"{self.id}" # cannot be overridden
|
||||
|
||||
return res
|
||||
|
||||
def get_ft(self):
|
||||
return self._ft
|
||||
|
||||
def _cast_parameter(self, key, value):
|
||||
if key in self._callback_parameters:
|
||||
param = self._callback_parameters[key]
|
||||
def __str__(self):
|
||||
return f"Command({self.name})"
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Represents a command that encapsulates a callable action with parameters.
|
||||
|
||||
This class is designed to hold a defined action (callback) alongside its arguments
|
||||
and keyword arguments.
|
||||
|
||||
:ivar name: The name of the command.
|
||||
:type name: str
|
||||
:ivar description: A brief description of the command.
|
||||
:type description: str
|
||||
:ivar callback: The function or callable to be executed.
|
||||
:type callback: Callable
|
||||
:ivar args: Positional arguments to be passed to the callback.
|
||||
:type args: tuple
|
||||
:ivar kwargs: Keyword arguments to be passed to the callback.
|
||||
:type kwargs: dict
|
||||
"""
|
||||
|
||||
def __init__(self, name, description, callback, *args, **kwargs):
|
||||
super().__init__(name, description)
|
||||
self.callback = callback
|
||||
self.callback_parameters = dict(inspect.signature(callback).parameters) if callback else {}
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
def _convert(self, key, value):
|
||||
if key in self.callback_parameters:
|
||||
param = self.callback_parameters[key]
|
||||
if param.annotation == bool:
|
||||
return value == "true"
|
||||
elif param.annotation == int:
|
||||
@@ -224,59 +154,70 @@ class Command:
|
||||
return json.loads(value)
|
||||
return value
|
||||
|
||||
def _create_kwargs(self, *args):
|
||||
"""
|
||||
Try to recreate the requested kwargs from the client response and the default kwargs.
|
||||
:param args:
|
||||
:return:
|
||||
"""
|
||||
all_args = {}
|
||||
for arg in [arg for arg in args if arg is not None]:
|
||||
all_args |= arg
|
||||
|
||||
res = {}
|
||||
for k, v in self._callback_parameters.items():
|
||||
if k in all_args:
|
||||
res[k] = self._cast_parameter(k, all_args[k])
|
||||
def ajax_htmx_options(self):
|
||||
res = super().ajax_htmx_options()
|
||||
if self.kwargs:
|
||||
res["values"] |= self.kwargs
|
||||
res["values"]["c_id"] = f"{self.id}" # cannot be overridden
|
||||
return res
|
||||
|
||||
def __str__(self):
|
||||
return f"Command({self.name})"
|
||||
def execute(self, client_response: dict = None):
|
||||
ret_from_bindings = []
|
||||
|
||||
def binding_result_callback(attr, old, new, results):
|
||||
ret_from_bindings.extend(results)
|
||||
|
||||
for data in self._bindings:
|
||||
add_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback)
|
||||
|
||||
new_kwargs = self.kwargs.copy()
|
||||
if client_response:
|
||||
for k, v in client_response.items():
|
||||
if k in self.callback_parameters:
|
||||
new_kwargs[k] = self._convert(k, v)
|
||||
if 'client_response' in self.callback_parameters:
|
||||
new_kwargs['client_response'] = client_response
|
||||
|
||||
ret = self.callback(*self.args, **new_kwargs)
|
||||
|
||||
for data in self._bindings:
|
||||
remove_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback)
|
||||
|
||||
# Set the hx-swap-oob attribute on all elements returned by the callback
|
||||
if isinstance(ret, (list, tuple)):
|
||||
for r in ret[1:]:
|
||||
if hasattr(r, 'attrs') and r.get("id", None) is not None:
|
||||
r.attrs["hx-swap-oob"] = r.attrs.get("hx-swap-oob", "true")
|
||||
|
||||
if not ret_from_bindings:
|
||||
return ret
|
||||
|
||||
if isinstance(ret, (list, tuple)):
|
||||
return list(ret) + ret_from_bindings
|
||||
else:
|
||||
return [ret] + ret_from_bindings
|
||||
|
||||
|
||||
class LambdaCommand(Command):
|
||||
def __init__(self, owner, delegate, name="LambdaCommand", description="Lambda Command"):
|
||||
super().__init__(name, description, owner, delegate)
|
||||
def __init__(self, delegate, name="LambdaCommand", description="Lambda Command"):
|
||||
super().__init__(name, description, delegate)
|
||||
self.htmx(target=None)
|
||||
|
||||
|
||||
class CommandTemplate:
|
||||
def __init__(self, key, command_type, args: list = None, kwargs: dict = None):
|
||||
self.key = key
|
||||
args = args or []
|
||||
kwargs = kwargs or {}
|
||||
self.command = CommandsManager.get_command_by_key(key) or command_type(*args, **kwargs)
|
||||
|
||||
def execute(self, client_response: dict = None):
|
||||
return self.callback(client_response)
|
||||
|
||||
|
||||
class CommandsManager:
|
||||
commands = {} # by_id
|
||||
commands_by_key = {}
|
||||
commands = {}
|
||||
|
||||
@staticmethod
|
||||
def register(command: Command):
|
||||
def register(command: BaseCommand):
|
||||
CommandsManager.commands[str(command.id)] = command
|
||||
if (key := command.get_key()) is not None:
|
||||
CommandsManager.commands_by_key[key] = command
|
||||
|
||||
@staticmethod
|
||||
def get_command(command_id: str) -> Optional[Command]:
|
||||
def get_command(command_id: str) -> Optional[BaseCommand]:
|
||||
return CommandsManager.commands.get(command_id)
|
||||
|
||||
@staticmethod
|
||||
def get_command_by_key(key):
|
||||
return CommandsManager.commands_by_key.get(key, None)
|
||||
|
||||
@staticmethod
|
||||
def reset():
|
||||
CommandsManager.commands.clear()
|
||||
CommandsManager.commands_by_key.clear()
|
||||
return CommandsManager.commands.clear()
|
||||
|
||||
@@ -4,10 +4,6 @@ DEFAULT_COLUMN_WIDTH = 100
|
||||
|
||||
ROUTE_ROOT = "/myfasthtml"
|
||||
|
||||
# Datagrid
|
||||
ROW_INDEX_ID = "__row_index__"
|
||||
DATAGRID_PAGE_SIZE = 1000
|
||||
FILTER_INPUT_CID = "__filter_input__"
|
||||
|
||||
class Routes:
|
||||
Commands = "/commands"
|
||||
|
||||
@@ -37,11 +37,10 @@ class DbObject:
|
||||
_initializing = False
|
||||
_forbidden_attrs = {"_initializing", "_db_manager", "_name", "_owner", "_forbidden_attrs"}
|
||||
|
||||
def __init__(self, owner: BaseInstance, name=None, db_manager=None, save_state=True):
|
||||
def __init__(self, owner: BaseInstance, name=None, db_manager=None):
|
||||
self._owner = owner
|
||||
self._name = name or owner.get_full_id()
|
||||
self._db_manager = db_manager or DbManager(self._owner)
|
||||
self._save_state = save_state
|
||||
|
||||
self._finalize_initialization()
|
||||
|
||||
@@ -56,14 +55,13 @@ class DbObject:
|
||||
self._initializing = old_state
|
||||
|
||||
def __setattr__(self, name: str, value: str):
|
||||
if name.startswith("_") or name.startswith("ns_") or getattr(self, "_initializing", False):
|
||||
if name.startswith("_") or name.startswith("ns") or getattr(self, "_initializing", False):
|
||||
super().__setattr__(name, value)
|
||||
return
|
||||
|
||||
if not name.startswith("ne_"):
|
||||
old_value = getattr(self, name, None)
|
||||
if old_value == value:
|
||||
return
|
||||
old_value = getattr(self, name, None)
|
||||
if old_value == value:
|
||||
return
|
||||
|
||||
super().__setattr__(name, value)
|
||||
self._save_self()
|
||||
@@ -76,9 +74,6 @@ class DbObject:
|
||||
self._save_self()
|
||||
|
||||
def _save_self(self):
|
||||
if not self._save_state:
|
||||
return
|
||||
|
||||
props = {k: getattr(self, k) for k, v in self._get_properties().items() if
|
||||
not k.startswith("_") and not k.startswith("ns")}
|
||||
if props:
|
||||
@@ -103,8 +98,6 @@ class DbObject:
|
||||
properties = {}
|
||||
if args:
|
||||
arg = args[0]
|
||||
if arg is None:
|
||||
return self
|
||||
if not isinstance(arg, (dict, SimpleNamespace)):
|
||||
raise ValueError("Only dict or Expando are allowed as argument")
|
||||
properties |= vars(arg) if isinstance(arg, SimpleNamespace) else arg
|
||||
|
||||
@@ -3,7 +3,7 @@ import uuid
|
||||
from typing import Optional
|
||||
|
||||
from myfasthtml.controls.helpers import Ids
|
||||
from myfasthtml.core.utils import pascal_to_snake, get_class, snake_to_pascal
|
||||
from myfasthtml.core.utils import pascal_to_snake
|
||||
|
||||
logger = logging.getLogger("InstancesManager")
|
||||
|
||||
@@ -67,7 +67,6 @@ class BaseInstance:
|
||||
self._session = session or (parent.get_session() if parent else None)
|
||||
self._id = self.compute_id(_id, parent)
|
||||
self._prefix = self._id if isinstance(self, (UniqueInstance, SingleInstance)) else self.compute_prefix()
|
||||
self._bound_commands = {}
|
||||
|
||||
if auto_register:
|
||||
InstancesManager.register(self._session, self)
|
||||
@@ -81,26 +80,16 @@ class BaseInstance:
|
||||
def get_parent(self) -> Optional['BaseInstance']:
|
||||
return self._parent
|
||||
|
||||
def get_safe_parent_key(self):
|
||||
return self.get_parent_full_id() if self.get_parent() else self.get_full_id()
|
||||
|
||||
def get_prefix(self) -> str:
|
||||
return self._prefix
|
||||
|
||||
def get_full_id(self) -> str:
|
||||
return f"{InstancesManager.get_session_id(self._session)}#{self._id}"
|
||||
|
||||
def get_parent_full_id(self) -> Optional[str]:
|
||||
def get_full_parent_id(self) -> Optional[str]:
|
||||
parent = self.get_parent()
|
||||
return parent.get_full_id() if parent else None
|
||||
|
||||
def bind_command(self, command, command_to_bind):
|
||||
command_name = command.name if hasattr(command, "name") else command
|
||||
self._bound_commands.setdefault(command_name, []).append(command_to_bind)
|
||||
|
||||
def get_bound_commands(self, command_name):
|
||||
return self._bound_commands.get(command_name, [])
|
||||
|
||||
@classmethod
|
||||
def compute_prefix(cls):
|
||||
return f"mf-{pascal_to_snake(cls.__name__)}"
|
||||
@@ -144,11 +133,8 @@ class UniqueInstance(BaseInstance):
|
||||
parent: Optional[BaseInstance] = None,
|
||||
session: Optional[dict] = None,
|
||||
_id: Optional[str] = None,
|
||||
auto_register: bool = True,
|
||||
on_init=None):
|
||||
auto_register: bool = True):
|
||||
super().__init__(parent, session, _id, auto_register)
|
||||
if on_init is not None:
|
||||
on_init()
|
||||
|
||||
|
||||
class MultipleInstance(BaseInstance):
|
||||
@@ -183,22 +169,16 @@ class InstancesManager:
|
||||
return instance
|
||||
|
||||
@staticmethod
|
||||
def get(session: dict, instance_id: str, default="**__no_default__**"):
|
||||
def get(session: dict, instance_id: str):
|
||||
"""
|
||||
Get or create an instance of the given type (from its id)
|
||||
:param session:
|
||||
:param instance_id:
|
||||
:param default:
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
session_id = InstancesManager.get_session_id(session)
|
||||
key = (session_id, instance_id)
|
||||
return InstancesManager.instances[key]
|
||||
except KeyError:
|
||||
if default != "**__non__**":
|
||||
return default
|
||||
raise
|
||||
session_id = InstancesManager.get_session_id(session)
|
||||
key = (session_id, instance_id)
|
||||
return InstancesManager.instances[key]
|
||||
|
||||
@staticmethod
|
||||
def get_by_type(session: dict, cls: type):
|
||||
@@ -208,28 +188,6 @@ class InstancesManager:
|
||||
assert len(res) > 0, f"No instance of type {cls.__name__} found"
|
||||
return res[0]
|
||||
|
||||
@staticmethod
|
||||
def dynamic_get(session, component_parent: tuple, component: tuple):
|
||||
component_type, component_id = component
|
||||
|
||||
# 1. Check if component already exists
|
||||
existing = InstancesManager.get(session, component_id, None)
|
||||
if existing is not None:
|
||||
logger.debug(f"Component {component_id} already exists, returning existing instance")
|
||||
return existing
|
||||
|
||||
# 2. Component doesn't exist, create it
|
||||
parent_type, parent_id = component_parent
|
||||
|
||||
# parent should always exist
|
||||
parent = InstancesManager.get(session, parent_id)
|
||||
|
||||
real_component_type = snake_to_pascal(component_type.removeprefix("mf-"))
|
||||
component_full_type = f"myfasthtml.controls.{real_component_type}.{real_component_type}"
|
||||
cls = get_class(component_full_type)
|
||||
logger.debug(f"Creating new component {component_id} of type {real_component_type}")
|
||||
return cls(parent, _id=component_id)
|
||||
|
||||
@staticmethod
|
||||
def get_session_id(session):
|
||||
if isinstance(session, str):
|
||||
|
||||
@@ -150,7 +150,6 @@ def from_parent_child_list(
|
||||
id_getter: Callable = None,
|
||||
label_getter: Callable = None,
|
||||
parent_getter: Callable = None,
|
||||
ghost_label_getter: Callable = lambda node: str(node),
|
||||
ghost_color: str = GHOST_COLOR,
|
||||
root_color: str | None = ROOT_COLOR
|
||||
) -> tuple[list, list]:
|
||||
@@ -162,7 +161,6 @@ def from_parent_child_list(
|
||||
id_getter: callback to extract node ID
|
||||
label_getter: callback to extract node label
|
||||
parent_getter: callback to extract parent ID
|
||||
ghost_label_getter: callback to extract label for ghost nodes
|
||||
ghost_color: color for ghost nodes (referenced parents)
|
||||
root_color: color for root nodes (nodes without parent)
|
||||
|
||||
@@ -227,7 +225,7 @@ def from_parent_child_list(
|
||||
ghost_nodes.add(parent_id)
|
||||
nodes.append({
|
||||
"id": parent_id,
|
||||
"label": ghost_label_getter(parent_id),
|
||||
"label": str(parent_id),
|
||||
"color": ghost_color
|
||||
})
|
||||
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
"""
|
||||
Optimized FastHTML-compatible elements that generate HTML directly.
|
||||
|
||||
These classes bypass FastHTML's overhead for performance-critical rendering
|
||||
by generating HTML strings directly instead of creating full FastHTML objects.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from fasthtml.common import NotStr
|
||||
|
||||
|
||||
class OptimizedFt:
|
||||
"""Lightweight FastHTML-compatible element that generates HTML directly."""
|
||||
|
||||
ATTR_MAP = {
|
||||
"cls": "class",
|
||||
"_id": "id",
|
||||
}
|
||||
|
||||
def __init__(self, tag, *args, **kwargs):
|
||||
self.tag = tag
|
||||
self.children = args
|
||||
self.attrs = {self.safe_attr(k): v for k, v in kwargs.items() if v is not None}
|
||||
|
||||
@staticmethod
|
||||
@lru_cache(maxsize=128)
|
||||
def safe_attr(attr_name):
|
||||
"""Convert Python attribute names to HTML attribute names."""
|
||||
attr_name = attr_name.replace("hx_", "hx-")
|
||||
attr_name = attr_name.replace("data_", "data-")
|
||||
return OptimizedFt.ATTR_MAP.get(attr_name, attr_name)
|
||||
|
||||
@staticmethod
|
||||
def to_html_helper(item):
|
||||
"""Convert any item to HTML string."""
|
||||
if item is None:
|
||||
return ""
|
||||
elif isinstance(item, str):
|
||||
return item
|
||||
elif isinstance(item, (int, float, bool)):
|
||||
return str(item)
|
||||
elif isinstance(item, OptimizedFt):
|
||||
return item.to_html()
|
||||
elif isinstance(item, NotStr):
|
||||
return str(item)
|
||||
else:
|
||||
raise Exception(f"Unsupported type: {type(item)}, {item=}")
|
||||
|
||||
def to_html(self):
|
||||
"""Generate HTML string."""
|
||||
# Build attributes
|
||||
attrs_list = []
|
||||
for k, v in self.attrs.items():
|
||||
if v is False:
|
||||
continue # Skip False attributes
|
||||
if v is True:
|
||||
attrs_list.append(k) # Boolean attribute
|
||||
else:
|
||||
# No need to escape v since we control the values (width, IDs, etc.)
|
||||
attrs_list.append(f'{k}="{v}"')
|
||||
|
||||
attrs_str = ' ' + ' '.join(attrs_list) if attrs_list else ''
|
||||
|
||||
# Build children HTML
|
||||
children_html = ''.join(self.to_html_helper(child) for child in self.children)
|
||||
|
||||
return f'<{self.tag}{attrs_str}>{children_html}</{self.tag}>'
|
||||
|
||||
def __ft__(self):
|
||||
"""FastHTML compatibility - returns NotStr to avoid double escaping."""
|
||||
return NotStr(self.to_html())
|
||||
|
||||
def __str__(self):
|
||||
return self.to_html()
|
||||
|
||||
|
||||
class OptimizedDiv(OptimizedFt):
|
||||
"""Optimized Div element."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__("div", *args, **kwargs)
|
||||
@@ -1,4 +1,3 @@
|
||||
import importlib
|
||||
import logging
|
||||
import re
|
||||
|
||||
@@ -13,7 +12,7 @@ from myfasthtml.core.constants import Routes, ROUTE_ROOT
|
||||
from myfasthtml.test.MyFT import MyFT
|
||||
|
||||
utils_app, utils_rt = fast_app()
|
||||
logger = logging.getLogger("Routing")
|
||||
logger = logging.getLogger("Commands")
|
||||
|
||||
|
||||
def mount_if_not_exists(app, path: str, sub_app):
|
||||
@@ -263,84 +262,6 @@ def snake_to_pascal(name: str) -> str:
|
||||
return ''.join(word.capitalize() for word in parts if word)
|
||||
|
||||
|
||||
def flatten(*args):
|
||||
"""
|
||||
Flattens nested lists or tuples into a single list. This utility function takes
|
||||
any number of arguments, iterating recursively through any nested lists or
|
||||
tuples, and returns a flat list containing all the elements.
|
||||
|
||||
:param args: Arbitrary number of arguments, which can include nested lists or
|
||||
tuples to be flattened.
|
||||
:type args: Any
|
||||
:return: A flat list containing all the elements from the input, preserving the
|
||||
order of elements as they are recursively extracted from nested
|
||||
structures.
|
||||
:rtype: list
|
||||
"""
|
||||
res = []
|
||||
for arg in args:
|
||||
if isinstance(arg, (list, tuple)):
|
||||
res.extend(flatten(*arg))
|
||||
else:
|
||||
res.append(arg)
|
||||
return res
|
||||
|
||||
|
||||
def make_html_id(s: str | None) -> str | None:
|
||||
"""
|
||||
Creates a valid html id
|
||||
:param s:
|
||||
:return:
|
||||
"""
|
||||
if s is None:
|
||||
return None
|
||||
|
||||
s = str(s).strip()
|
||||
# Replace spaces and special characters with hyphens or remove them
|
||||
s = re.sub(r'[^a-zA-Z0-9_-]', '-', s)
|
||||
|
||||
# Ensure the ID starts with a letter or underscore
|
||||
if not re.match(r'^[a-zA-Z_]', s):
|
||||
s = 'id_' + s # Add a prefix if it doesn't
|
||||
|
||||
# Collapse multiple consecutive hyphens into one
|
||||
s = re.sub(r'-+', '-', s)
|
||||
|
||||
# Replace trailing hyphens with underscores
|
||||
s = re.sub(r'-+$', '_', s)
|
||||
|
||||
return s
|
||||
|
||||
def make_safe_id(s: str | None):
|
||||
if s is None:
|
||||
return None
|
||||
|
||||
res = re.sub('-', '_', make_html_id(s)) # replace '-' by '_'
|
||||
return res.lower() # no uppercase
|
||||
|
||||
|
||||
def get_class(qualified_class_name: str):
|
||||
"""
|
||||
Dynamically loads and returns a class type from its fully qualified name.
|
||||
Note that the class is not instantiated.
|
||||
|
||||
:param qualified_class_name: Fully qualified name of the class (e.g., 'some.module.ClassName').
|
||||
:return: The class object.
|
||||
:raises ImportError: If the module cannot be imported.
|
||||
:raises AttributeError: If the class cannot be resolved in the module.
|
||||
"""
|
||||
module_name, class_name = qualified_class_name.rsplit(".", 1)
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
except ModuleNotFoundError as e:
|
||||
raise ImportError(f"Could not import module '{module_name}' for '{qualified_class_name}': {e}")
|
||||
|
||||
if not hasattr(module, class_name):
|
||||
raise AttributeError(f"Component '{class_name}' not found in '{module.__name__}'.")
|
||||
|
||||
return getattr(module, class_name)
|
||||
|
||||
@utils_rt(Routes.Commands)
|
||||
def post(session, c_id: str, client_response: dict = None):
|
||||
"""
|
||||
|
||||
@@ -49,14 +49,8 @@ def get():
|
||||
mk.manage_binding(datalist, Binding(data))
|
||||
mk.manage_binding(label_elt, Binding(data))
|
||||
|
||||
add_button = mk.button("Add", command=Command("Add",
|
||||
"Add a suggestion",
|
||||
None,
|
||||
add_suggestion).bind(data))
|
||||
remove_button = mk.button("Remove", command=Command("Remove",
|
||||
"Remove a suggestion",
|
||||
None,
|
||||
remove_suggestion).bind(data))
|
||||
add_button = mk.button("Add", command=Command("Add", "Add a suggestion", add_suggestion).bind(data))
|
||||
remove_button = mk.button("Remove", command=Command("Remove", "Remove a suggestion", remove_suggestion).bind(data))
|
||||
|
||||
return Div(
|
||||
add_button,
|
||||
|
||||
@@ -11,10 +11,7 @@ def say_hello():
|
||||
|
||||
|
||||
# Create the command
|
||||
hello_command = Command("say_hello",
|
||||
"Responds with a greeting",
|
||||
None,
|
||||
say_hello)
|
||||
hello_command = Command("say_hello", "Responds with a greeting", say_hello)
|
||||
|
||||
# Create the app
|
||||
app, rt = create_app(protect_routes=False)
|
||||
|
||||
@@ -13,10 +13,7 @@ def change_text():
|
||||
return "New text"
|
||||
|
||||
|
||||
command = Command("change_text",
|
||||
"change the text",
|
||||
None,
|
||||
change_text).htmx(target="#text")
|
||||
command = Command("change_text", "change the text", change_text).htmx(target="#text")
|
||||
|
||||
|
||||
@rt("/")
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Optional, Any
|
||||
from fastcore.basics import NotStr
|
||||
from fastcore.xml import FT
|
||||
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.commands import BaseCommand
|
||||
from myfasthtml.core.utils import quoted_str, snake_to_pascal
|
||||
from myfasthtml.test.testclient import MyFT
|
||||
|
||||
@@ -110,14 +110,6 @@ class Regex(AttrPredicate):
|
||||
return re.match(self.value, actual) is not None
|
||||
|
||||
|
||||
class And(AttrPredicate):
|
||||
def __init__(self, *predicates):
|
||||
super().__init__(predicates)
|
||||
|
||||
def validate(self, actual):
|
||||
return all(p.validate(actual) for p in self.value)
|
||||
|
||||
|
||||
class ChildrenPredicate(Predicate):
|
||||
"""
|
||||
Predicate given as a child of an element.
|
||||
@@ -160,7 +152,7 @@ class AttributeForbidden(ChildrenPredicate):
|
||||
|
||||
|
||||
class HasHtmx(ChildrenPredicate):
|
||||
def __init__(self, command: Command = None, **htmx_params):
|
||||
def __init__(self, command: BaseCommand = None, **htmx_params):
|
||||
super().__init__(None)
|
||||
self.command = command
|
||||
if command:
|
||||
@@ -799,6 +791,10 @@ def find(ft, expected):
|
||||
for element in elements_to_search:
|
||||
all_matches.extend(_search_tree(element, expected))
|
||||
|
||||
# Raise error if nothing found
|
||||
if not all_matches:
|
||||
raise AssertionError(f"No element found for '{expected}'")
|
||||
|
||||
return all_matches
|
||||
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ def test_i_can_mk_button_with_attrs():
|
||||
def test_i_can_mk_button_with_command(user, rt):
|
||||
def new_value(value): return value
|
||||
|
||||
command = Command('test', 'TestingCommand', None, new_value, args=["this is my new value"])
|
||||
command = Command('test', 'TestingCommand', new_value, "this is my new value")
|
||||
|
||||
@rt('/')
|
||||
def get(): return mk.button('button', command)
|
||||
|
||||
@@ -1,502 +0,0 @@
|
||||
"""Unit tests for Panel component."""
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.controls.Panel import Panel, PanelConf
|
||||
from myfasthtml.test.matcher import matches, find, Contains, find_one, TestIcon, TestScript, TestIconNotStr
|
||||
from .conftest import root_instance
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def cleanup_db():
|
||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||
|
||||
|
||||
class TestPanelBehaviour:
|
||||
"""Tests for Panel behavior and logic."""
|
||||
|
||||
# 1. Creation and initialization
|
||||
|
||||
def test_i_can_create_panel_with_default_config(self, root_instance):
|
||||
"""Test that a Panel can be created with default configuration."""
|
||||
panel = Panel(root_instance)
|
||||
|
||||
assert panel is not None
|
||||
assert panel.conf.left is False
|
||||
assert panel.conf.right is True
|
||||
|
||||
def test_i_can_create_panel_with_custom_config(self, root_instance):
|
||||
"""Test that a Panel accepts a custom PanelConf."""
|
||||
custom_conf = PanelConf(left=False, right=True)
|
||||
panel = Panel(root_instance, conf=custom_conf)
|
||||
|
||||
assert panel.conf.left is False
|
||||
assert panel.conf.right is True
|
||||
|
||||
def test_panel_has_default_state_after_creation(self, root_instance):
|
||||
"""Test that _state has correct initial values."""
|
||||
panel = Panel(root_instance)
|
||||
state = panel._state
|
||||
|
||||
assert state.left_visible is True
|
||||
assert state.right_visible is True
|
||||
assert state.left_width == 250
|
||||
assert state.right_width == 250
|
||||
|
||||
def test_panel_creates_commands_instance(self, root_instance):
|
||||
"""Test that panel.commands exists and is of type Commands."""
|
||||
panel = Panel(root_instance)
|
||||
|
||||
assert panel.commands is not None
|
||||
assert panel.commands.__class__.__name__ == "Commands"
|
||||
|
||||
# 2. Content management
|
||||
|
||||
def test_i_can_set_main_content(self, root_instance):
|
||||
"""Test that set_main() stores content in _main."""
|
||||
panel = Panel(root_instance)
|
||||
content = Div("Main content")
|
||||
|
||||
panel.set_main(content)
|
||||
|
||||
assert panel._main == content
|
||||
|
||||
def test_set_main_returns_self(self, root_instance):
|
||||
"""Test that set_main() returns self for method chaining."""
|
||||
panel = Panel(root_instance)
|
||||
content = Div("Main content")
|
||||
|
||||
result = panel.set_main(content)
|
||||
|
||||
assert result is panel
|
||||
|
||||
def test_i_can_set_left_content(self, root_instance):
|
||||
"""Test that set_left() stores content in _left."""
|
||||
panel = Panel(root_instance)
|
||||
content = Div("Left content")
|
||||
|
||||
panel.set_left(content)
|
||||
|
||||
assert panel._left == content
|
||||
|
||||
def test_i_can_set_right_content(self, root_instance):
|
||||
"""Test that set_right() stores content in _right."""
|
||||
panel = Panel(root_instance)
|
||||
content = Div("Right content")
|
||||
|
||||
panel.set_right(content)
|
||||
|
||||
assert panel._right == content
|
||||
|
||||
# 3. Toggle visibility
|
||||
|
||||
def test_i_can_hide_left_panel(self, root_instance):
|
||||
"""Test that toggle_side('left', False) sets _state.left_visible to False."""
|
||||
panel = Panel(root_instance)
|
||||
|
||||
panel.toggle_side("left", False)
|
||||
|
||||
assert panel._state.left_visible is False
|
||||
|
||||
def test_i_can_show_left_panel(self, root_instance):
|
||||
"""Test that toggle_side('left', True) sets _state.left_visible to True."""
|
||||
panel = Panel(root_instance)
|
||||
panel._state.left_visible = False
|
||||
|
||||
panel.toggle_side("left", True)
|
||||
|
||||
assert panel._state.left_visible is True
|
||||
|
||||
def test_i_can_hide_right_panel(self, root_instance):
|
||||
"""Test that toggle_side('right', False) sets _state.right_visible to False."""
|
||||
panel = Panel(root_instance)
|
||||
|
||||
panel.toggle_side("right", False)
|
||||
|
||||
assert panel._state.right_visible is False
|
||||
|
||||
def test_i_can_show_right_panel(self, root_instance):
|
||||
"""Test that toggle_side('right', True) sets _state.right_visible to True."""
|
||||
panel = Panel(root_instance)
|
||||
panel._state.right_visible = False
|
||||
|
||||
panel.toggle_side("right", True)
|
||||
|
||||
assert panel._state.right_visible is True
|
||||
|
||||
def test_toggle_side_returns_panel_and_icon(self, root_instance):
|
||||
"""Test that toggle_side() returns a tuple (panel_element, show_icon_element)."""
|
||||
panel = Panel(root_instance)
|
||||
|
||||
result = panel.toggle_side("left", False)
|
||||
|
||||
assert isinstance(result, tuple)
|
||||
assert len(result) == 2
|
||||
|
||||
# 4. Width management
|
||||
|
||||
def test_i_can_update_left_panel_width(self, root_instance):
|
||||
"""Test that update_side_width('left', 300) sets _state.left_width to 300."""
|
||||
panel = Panel(root_instance)
|
||||
|
||||
panel.update_side_width("left", 300)
|
||||
|
||||
assert panel._state.left_width == 300
|
||||
|
||||
def test_i_can_update_right_panel_width(self, root_instance):
|
||||
"""Test that update_side_width('right', 400) sets _state.right_width to 400."""
|
||||
panel = Panel(root_instance)
|
||||
|
||||
panel.update_side_width("right", 400)
|
||||
|
||||
assert panel._state.right_width == 400
|
||||
|
||||
def test_update_width_returns_panel_element(self, root_instance):
|
||||
"""Test that update_side_width() returns a panel element."""
|
||||
panel = Panel(root_instance)
|
||||
|
||||
result = panel.update_side_width("right", 300)
|
||||
|
||||
assert result is not None
|
||||
|
||||
# 5. Configuration
|
||||
|
||||
def test_disabled_left_panel_returns_none(self, root_instance):
|
||||
"""Test that _mk_panel('left') returns None when conf.left=False."""
|
||||
custom_conf = PanelConf(left=False, right=True)
|
||||
panel = Panel(root_instance, conf=custom_conf)
|
||||
|
||||
result = panel._mk_panel("left")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_disabled_right_panel_returns_none(self, root_instance):
|
||||
"""Test that _mk_panel('right') returns None when conf.right=False."""
|
||||
custom_conf = PanelConf(left=True, right=False)
|
||||
panel = Panel(root_instance, conf=custom_conf)
|
||||
|
||||
result = panel._mk_panel("right")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_disabled_panel_show_icon_returns_none(self, root_instance):
|
||||
"""Test that _mk_show_icon() returns None when the panel is disabled."""
|
||||
custom_conf = PanelConf(left=False, right=True)
|
||||
panel = Panel(root_instance, conf=custom_conf)
|
||||
|
||||
result = panel._mk_show_icon("left")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestPanelRender:
|
||||
"""Tests for Panel HTML rendering."""
|
||||
|
||||
@pytest.fixture
|
||||
def panel(self, root_instance):
|
||||
panel = Panel(root_instance, PanelConf(True, True))
|
||||
panel.set_main(Div("Main content"))
|
||||
panel.set_left(Div("Left content"))
|
||||
panel.set_right(Div("Right content"))
|
||||
return panel
|
||||
|
||||
# 1. Global structure (UTR-11.1 - FIRST TEST)
|
||||
|
||||
def test_i_can_render_panel_with_default_state(self, panel):
|
||||
"""Test that Panel renders with correct global structure.
|
||||
|
||||
Why these elements matter:
|
||||
- 4 children: Verifies all main sections are rendered (left panel, main, right panel, script)
|
||||
- _id: Essential for panel identification and resizer initialization
|
||||
- cls="mf-panel": Root CSS class for panel styling
|
||||
"""
|
||||
expected = Div(
|
||||
Div(), # left panel
|
||||
Div(), # main
|
||||
Div(), # right panel
|
||||
Script(),
|
||||
id=panel._id,
|
||||
cls="mf-panel"
|
||||
)
|
||||
|
||||
assert matches(panel.render(), expected)
|
||||
|
||||
# 2. Left panel
|
||||
|
||||
def test_left_panel_renders_with_correct_structure(self, panel):
|
||||
"""Test that left panel has content div before resizer.
|
||||
|
||||
Why these elements matter:
|
||||
- Order (content then resizer): Critical for positioning resizer on the right side
|
||||
- id: Required for HTMX targeting during toggle/resize operations
|
||||
- cls Contains "mf-panel-left": CSS class for left panel styling
|
||||
"""
|
||||
left_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("left")))
|
||||
|
||||
# Step 1: Validate left panel global structure
|
||||
expected = Div(
|
||||
TestIcon("subtract20_regular"),
|
||||
Div(id=panel.get_ids().left), # content div, tested in detail later
|
||||
Div(cls=Contains("mf-resizer-left")), # resizer
|
||||
id=panel.get_ids().panel("left"),
|
||||
cls=Contains("mf-panel-left")
|
||||
)
|
||||
|
||||
assert matches(left_panel, expected)
|
||||
|
||||
def test_left_panel_has_mf_hidden_class_when_not_visible(self, panel):
|
||||
"""Test that left panel has 'mf-hidden' class when not visible.
|
||||
|
||||
Why these elements matter:
|
||||
- cls Contains "mf-hidden": CSS class required for width animation
|
||||
"""
|
||||
panel._state.left_visible = False
|
||||
left_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("left")))
|
||||
|
||||
expected = Div(cls=Contains("mf-hidden"))
|
||||
|
||||
assert matches(left_panel, expected)
|
||||
|
||||
def test_left_panel_does_not_render_when_disabled(self, panel):
|
||||
"""Test that render() does not contain left panel when conf.left=False.
|
||||
|
||||
Why these elements matter:
|
||||
- Absence of left panel: Configuration must prevent rendering
|
||||
"""
|
||||
panel.conf.left = False
|
||||
rendered = panel.render()
|
||||
|
||||
# Verify left panel is not present
|
||||
left_panels = find(rendered, Div(id=panel.get_ids().panel("left")))
|
||||
assert len(left_panels) == 0, "Left panel should not be present when conf.left=False"
|
||||
|
||||
# 3. Right panel
|
||||
|
||||
def test_right_panel_renders_with_correct_structure(self, panel):
|
||||
"""Test that right panel has resizer before content div.
|
||||
|
||||
Why these elements matter:
|
||||
- Order (resizer then hide icon then content): Critical for positioning resizer on the left side
|
||||
- id: Required for HTMX targeting during toggle/resize operations
|
||||
- cls Contains "mf-panel-right": CSS class for right panel styling
|
||||
"""
|
||||
right_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("right")))
|
||||
|
||||
# Step 1: Validate right panel global structure
|
||||
expected = Div(
|
||||
Div(cls=Contains("mf-resizer-right")), # resizer
|
||||
TestIcon("subtract20_regular"), # hide icon
|
||||
Div(id=panel.get_ids().right), # content div, tested in detail later
|
||||
id=panel.get_ids().panel("right"),
|
||||
cls=Contains("mf-panel-right")
|
||||
)
|
||||
|
||||
assert matches(right_panel, expected)
|
||||
|
||||
def test_right_panel_has_mf_hidden_class_when_not_visible(self, panel):
|
||||
"""Test that right panel has 'mf-hidden' class when not visible.
|
||||
|
||||
Why these elements matter:
|
||||
- cls Contains "mf-hidden": CSS class required for width animation
|
||||
"""
|
||||
panel._state.right_visible = False
|
||||
right_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("right")))
|
||||
|
||||
expected = Div(cls=Contains("mf-hidden"))
|
||||
|
||||
assert matches(right_panel, expected)
|
||||
|
||||
def test_right_panel_does_not_render_when_disabled(self, panel):
|
||||
"""Test that render() does not contain right panel when conf.right=False.
|
||||
|
||||
Why these elements matter:
|
||||
- Absence of right panel: Configuration must prevent rendering
|
||||
"""
|
||||
panel.conf.right = False
|
||||
rendered = panel.render()
|
||||
|
||||
# Verify right panel is not present
|
||||
right_panels = find(rendered, Div(id=panel.get_ids().panel("right")))
|
||||
assert len(right_panels) == 0, "Right panel should not be present when conf.right=False"
|
||||
|
||||
# 4. Resizers
|
||||
|
||||
def test_left_panel_has_resizer_with_correct_attributes(self, panel):
|
||||
"""Test that left panel resizer has required attributes.
|
||||
|
||||
Why these elements matter:
|
||||
- data_side="left": JavaScript uses this to determine which side is being resized
|
||||
- data_command_id: Required to trigger update_side_width command via HTMX
|
||||
- cls Contains "mf-resizer": Base CSS class for resizer styling
|
||||
- cls Contains "mf-resizer-left": Left-specific CSS class for positioning
|
||||
"""
|
||||
left_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("left")))
|
||||
resizer = find_one(left_panel, Div(cls=Contains("mf-resizer-left")))
|
||||
|
||||
expected = Div(
|
||||
data_side="left",
|
||||
cls=Contains("mf-resizer", "mf-resizer-left")
|
||||
)
|
||||
|
||||
assert matches(resizer, expected)
|
||||
# Verify data-command-id exists (value is dynamic, HTML uses hyphens)
|
||||
assert "data-command-id" in resizer.attrs
|
||||
|
||||
def test_right_panel_has_resizer_with_correct_attributes(self, panel):
|
||||
"""Test that right panel resizer has required attributes.
|
||||
|
||||
Why these elements matter:
|
||||
- data_side="right": JavaScript uses this to determine which side is being resized
|
||||
- data_command_id: Required to trigger update_side_width command via HTMX
|
||||
- cls Contains "mf-resizer": Base CSS class for resizer styling
|
||||
- cls Contains "mf-resizer-right": Right-specific CSS class for positioning
|
||||
"""
|
||||
right_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("right")))
|
||||
resizer = find_one(right_panel, Div(cls=Contains("mf-resizer-right")))
|
||||
|
||||
expected = Div(
|
||||
data_side="right",
|
||||
cls=Contains("mf-resizer", "mf-resizer-right")
|
||||
)
|
||||
|
||||
assert matches(resizer, expected)
|
||||
# Verify data-command-id exists (value is dynamic, HTML uses hyphens)
|
||||
assert "data-command-id" in resizer.attrs
|
||||
|
||||
# 5. Icons
|
||||
|
||||
def test_hide_icon_in_left_panel_has_correct_command(self, panel):
|
||||
"""Test that hide icon in left panel triggers toggle_side command.
|
||||
|
||||
Why these elements matter:
|
||||
- TestIconNotStr("subtract20_regular"): Verify correct icon is used for hiding
|
||||
- cls Contains "mf-panel-hide-icon": CSS class for hide icon positioning
|
||||
"""
|
||||
left_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("left")))
|
||||
|
||||
# Find the hide icon (should be wrapped by mk.icon)
|
||||
hide_icons = find(left_panel, Div(cls=Contains("mf-panel-hide-icon")))
|
||||
assert len(hide_icons) == 1, "Left panel should contain exactly one hide icon"
|
||||
|
||||
# Verify it contains the subtract icon
|
||||
expected = Div(
|
||||
TestIconNotStr("subtract20_regular"),
|
||||
cls=Contains("mf-panel-hide-icon")
|
||||
)
|
||||
assert matches(hide_icons[0], expected)
|
||||
|
||||
def test_hide_icon_in_right_panel_has_correct_command(self, panel):
|
||||
"""Test that hide icon in right panel triggers toggle_side command.
|
||||
|
||||
Why these elements matter:
|
||||
- TestIconNotStr("subtract20_regular"): Verify correct icon is used for hiding
|
||||
- cls Contains "mf-panel-hide-icon": CSS class for hide icon positioning
|
||||
"""
|
||||
right_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("right")))
|
||||
|
||||
# Find the hide icon (should be wrapped by mk.icon)
|
||||
hide_icons = find(right_panel, Div(cls=Contains("mf-panel-hide-icon")))
|
||||
assert len(hide_icons) == 1, "Right panel should contain exactly one hide icon"
|
||||
|
||||
# Verify it contains the subtract icon
|
||||
expected = Div(
|
||||
TestIconNotStr("subtract20_regular"),
|
||||
cls=Contains("mf-panel-hide-icon")
|
||||
)
|
||||
assert matches(hide_icons[0], expected)
|
||||
|
||||
def test_show_icon_left_is_hidden_when_panel_visible(self, panel):
|
||||
"""Test that show icon has 'hidden' class when left panel is visible.
|
||||
|
||||
Why these elements matter:
|
||||
- cls Contains "hidden": Tailwind class to hide icon when panel is visible
|
||||
- id: Required for HTMX swap-oob targeting
|
||||
"""
|
||||
show_icon = find_one(panel.render(), Div(id=f"{panel._id}_show_left"))
|
||||
|
||||
expected = Div(
|
||||
cls=Contains("hidden"),
|
||||
id=f"{panel._id}_show_left"
|
||||
)
|
||||
|
||||
assert matches(show_icon, expected)
|
||||
|
||||
def test_show_icon_left_is_visible_when_panel_hidden(self, panel):
|
||||
"""Test that show icon is positioned left when left panel is hidden.
|
||||
|
||||
Why these elements matter:
|
||||
- cls Contains "mf-panel-show-icon-left": CSS class for left positioning in main panel
|
||||
- TestIconNotStr("more_horizontal20_regular"): Verify correct icon is used for showing
|
||||
"""
|
||||
panel._state.left_visible = False
|
||||
show_icon = find_one(panel.render(), Div(id=f"{panel._id}_show_left"))
|
||||
|
||||
expected = Div(
|
||||
TestIconNotStr("more_horizontal20_regular"),
|
||||
cls=Contains("mf-panel-show-icon-left"),
|
||||
id=f"{panel._id}_show_left"
|
||||
)
|
||||
|
||||
assert matches(show_icon, expected)
|
||||
|
||||
def test_show_icon_right_is_visible_when_panel_hidden(self, panel):
|
||||
"""Test that show icon is positioned right when right panel is hidden.
|
||||
|
||||
Why these elements matter:
|
||||
- cls Contains "mf-panel-show-icon-right": CSS class for right positioning in main panel
|
||||
- TestIconNotStr("more_horizontal20_regular"): Verify correct icon is used for showing
|
||||
"""
|
||||
panel._state.right_visible = False
|
||||
show_icon = find_one(panel.render(), Div(id=f"{panel._id}_show_right"))
|
||||
|
||||
expected = Div(
|
||||
TestIconNotStr("more_horizontal20_regular"),
|
||||
cls=Contains("mf-panel-show-icon-right"),
|
||||
id=f"{panel._id}_show_right"
|
||||
)
|
||||
|
||||
assert matches(show_icon, expected)
|
||||
|
||||
# 6. Main panel
|
||||
|
||||
def test_main_panel_contains_show_icons_and_content(self, panel):
|
||||
"""Test that main panel contains show icons and content in correct order.
|
||||
|
||||
Why these elements matter:
|
||||
- 3 children: show_icon_left + inner main div + show_icon_right
|
||||
- Order: Show icons must be positioned correctly (left then right)
|
||||
- cls="mf-panel-main": CSS class for main panel styling
|
||||
- Inner div with id: Main content wrapper for HTMX targeting
|
||||
"""
|
||||
# Find all Divs with cls="mf-panel-main" (there are 2: outer wrapper and inner content)
|
||||
main_panels = find(panel.render(), Div(cls=Contains("mf-panel-main")))
|
||||
assert len(main_panels) == 2, "Should find outer wrapper and inner content div"
|
||||
|
||||
# The outer wrapper is the first one (depth-first search)
|
||||
main_panel = main_panels[0]
|
||||
|
||||
# Step 1: Validate main panel structure
|
||||
expected = Div(
|
||||
Div(id=f"{panel._id}_show_left"), # show icon left
|
||||
Div(id=panel.get_ids().main), # inner main content wrapper
|
||||
Div(id=f"{panel._id}_show_right"), # show icon right
|
||||
cls="mf-panel-main"
|
||||
)
|
||||
|
||||
assert matches(main_panel, expected)
|
||||
|
||||
# 7. Script
|
||||
|
||||
def test_init_resizer_script_is_present(self, panel):
|
||||
"""Test that initResizer script is present with correct panel ID.
|
||||
|
||||
Why these elements matter:
|
||||
- Script content: Must call initResizer with panel ID for resize functionality
|
||||
"""
|
||||
script = find_one(panel.render(), Script())
|
||||
|
||||
expected = TestScript(f"initResizer('{panel._id}');")
|
||||
|
||||
assert matches(script, expected)
|
||||
@@ -1,18 +1,17 @@
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
from fasthtml.common import Div, Span
|
||||
from fasthtml.components import *
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||
from myfasthtml.core.instances import InstancesManager
|
||||
from myfasthtml.test.matcher import matches, find_one, find, Contains, TestIcon, TestScript, TestObject, DoesNotContain, \
|
||||
And, TestIconNotStr
|
||||
from myfasthtml.test.matcher import matches, NoChildren
|
||||
from .conftest import session
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def tabs_manager(root_instance):
|
||||
"""Create a fresh TabsManager instance for each test."""
|
||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||
yield TabsManager(root_instance)
|
||||
|
||||
@@ -21,799 +20,107 @@ def tabs_manager(root_instance):
|
||||
|
||||
|
||||
class TestTabsManagerBehaviour:
|
||||
"""Tests for TabsManager behavior and logic."""
|
||||
def test_tabs_manager_is_registered(self, session, tabs_manager):
|
||||
from_instance_manager = InstancesManager.get(session, tabs_manager.get_id())
|
||||
assert from_instance_manager == tabs_manager
|
||||
|
||||
# =========================================================================
|
||||
# Initialization
|
||||
# =========================================================================
|
||||
|
||||
def test_i_can_create_tabs_manager(self, root_instance):
|
||||
"""Test that TabsManager can be created with default state."""
|
||||
tm = TabsManager(root_instance)
|
||||
|
||||
assert tm is not None
|
||||
assert tm.get_state().tabs == {}
|
||||
assert tm.get_state().tabs_order == []
|
||||
assert tm.get_state().active_tab is None
|
||||
assert tm.get_state().ns_tabs_content == {}
|
||||
assert tm.get_state().ns_tabs_sent_to_client == set()
|
||||
assert tm._tab_count == 0
|
||||
|
||||
# =========================================================================
|
||||
# Tab Creation
|
||||
# =========================================================================
|
||||
|
||||
def test_i_can_create_tab_with_simple_component(self, tabs_manager):
|
||||
"""Test creating a tab with a simple Div component."""
|
||||
tab_id = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
||||
def test_i_can_add_tab(self, tabs_manager):
|
||||
tab_id = tabs_manager.add_tab("Tab1", Div("Content 1"))
|
||||
|
||||
assert tab_id is not None
|
||||
assert tab_id in tabs_manager.get_state().tabs
|
||||
assert tabs_manager.get_state().tabs[tab_id]["label"] == "Tab1"
|
||||
assert tabs_manager.get_state().tabs[tab_id]["id"] == tab_id
|
||||
assert tabs_manager.get_state().tabs[tab_id]["component"] is None
|
||||
assert tabs_manager.get_state().tabs[tab_id]["component_parent"] is None
|
||||
assert tabs_manager.get_state().tabs[tab_id]["component_type"] is None # Div is not BaseInstance
|
||||
assert tabs_manager.get_state().tabs[tab_id]["component_id"] is None # Div is not BaseInstance
|
||||
assert tabs_manager.get_state().tabs_order == [tab_id]
|
||||
assert tabs_manager.get_state().active_tab == tab_id
|
||||
|
||||
def test_i_can_create_tab_with_base_instance(self, tabs_manager):
|
||||
"""Test creating a tab with a BaseInstance component (VisNetwork)."""
|
||||
vis_network = VisNetwork(tabs_manager, nodes=[], edges=[])
|
||||
tab_id = tabs_manager.create_tab("Network", vis_network)
|
||||
|
||||
component_type, component_id = vis_network.get_prefix(), vis_network.get_id()
|
||||
parent_type, parent_id = tabs_manager.get_prefix(), tabs_manager.get_id()
|
||||
|
||||
assert tab_id is not None
|
||||
assert tabs_manager.get_state().tabs[tab_id]["component"] == (component_type, component_id)
|
||||
assert tabs_manager.get_state().tabs[tab_id]["component_parent"] == (parent_type, parent_id)
|
||||
|
||||
def test_i_can_create_multiple_tabs(self, tabs_manager):
|
||||
"""Test creating multiple tabs maintains correct order and activation."""
|
||||
tab_id1 = tabs_manager.create_tab("Users", Div("Content 1"))
|
||||
tab_id2 = tabs_manager.create_tab("User2", Div("Content 2"))
|
||||
def test_i_can_add_multiple_tabs(self, tabs_manager):
|
||||
tab_id1 = tabs_manager.add_tab("Users", Div("Content 1"))
|
||||
tab_id2 = tabs_manager.add_tab("User2", Div("Content 2"))
|
||||
|
||||
assert len(tabs_manager.get_state().tabs) == 2
|
||||
assert tabs_manager.get_state().tabs_order == [tab_id1, tab_id2]
|
||||
assert tabs_manager.get_state().active_tab == tab_id2
|
||||
|
||||
def test_created_tab_is_activated_by_default(self, tabs_manager):
|
||||
"""Test that newly created tab becomes active by default."""
|
||||
tab_id = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
||||
def test_i_can_show_tab(self, tabs_manager):
|
||||
tab_id1 = tabs_manager.add_tab("Tab1", Div("Content 1"))
|
||||
tab_id2 = tabs_manager.add_tab("Tab2", Div("Content 2"))
|
||||
|
||||
assert tabs_manager.get_state().active_tab == tab_id
|
||||
|
||||
def test_i_can_create_tab_without_activating(self, tabs_manager):
|
||||
"""Test creating a tab without activating it."""
|
||||
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
||||
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"), activate=False)
|
||||
|
||||
assert tab_id2 in tabs_manager.get_state().tabs
|
||||
assert tabs_manager.get_state().active_tab == tab_id1
|
||||
|
||||
# =========================================================================
|
||||
# Tab Activation
|
||||
# =========================================================================
|
||||
|
||||
def test_i_can_show_existing_tab(self, tabs_manager):
|
||||
"""Test showing an existing tab activates it."""
|
||||
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
||||
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
|
||||
|
||||
assert tabs_manager.get_state().active_tab == tab_id2
|
||||
assert tabs_manager.get_state().active_tab == tab_id2 # last crated tab is active
|
||||
|
||||
tabs_manager.show_tab(tab_id1)
|
||||
assert tabs_manager.get_state().active_tab == tab_id1
|
||||
|
||||
def test_i_can_show_tab_without_activating(self, tabs_manager):
|
||||
"""Test showing a tab without changing active tab."""
|
||||
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
||||
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
|
||||
|
||||
assert tabs_manager.get_state().active_tab == tab_id2
|
||||
|
||||
tabs_manager.show_tab(tab_id1, activate=False)
|
||||
assert tabs_manager.get_state().active_tab == tab_id2
|
||||
|
||||
def test_show_tab_returns_controller_only_when_already_sent(self, tabs_manager):
|
||||
"""Test that show_tab returns only controller when tab was already sent to client."""
|
||||
tab_id = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
||||
|
||||
# First call: tab not sent yet, returns controller + header + content
|
||||
result1 = tabs_manager.show_tab(tab_id, oob=False)
|
||||
assert len(result1) == 3 # controller, header, wrapped_content
|
||||
assert tab_id in tabs_manager.get_state().ns_tabs_sent_to_client
|
||||
|
||||
# Second call: tab already sent, returns only controller
|
||||
result2 = tabs_manager.show_tab(tab_id, oob=False)
|
||||
assert result2 is not None
|
||||
# Result2 is a single Div (controller), not a tuple
|
||||
assert hasattr(result2, 'tag') and result2.tag == 'div'
|
||||
|
||||
def test_i_cannot_show_nonexistent_tab(self, tabs_manager):
|
||||
"""Test that showing a nonexistent tab returns None."""
|
||||
result = tabs_manager.show_tab("nonexistent_id")
|
||||
|
||||
assert result is None
|
||||
|
||||
# =========================================================================
|
||||
# Tab Closing
|
||||
# =========================================================================
|
||||
|
||||
def test_i_can_close_tab(self, tabs_manager):
|
||||
"""Test closing a tab removes it from state."""
|
||||
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
||||
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
|
||||
tab_id3 = tabs_manager.create_tab("Tab3", Div("Content 3"))
|
||||
tab_id1 = tabs_manager.add_tab("Tab1", Div("Content 1"))
|
||||
tab_id2 = tabs_manager.add_tab("Tab2", Div("Content 2"))
|
||||
tab_id3 = tabs_manager.add_tab("Tab3", Div("Content 3"))
|
||||
|
||||
tabs_manager.close_tab(tab_id2)
|
||||
|
||||
assert len(tabs_manager.get_state().tabs) == 2
|
||||
assert tab_id2 not in tabs_manager.get_state().tabs
|
||||
assert [tab_id for tab_id in tabs_manager.get_state().tabs] == [tab_id1, tab_id3]
|
||||
assert tabs_manager.get_state().tabs_order == [tab_id1, tab_id3]
|
||||
assert tabs_manager.get_state().active_tab == tab_id3
|
||||
assert tabs_manager.get_state().active_tab == tab_id3 # last tab stays active
|
||||
|
||||
def test_closing_active_tab_activates_first_remaining(self, tabs_manager):
|
||||
"""Test that closing the active tab activates the first remaining tab."""
|
||||
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
||||
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
|
||||
tab_id3 = tabs_manager.create_tab("Tab3", Div("Content 3"))
|
||||
def test_i_still_have_an_active_tab_after_close(self, tabs_manager):
|
||||
tab_id1 = tabs_manager.add_tab("Tab1", Div("Content 1"))
|
||||
tab_id2 = tabs_manager.add_tab("Tab2", Div("Content 2"))
|
||||
tab_id3 = tabs_manager.add_tab("Tab3", Div("Content 3"))
|
||||
|
||||
tabs_manager.close_tab(tab_id3) # close the currently active tab
|
||||
assert tabs_manager.get_state().active_tab == tab_id1
|
||||
|
||||
def test_closing_last_tab_sets_active_to_none(self, tabs_manager):
|
||||
"""Test that closing the last remaining tab sets active_tab to None."""
|
||||
tab_id = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
||||
|
||||
tabs_manager.close_tab(tab_id)
|
||||
|
||||
assert len(tabs_manager.get_state().tabs) == 0
|
||||
assert tabs_manager.get_state().active_tab is None
|
||||
|
||||
def test_close_tab_cleans_cache(self, tabs_manager):
|
||||
"""Test that closing a tab removes it from content cache."""
|
||||
tab_id = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
||||
|
||||
# Trigger cache population
|
||||
tabs_manager._get_or_create_tab_content(tab_id)
|
||||
assert tab_id in tabs_manager.get_state().ns_tabs_content
|
||||
|
||||
tabs_manager.close_tab(tab_id)
|
||||
|
||||
assert tab_id not in tabs_manager.get_state().ns_tabs_content
|
||||
|
||||
def test_close_tab_cleans_sent_to_client(self, tabs_manager):
|
||||
"""Test that closing a tab removes it from ns_tabs_sent_to_client."""
|
||||
tab_id = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
||||
|
||||
# Send tab to client
|
||||
tabs_manager.show_tab(tab_id)
|
||||
assert tab_id in tabs_manager.get_state().ns_tabs_sent_to_client
|
||||
|
||||
tabs_manager.close_tab(tab_id)
|
||||
|
||||
assert tab_id not in tabs_manager.get_state().ns_tabs_sent_to_client
|
||||
|
||||
def test_i_cannot_close_nonexistent_tab(self, tabs_manager):
|
||||
"""Test that closing a nonexistent tab returns self without error."""
|
||||
result = tabs_manager.close_tab("nonexistent_id")
|
||||
|
||||
assert result == tabs_manager
|
||||
|
||||
# =========================================================================
|
||||
# Content Management
|
||||
# =========================================================================
|
||||
|
||||
def test_cached_content_is_reused(self, tabs_manager):
|
||||
"""Test that cached content is returned without re-creation."""
|
||||
content = Div("Test Content")
|
||||
tab_id = tabs_manager.create_tab("Tab1", content)
|
||||
|
||||
# First call creates cache
|
||||
result1 = tabs_manager._get_or_create_tab_content(tab_id)
|
||||
|
||||
# Second call should return same object from cache
|
||||
result2 = tabs_manager._get_or_create_tab_content(tab_id)
|
||||
|
||||
assert result1 is result2
|
||||
|
||||
def test_dynamic_get_content_for_base_instance(self, tabs_manager):
|
||||
"""Test that _dynamic_get_content retrieves BaseInstance from InstancesManager."""
|
||||
vis_network = VisNetwork(tabs_manager, nodes=[], edges=[])
|
||||
tab_id = tabs_manager.create_tab("Network", vis_network)
|
||||
|
||||
# Get content dynamically
|
||||
result = tabs_manager._dynamic_get_content(tab_id)
|
||||
|
||||
assert result == vis_network
|
||||
|
||||
def test_dynamic_get_content_for_simple_component(self, tabs_manager):
|
||||
"""Test that _dynamic_get_content returns error message for non-BaseInstance."""
|
||||
content = Div("Simple content")
|
||||
tab_id = tabs_manager.create_tab("Tab1", content)
|
||||
|
||||
# Get content dynamically (should fail gracefully)
|
||||
result = tabs_manager._dynamic_get_content(tab_id)
|
||||
|
||||
# Should return error Div since Div is not serializable
|
||||
assert matches(result, Div('Tab content does not support serialization.'))
|
||||
|
||||
def test_dynamic_get_content_for_nonexistent_tab(self, tabs_manager):
|
||||
"""Test that _dynamic_get_content returns error for nonexistent tab."""
|
||||
result = tabs_manager._dynamic_get_content("nonexistent_id")
|
||||
|
||||
assert matches(result, Div('Tab not found.'))
|
||||
|
||||
# =========================================================================
|
||||
# Content Change
|
||||
# =========================================================================
|
||||
|
||||
def test_i_can_change_tab_content(self, tabs_manager):
|
||||
"""Test changing the content of an existing tab."""
|
||||
tab_id = tabs_manager.create_tab("Tab1", Div("Original Content"))
|
||||
|
||||
new_content = Div("New Content")
|
||||
tabs_manager.change_tab_content(tab_id, "Updated Tab", new_content)
|
||||
|
||||
assert tabs_manager.get_state().tabs[tab_id]["label"] == "Updated Tab"
|
||||
assert tabs_manager.get_state().ns_tabs_content[tab_id] == new_content
|
||||
|
||||
def test_change_tab_content_invalidates_cache(self, tabs_manager):
|
||||
"""Test that changing tab content invalidates the sent_to_client cache."""
|
||||
tab_id = tabs_manager.create_tab("Tab1", Div("Original"))
|
||||
|
||||
# Send tab to client
|
||||
tabs_manager.show_tab(tab_id)
|
||||
assert tab_id in tabs_manager.get_state().ns_tabs_sent_to_client
|
||||
|
||||
# Change content
|
||||
tabs_manager.change_tab_content(tab_id, "Tab1", Div("New Content"))
|
||||
|
||||
# Cache should be invalidated (discard was called)
|
||||
# The show_tab inside change_tab_content will re-add it, but the test
|
||||
# verifies that discard was called by checking the behavior
|
||||
# Since show_tab is called after discard, tab will be in sent_to_client again
|
||||
# We verify the invalidation worked by checking the method was called
|
||||
# This is more of an integration test
|
||||
assert tab_id in tabs_manager.get_state().ns_tabs_sent_to_client
|
||||
|
||||
def test_i_cannot_change_content_of_nonexistent_tab(self, tabs_manager):
|
||||
"""Test that changing content of nonexistent tab returns None."""
|
||||
result = tabs_manager.change_tab_content("nonexistent", "Label", Div("Content"))
|
||||
|
||||
assert result is None
|
||||
|
||||
# =========================================================================
|
||||
# Auto-increment
|
||||
# =========================================================================
|
||||
|
||||
def test_on_new_tab_with_auto_increment(self, tabs_manager):
|
||||
"""Test that on_new_tab with auto_increment appends counter to label."""
|
||||
tabs_manager.on_new_tab("Untitled", None, auto_increment=True)
|
||||
tabs_manager.on_new_tab("Untitled", None, auto_increment=True)
|
||||
tabs_manager.on_new_tab("Untitled", None, auto_increment=True)
|
||||
|
||||
tabs = list(tabs_manager.get_state().tabs.values())
|
||||
assert tabs[0]["label"] == "Untitled_0"
|
||||
assert tabs[1]["label"] == "Untitled_1"
|
||||
assert tabs[2]["label"] == "Untitled_2"
|
||||
|
||||
def test_each_instance_has_independent_counter(self, root_instance):
|
||||
"""Test that each TabsManager instance has its own independent counter."""
|
||||
tm1 = TabsManager(root_instance, _id="tm1")
|
||||
tm2 = TabsManager(root_instance, _id="tm2")
|
||||
|
||||
tm1.on_new_tab("Tab", None, auto_increment=True)
|
||||
tm1.on_new_tab("Tab", None, auto_increment=True)
|
||||
|
||||
tm2.on_new_tab("Tab", None, auto_increment=True)
|
||||
|
||||
# tm1 should have Tab_0 and Tab_1
|
||||
tabs_tm1 = list(tm1.get_state().tabs.values())
|
||||
assert tabs_tm1[0]["label"] == "Tab_0"
|
||||
assert tabs_tm1[1]["label"] == "Tab_1"
|
||||
|
||||
# tm2 should have Tab_0 (independent counter)
|
||||
tabs_tm2 = list(tm2.get_state().tabs.values())
|
||||
assert tabs_tm2[0]["label"] == "Tab_0"
|
||||
|
||||
def test_auto_increment_uses_default_component_when_none(self, tabs_manager):
|
||||
"""Test that on_new_tab uses VisNetwork when component is None."""
|
||||
tabs_manager.on_new_tab("Network", None, auto_increment=False)
|
||||
|
||||
tab_id = tabs_manager.get_state().tabs_order[0]
|
||||
content = tabs_manager.get_state().ns_tabs_content[tab_id]
|
||||
|
||||
assert isinstance(content, VisNetwork)
|
||||
|
||||
# =========================================================================
|
||||
# Duplicate Detection
|
||||
# =========================================================================
|
||||
|
||||
def test_tab_already_exists_with_same_label_and_component(self, tabs_manager):
|
||||
"""Test that duplicate tab is detected and returns existing tab_id."""
|
||||
vis_network = VisNetwork(tabs_manager, nodes=[], edges=[])
|
||||
|
||||
tab_id1 = tabs_manager.create_tab("Network", vis_network)
|
||||
tab_id2 = tabs_manager.create_tab("Network", vis_network)
|
||||
|
||||
# Should return same tab_id
|
||||
assert tab_id1 == tab_id2
|
||||
assert len(tabs_manager.get_state().tabs) == 1
|
||||
|
||||
def test_tab_already_exists_returns_none_for_different_label(self, tabs_manager):
|
||||
"""Test that same component with different label creates new tab."""
|
||||
vis_network = VisNetwork(tabs_manager, nodes=[], edges=[])
|
||||
|
||||
tab_id1 = tabs_manager.create_tab("Network 1", vis_network)
|
||||
tab_id2 = tabs_manager.create_tab("Network 2", vis_network)
|
||||
|
||||
# Should create two different tabs
|
||||
assert tab_id1 != tab_id2
|
||||
assert len(tabs_manager.get_state().tabs) == 2
|
||||
|
||||
def test_tab_already_exists_returns_none_for_non_base_instance(self, tabs_manager):
|
||||
"""Test that Div components are never considered duplicates."""
|
||||
content = Div("Content")
|
||||
|
||||
tab_id1 = tabs_manager.create_tab("Tab", content)
|
||||
tab_id2 = tabs_manager.create_tab("Tab", content)
|
||||
|
||||
# Should create two different tabs (Div is not BaseInstance)
|
||||
assert tab_id1 != tab_id2
|
||||
assert len(tabs_manager.get_state().tabs) == 2
|
||||
|
||||
# =========================================================================
|
||||
# Edge Cases
|
||||
# =========================================================================
|
||||
|
||||
def test_get_ordered_tabs_respects_order(self, tabs_manager):
|
||||
"""Test that _get_ordered_tabs returns tabs in correct order."""
|
||||
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
||||
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
|
||||
tab_id3 = tabs_manager.create_tab("Tab3", Div("Content 3"))
|
||||
|
||||
ordered = tabs_manager._get_ordered_tabs()
|
||||
|
||||
assert list(ordered.keys()) == [tab_id1, tab_id2, tab_id3]
|
||||
|
||||
def test_get_tab_list_returns_only_existing_tabs(self, tabs_manager):
|
||||
"""Test that _get_tab_list is robust to inconsistent state."""
|
||||
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
||||
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
|
||||
|
||||
# Manually corrupt state (tab_id in order but not in tabs dict)
|
||||
tabs_manager._state.tabs_order.append("fake_id")
|
||||
|
||||
tab_list = tabs_manager._get_tab_list()
|
||||
|
||||
# Should only return existing tabs
|
||||
assert len(tab_list) == 2
|
||||
assert all(tab["id"] in [tab_id1, tab_id2] for tab in tab_list)
|
||||
|
||||
def test_state_update_propagates_to_search(self, tabs_manager):
|
||||
"""Test that state updates propagate to the Search component."""
|
||||
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
||||
|
||||
# Search should have 1 item
|
||||
assert len(tabs_manager._search.get_items()) == 1
|
||||
|
||||
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
|
||||
|
||||
# Search should have 2 items
|
||||
assert len(tabs_manager._search.get_items()) == 2
|
||||
|
||||
tabs_manager.close_tab(tab_id1)
|
||||
|
||||
# Search should have 1 item again
|
||||
assert len(tabs_manager._search.get_items()) == 1
|
||||
|
||||
def test_multiple_tabs_managers_in_same_session(self, root_instance):
|
||||
"""Test that multiple TabsManager instances can coexist in same session."""
|
||||
tm1 = TabsManager(root_instance, _id="tm1")
|
||||
tm2 = TabsManager(root_instance, _id="tm2")
|
||||
|
||||
tm1.create_tab("Tab1", Div("Content 1"))
|
||||
tm2.create_tab("Tab2", Div("Content 2"))
|
||||
|
||||
assert len(tm1.get_state().tabs) == 1
|
||||
assert len(tm2.get_state().tabs) == 1
|
||||
assert tm1.get_id() != tm2.get_id()
|
||||
assert tabs_manager.get_state().active_tab == tab_id1 # default to the first tab
|
||||
|
||||
|
||||
class TestTabsManagerRender:
|
||||
"""Tests for TabsManager HTML rendering."""
|
||||
|
||||
@pytest.fixture
|
||||
def tabs_manager(self, root_instance):
|
||||
"""Create a fresh TabsManager instance for render tests."""
|
||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||
tm = TabsManager(root_instance)
|
||||
yield tm
|
||||
InstancesManager.reset()
|
||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||
|
||||
# =========================================================================
|
||||
# Controller
|
||||
# =========================================================================
|
||||
|
||||
def test_i_can_render_tabs_manager_with_no_tabs(self, tabs_manager):
|
||||
"""Test that TabsManager renders with no tabs."""
|
||||
html = tabs_manager.render()
|
||||
def test_i_can_render_when_no_tabs(self, tabs_manager):
|
||||
res = tabs_manager.render()
|
||||
|
||||
expected = Div(
|
||||
Div(id=f"{tabs_manager.get_id()}-controller"), # controller
|
||||
Div(id=f"{tabs_manager.get_id()}-header-wrapper"), # header
|
||||
Div(id=f"{tabs_manager.get_id()}-content-wrapper"), # active content
|
||||
Div(
|
||||
Div(id=f"{tabs_manager.get_id()}-controller"),
|
||||
Script(f'updateTabs("{tabs_manager.get_id()}-controller");'),
|
||||
),
|
||||
Div(
|
||||
Div(NoChildren(), id=f"{tabs_manager.get_id()}-header"),
|
||||
id=f"{tabs_manager.get_id()}-header-wrapper"
|
||||
),
|
||||
Div(
|
||||
Div(id=f"{tabs_manager.get_id()}-None-content"),
|
||||
id=f"{tabs_manager.get_id()}-content-wrapper"
|
||||
),
|
||||
id=tabs_manager.get_id(),
|
||||
)
|
||||
|
||||
assert matches(html, expected)
|
||||
assert matches(res, expected)
|
||||
|
||||
def test_controller_has_correct_id_and_attributes(self, tabs_manager):
|
||||
"""Test that controller has correct ID, data attribute, and HTMX script.
|
||||
|
||||
Why these elements matter:
|
||||
- id: Required for HTMX targeting in commands
|
||||
- data_active_tab: Stores active tab ID for JavaScript access
|
||||
- hx_on__after_settle: Triggers updateTabs() after HTMX swap completes
|
||||
"""
|
||||
tab_id = tabs_manager.create_tab("Tab1", Div("Content"))
|
||||
controller = tabs_manager._mk_tabs_controller(oob=False)
|
||||
def test_i_can_render_when_multiple_tabs(self, tabs_manager):
|
||||
tabs_manager.add_tab("Users1", Div("Content 1"))
|
||||
tabs_manager.add_tab("Users2", Div("Content 2"))
|
||||
tabs_manager.add_tab("Users3", Div("Content 3"))
|
||||
res = tabs_manager.render()
|
||||
|
||||
expected = Div(
|
||||
id=f"{tabs_manager.get_id()}-controller",
|
||||
data_active_tab=tab_id,
|
||||
hx_on__after_settle=f'updateTabs("{tabs_manager.get_id()}-controller");'
|
||||
)
|
||||
|
||||
assert matches(controller, expected)
|
||||
|
||||
def test_controller_with_oob_false(self, tabs_manager):
|
||||
"""Test that controller has no swap attribute when oob=False.
|
||||
|
||||
Why this matters:
|
||||
- hx_swap_oob controls whether element swaps out-of-band
|
||||
- When False, element swaps into target specified in command
|
||||
"""
|
||||
controller = tabs_manager._mk_tabs_controller(oob=False)
|
||||
|
||||
# Controller should not have hx_swap_oob attribute
|
||||
assert not hasattr(controller, 'hx_swap_oob') or controller.attrs.get('hx_swap_oob') is None
|
||||
|
||||
def test_controller_with_oob_true(self, tabs_manager):
|
||||
"""Test that controller has swap attribute when oob=True.
|
||||
|
||||
Why this matters:
|
||||
- hx_swap_oob="true" enables out-of-band swapping
|
||||
- Allows updating controller independently of main target
|
||||
"""
|
||||
controller = tabs_manager._mk_tabs_controller(oob=True)
|
||||
|
||||
expected = Div(hx_swap_oob="true")
|
||||
assert matches(controller, expected)
|
||||
|
||||
# =========================================================================
|
||||
# Header
|
||||
# =========================================================================
|
||||
|
||||
def test_header_wrapper_with_no_tabs(self, tabs_manager):
|
||||
"""Test that header wrapper renders empty when no tabs exist.
|
||||
|
||||
Why these elements matter:
|
||||
- id: Required for targeting header in updates
|
||||
- cls: Provides base styling for header
|
||||
- Empty header div: Shows no tabs are present
|
||||
- Search menu: Always present for adding tabs
|
||||
"""
|
||||
header = tabs_manager._mk_tabs_header_wrapper(oob=False)
|
||||
|
||||
# Should have no children (no tabs)
|
||||
tab_buttons = find(header, Div(cls=Contains("mf-tab-button")))
|
||||
assert len(tab_buttons) == 0, "Header should contain no tab buttons when empty"
|
||||
|
||||
def test_header_wrapper_with_multiple_tabs(self, tabs_manager):
|
||||
"""Test that header wrapper renders all tab buttons.
|
||||
|
||||
Why these elements matter:
|
||||
- Multiple Div elements: Each represents a tab button
|
||||
- Order preservation: Tabs must appear in creation order
|
||||
- cls mf-tab-button: Identifies tab buttons for styling/selection
|
||||
"""
|
||||
tabs_manager.create_tab("Tab1", Div("Content 1"))
|
||||
tabs_manager.create_tab("Tab2", Div("Content 2"))
|
||||
tabs_manager.create_tab("Tab3", Div("Content 3"))
|
||||
|
||||
header = tabs_manager._mk_tabs_header_wrapper(oob=False)
|
||||
|
||||
# Should have 3 tab buttons
|
||||
tab_buttons = find(header, Div(cls=Contains("mf-tab-button")))
|
||||
assert len(tab_buttons) == 3, "Header should contain exactly 3 tab buttons"
|
||||
|
||||
def test_header_wrapper_contains_search_menu(self, tabs_manager):
|
||||
"""Test that header wrapper contains the search menu dropdown.
|
||||
|
||||
Why these elements matter:
|
||||
- dropdown: DaisyUI dropdown container for tab search
|
||||
- dropdown-end: Positions dropdown at end of header
|
||||
- TestIcon for tabs icon: Visual indicator for menu
|
||||
- Search component: Provides tab search functionality
|
||||
"""
|
||||
header = tabs_manager._mk_tabs_header_wrapper(oob=False)
|
||||
|
||||
# Find dropdown menu
|
||||
dropdown = find_one(header, Div(cls=Contains("dropdown", "dropdown-end")))
|
||||
|
||||
# Should contain tabs icon
|
||||
icon = find_one(dropdown, TestIcon("tabs24_regular"))
|
||||
assert icon is not None, "Dropdown should contain tabs icon"
|
||||
|
||||
# Should contain Search component
|
||||
search = find_one(dropdown, TestObject(tabs_manager._search.__class__))
|
||||
assert search is not None, "Dropdown should contain Search component"
|
||||
|
||||
# =========================================================================
|
||||
# Tab Button
|
||||
# =========================================================================
|
||||
|
||||
def test_tab_button_for_active_tab(self, tabs_manager):
|
||||
"""Test that active tab button has the active class.
|
||||
|
||||
Why these elements matter:
|
||||
- cls mf-tab-active: Highlights the currently active tab
|
||||
- data_tab_id: Links button to specific tab
|
||||
- data_manager_id: Links button to TabsManager instance
|
||||
"""
|
||||
tab_id = tabs_manager.create_tab("Active Tab", Div("Content"))
|
||||
tab_data = tabs_manager.get_state().tabs[tab_id]
|
||||
|
||||
button = tabs_manager._mk_tab_button(tab_data)
|
||||
|
||||
expected = Div(
|
||||
cls=Contains("mf-tab-button", "mf-tab-active"),
|
||||
data_tab_id=tab_id,
|
||||
data_manager_id=tabs_manager.get_id()
|
||||
)
|
||||
|
||||
assert matches(button, expected)
|
||||
|
||||
def test_tab_button_for_inactive_tab(self, tabs_manager):
|
||||
"""Test that inactive tab button does not have active class.
|
||||
|
||||
Why these elements matter:
|
||||
- cls without mf-tab-active: Indicates tab is not active
|
||||
- Visual distinction: User can see which tab is selected
|
||||
"""
|
||||
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
||||
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
|
||||
|
||||
# tab_id1 is now inactive
|
||||
tab_data = tabs_manager.get_state().tabs[tab_id1]
|
||||
button = tabs_manager._mk_tab_button(tab_data)
|
||||
|
||||
expected = Div(
|
||||
cls=And(DoesNotContain("mf-tab-active"), Contains("mf-tab-button")),
|
||||
data_tab_id=tab_id1,
|
||||
data_manager_id=tabs_manager.get_id()
|
||||
)
|
||||
|
||||
assert matches(button, expected)
|
||||
|
||||
def test_tab_button_has_label_and_close_icon(self, tabs_manager):
|
||||
"""Test that tab button contains label and close icon.
|
||||
|
||||
Why these elements matter:
|
||||
- Label Span: Displays tab name
|
||||
- Close icon: Allows user to close the tab
|
||||
- cls mf-tab-label: Styles the label text
|
||||
- cls mf-tab-close-btn: Styles the close button
|
||||
"""
|
||||
tab_id = tabs_manager.create_tab("My Tab", Div("Content"))
|
||||
tab_data = tabs_manager.get_state().tabs[tab_id]
|
||||
|
||||
button = tabs_manager._mk_tab_button(tab_data)
|
||||
|
||||
expected = Div(
|
||||
Span("My Tab", cls=Contains("mf-tab-label")),
|
||||
Span(TestIconNotStr("dismiss_circle16_regular"), cls=Contains("mf-tab-close-btn")),
|
||||
)
|
||||
|
||||
assert matches(button, expected)
|
||||
|
||||
# =========================================================================
|
||||
# Content
|
||||
# =========================================================================
|
||||
|
||||
def test_content_wrapper_when_no_active_tab(self, tabs_manager):
|
||||
"""Test that content wrapper shows 'No Content' when no tab is active.
|
||||
|
||||
Why these elements matter:
|
||||
- id content-wrapper: Container for all tab contents
|
||||
- 'No Content' message: Informs user no tab is selected
|
||||
- cls hidden: Hides empty content by default
|
||||
"""
|
||||
wrapper = tabs_manager._mk_tab_content_wrapper()
|
||||
|
||||
# Should contain "No Content" message
|
||||
content = find_one(wrapper, Div(cls=Contains("mf-empty-content")))
|
||||
assert "No Content" in str(content)
|
||||
|
||||
def test_content_wrapper_when_tab_active(self, tabs_manager):
|
||||
"""Test that content wrapper shows active tab content.
|
||||
|
||||
Why these elements matter:
|
||||
- Active tab content is visible
|
||||
- Content is wrapped in mf-tab-content div
|
||||
- Correct tab ID in content div ID
|
||||
"""
|
||||
tab_id = tabs_manager.create_tab("Tab1", Div("My Content"))
|
||||
|
||||
wrapper = tabs_manager._mk_tab_content_wrapper()
|
||||
|
||||
# global view from the wrapper
|
||||
expected = Div(
|
||||
Div(), # tab content, tested just after
|
||||
id=f"{tabs_manager.get_id()}-content-wrapper",
|
||||
cls=Contains("mf-tab-content-wrapper"),
|
||||
)
|
||||
assert matches(wrapper, expected)
|
||||
|
||||
# check if the content is present
|
||||
tab_content = find_one(wrapper, Div(id=f"{tabs_manager.get_id()}-{tab_id}-content"))
|
||||
expected = Div(
|
||||
Div("My Content"), # <= content
|
||||
cls=Contains("mf-tab-content"),
|
||||
)
|
||||
|
||||
assert matches(tab_content, expected)
|
||||
|
||||
def test_tab_content_for_active_tab(self, tabs_manager):
|
||||
"""Test that active tab content does not have hidden class.
|
||||
|
||||
Why these elements matter:
|
||||
- No 'hidden' class: Content is visible to user
|
||||
- Correct content ID: Links to specific tab
|
||||
"""
|
||||
content_div = Div("Test Content")
|
||||
tab_id = tabs_manager.create_tab("Tab1", content_div)
|
||||
|
||||
tab_content_wrapper = tabs_manager._mk_tab_content(tab_id, content_div)
|
||||
tab_content = find_one(tab_content_wrapper, Div(id=f"{tabs_manager.get_id()}-{tab_id}-content"))
|
||||
|
||||
# Should not have 'hidden' in classes
|
||||
classes = tab_content.attrs.get('class', '')
|
||||
assert 'hidden' not in classes
|
||||
assert 'mf-tab-content' in classes
|
||||
|
||||
def test_tab_content_for_inactive_tab(self, tabs_manager):
|
||||
"""Test that inactive tab content has hidden class.
|
||||
|
||||
Why these elements matter:
|
||||
- 'hidden' class: Hides inactive tab content
|
||||
- Content still in DOM: Enables fast tab switching
|
||||
"""
|
||||
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
||||
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
|
||||
|
||||
# tab_id1 is now inactive
|
||||
content_div = Div("Content 1")
|
||||
tab_content = tabs_manager._mk_tab_content(tab_id1, content_div)
|
||||
|
||||
# Should have 'hidden' class
|
||||
expected = Div(cls=Contains("hidden"))
|
||||
assert matches(tab_content, expected)
|
||||
|
||||
def test_i_can_show_a_new_content(self, tabs_manager):
|
||||
"""Test that TabsManager.show_tab() send the correct div to the client"""
|
||||
tab_id = tabs_manager.create_tab("Tab1", Div("My Content"))
|
||||
actual = tabs_manager.show_tab(tab_id)
|
||||
|
||||
expected = (
|
||||
Div(data_active_tab=tab_id,
|
||||
hx_on__after_settle=f'updateTabs("{tabs_manager.get_id()}-controller");',
|
||||
hx_swap_oob="true"), # the controller is correctly updated
|
||||
Div(
|
||||
Div(id=f"{tabs_manager.get_id()}-controller"),
|
||||
Script(f'updateTabs("{tabs_manager.get_id()}-controller");'),
|
||||
),
|
||||
Div(
|
||||
Div(
|
||||
id=f'{tabs_manager.get_id()}-header-wrapper',
|
||||
hx_swap_oob="true"
|
||||
), # content of the header
|
||||
Div(
|
||||
Div(Div("My Content")),
|
||||
hx_swap_oob=f"beforeend:#{tabs_manager.get_id()}-content-wrapper", # hx_swap_oob="beforeend:" important !
|
||||
), # content + where to put it
|
||||
)
|
||||
assert matches(actual, expected)
|
||||
|
||||
def test_i_can_show_content_after_switch(self, tabs_manager):
|
||||
tab_id = tabs_manager.create_tab("Tab1", Div("My Content"))
|
||||
tabs_manager.show_tab(tab_id) # first time, send everything
|
||||
actual = tabs_manager.show_tab(tab_id) # second time, send only the controller
|
||||
|
||||
expected = Div(data_active_tab=tab_id,
|
||||
hx_on__after_settle=f'updateTabs("{tabs_manager.get_id()}-controller");',
|
||||
hx_swap_oob="true")
|
||||
|
||||
assert matches(actual, expected)
|
||||
|
||||
def test_i_can_close_a_tab(self, tabs_manager):
|
||||
tab_id = tabs_manager.create_tab("Tab1", Div("My Content"))
|
||||
tabs_manager.show_tab(tab_id) # was sent
|
||||
actual = tabs_manager.close_tab(tab_id)
|
||||
|
||||
expected = (
|
||||
Div(id=f'{tabs_manager.get_id()}-controller'),
|
||||
Div(id=f'{tabs_manager.get_id()}-header-wrapper'),
|
||||
Div(id=f'{tabs_manager.get_id()}-{tab_id}-content', hx_swap_oob="delete") # hx_swap_oob="delete" important !
|
||||
)
|
||||
|
||||
assert matches(actual, expected)
|
||||
|
||||
def test_i_can_change_content(self, tabs_manager):
|
||||
tab_id = tabs_manager.create_tab("Tab1", Div("My Content"))
|
||||
tabs_manager.show_tab(tab_id)
|
||||
actual = tabs_manager.change_tab_content(tab_id, "New Label", Div("New Content"))
|
||||
|
||||
expected = (
|
||||
Div(data_active_tab=tab_id, hx_swap_oob="true"),
|
||||
Div(id=f'{tabs_manager.get_id()}-header-wrapper', hx_swap_oob="true"),
|
||||
Div(
|
||||
Div("New Content"),
|
||||
id=f'{tabs_manager.get_id()}-{tab_id}-content',
|
||||
hx_swap_oob="outerHTML", # hx_swap_oob="true" important !
|
||||
Div(), # tab_button
|
||||
Div(), # tab_button
|
||||
Div(), # tab_button
|
||||
id=f"{tabs_manager.get_id()}-header"
|
||||
),
|
||||
id=f"{tabs_manager.get_id()}-header-wrapper"
|
||||
),
|
||||
Div(
|
||||
Div("Content 3"), # active tab content
|
||||
# Lasy loading for the other contents
|
||||
id=f"{tabs_manager.get_id()}-content-wrapper"
|
||||
),
|
||||
id=tabs_manager.get_id(),
|
||||
)
|
||||
assert matches(actual, expected)
|
||||
|
||||
# =========================================================================
|
||||
# Complete Render
|
||||
# =========================================================================
|
||||
|
||||
def test_complete_render_with_no_tabs(self, tabs_manager):
|
||||
"""Test complete render structure when no tabs exist.
|
||||
|
||||
Why these elements matter:
|
||||
- cls mf-tabs-manager: Root container class for styling
|
||||
- Controller: HTMX control and state management
|
||||
- Header wrapper: Tab buttons container (empty)
|
||||
- Content wrapper: Tab content container (shows 'No Content')
|
||||
- Script: Initializes tabs on first render
|
||||
"""
|
||||
render = tabs_manager.render()
|
||||
|
||||
# Extract main elements
|
||||
controller = find_one(render, Div(id=f"{tabs_manager.get_id()}-controller"))
|
||||
header = find_one(render, Div(id=f"{tabs_manager.get_id()}-header-wrapper"))
|
||||
content = find_one(render, Div(id=f"{tabs_manager.get_id()}-content-wrapper"))
|
||||
script = find_one(render, TestScript(f'updateTabs("{tabs_manager.get_id()}-controller")'))
|
||||
|
||||
assert controller is not None, "Render should contain controller"
|
||||
assert header is not None, "Render should contain header wrapper"
|
||||
assert content is not None, "Render should contain content wrapper"
|
||||
assert script is not None, "Render should contain initialization script"
|
||||
|
||||
def test_complete_render_with_multiple_tabs(self, tabs_manager):
|
||||
"""Test complete render structure with multiple tabs.
|
||||
|
||||
Why these elements matter:
|
||||
- 3 tab buttons: One for each created tab
|
||||
- Active tab content visible: Shows current tab content
|
||||
- Inactive tabs hidden: Lazy-loaded on activation
|
||||
- Structure consistency: All components present
|
||||
"""
|
||||
tabs_manager.create_tab("Tab1", Div("Content 1"))
|
||||
tabs_manager.create_tab("Tab2", Div("Content 2"))
|
||||
tab_id3 = tabs_manager.create_tab("Tab3", Div("Content 3"))
|
||||
|
||||
render = tabs_manager.render()
|
||||
|
||||
# Find header
|
||||
header_inner = find_one(render, Div(id=f"{tabs_manager.get_id()}-header"))
|
||||
|
||||
# Should have 3 tab buttons
|
||||
tab_buttons = find(header_inner, Div(cls=Contains("mf-tab-button")))
|
||||
assert len(tab_buttons) == 3, "Render should contain exactly 3 tab buttons"
|
||||
|
||||
# Content wrapper should show active tab (tab3)
|
||||
active_content = find_one(render, Div(id=f"{tabs_manager.get_id()}-{tab_id3}-content"))
|
||||
|
||||
expected = Div(Div("Content 3"), cls=Contains("mf-tab-content"))
|
||||
assert matches(active_content, expected), "Active tab content should be visible"
|
||||
assert matches(res, expected)
|
||||
|
||||
@@ -391,197 +391,6 @@ class TestTreeviewBehaviour:
|
||||
assert tree_view._state.items[node1.id].type == "folder"
|
||||
assert tree_view._state.items[node2.id].label == "Node 2"
|
||||
assert tree_view._state.items[node2.id].type == "file"
|
||||
|
||||
def test_i_can_ensure_simple_path(self, root_instance):
|
||||
"""Test that ensure_path creates a simple hierarchical path."""
|
||||
tree_view = TreeView(root_instance)
|
||||
|
||||
tree_view.ensure_path("folder1.folder2.file")
|
||||
|
||||
# Should have 3 nodes
|
||||
assert len(tree_view._state.items) == 3
|
||||
|
||||
# Find nodes by label
|
||||
nodes = list(tree_view._state.items.values())
|
||||
folder1 = [n for n in nodes if n.label == "folder1"][0]
|
||||
folder2 = [n for n in nodes if n.label == "folder2"][0]
|
||||
file_node = [n for n in nodes if n.label == "file"][0]
|
||||
|
||||
# Verify hierarchy
|
||||
assert folder1.parent is None
|
||||
assert folder2.parent == folder1.id
|
||||
assert file_node.parent == folder2.id
|
||||
|
||||
# Verify children relationships
|
||||
assert folder2.id in folder1.children
|
||||
assert file_node.id in folder2.children
|
||||
|
||||
# Verify all nodes have folder type
|
||||
assert folder1.type == "folder"
|
||||
assert folder2.type == "folder"
|
||||
assert file_node.type == "folder"
|
||||
|
||||
def test_i_can_ensure_path_with_existing_nodes(self, root_instance):
|
||||
"""Test that ensure_path reuses existing nodes in the path."""
|
||||
tree_view = TreeView(root_instance)
|
||||
|
||||
# Create initial path
|
||||
tree_view.ensure_path("folder1.folder2.file1")
|
||||
initial_count = len(tree_view._state.items)
|
||||
|
||||
# Ensure same path again
|
||||
tree_view.ensure_path("folder1.folder2.file1")
|
||||
|
||||
# Should not create any new nodes
|
||||
assert len(tree_view._state.items) == initial_count
|
||||
assert len(tree_view._state.items) == 3
|
||||
|
||||
def test_i_can_ensure_path_partially_existing(self, root_instance):
|
||||
"""Test that ensure_path creates only missing nodes when path partially exists."""
|
||||
tree_view = TreeView(root_instance)
|
||||
|
||||
# Create initial path
|
||||
tree_view.ensure_path("folder1.folder2")
|
||||
assert len(tree_view._state.items) == 2
|
||||
|
||||
# Extend the path
|
||||
tree_view.ensure_path("folder1.folder2.subfolder.file")
|
||||
|
||||
# Should have 4 nodes total (folder1, folder2, subfolder, file)
|
||||
assert len(tree_view._state.items) == 4
|
||||
|
||||
# Find all nodes
|
||||
nodes = list(tree_view._state.items.values())
|
||||
folder1 = [n for n in nodes if n.label == "folder1"][0]
|
||||
folder2 = [n for n in nodes if n.label == "folder2"][0]
|
||||
subfolder = [n for n in nodes if n.label == "subfolder"][0]
|
||||
file_node = [n for n in nodes if n.label == "file"][0]
|
||||
|
||||
# Verify new nodes are children of existing path
|
||||
assert subfolder.parent == folder2.id
|
||||
assert file_node.parent == subfolder.id
|
||||
|
||||
def test_i_cannot_ensure_path_with_none(self, root_instance):
|
||||
"""Test that ensure_path raises ValueError when path is None."""
|
||||
tree_view = TreeView(root_instance)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid path.*None"):
|
||||
tree_view.ensure_path(None)
|
||||
|
||||
def test_i_cannot_ensure_path_with_empty_string(self, root_instance):
|
||||
"""Test that ensure_path raises ValueError for empty strings after stripping."""
|
||||
tree_view = TreeView(root_instance)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid path.*empty"):
|
||||
tree_view.ensure_path(" ")
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid path.*empty"):
|
||||
tree_view.ensure_path("")
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid path.*empty"):
|
||||
tree_view.ensure_path("...")
|
||||
|
||||
def test_i_can_ensure_path_strips_leading_trailing_dots(self, root_instance):
|
||||
"""Test that ensure_path strips leading and trailing dots."""
|
||||
tree_view = TreeView(root_instance)
|
||||
|
||||
tree_view.ensure_path(".folder1.folder2.")
|
||||
|
||||
# Should only create 2 nodes (folder1, folder2)
|
||||
assert len(tree_view._state.items) == 2
|
||||
|
||||
nodes = list(tree_view._state.items.values())
|
||||
labels = [n.label for n in nodes]
|
||||
|
||||
assert "folder1" in labels
|
||||
assert "folder2" in labels
|
||||
|
||||
def test_i_can_ensure_path_strips_spaces_in_parts(self, root_instance):
|
||||
"""Test that ensure_path strips spaces from each path part."""
|
||||
tree_view = TreeView(root_instance)
|
||||
|
||||
tree_view.ensure_path("folder1. folder2 . folder3 ")
|
||||
|
||||
# Should create 3 nodes with trimmed labels
|
||||
assert len(tree_view._state.items) == 3
|
||||
|
||||
nodes = list(tree_view._state.items.values())
|
||||
labels = [n.label for n in nodes]
|
||||
|
||||
assert "folder1" in labels
|
||||
assert "folder2" in labels
|
||||
assert "folder3" in labels
|
||||
|
||||
def test_ensure_path_creates_folder_type_nodes(self, root_instance):
|
||||
"""Test that ensure_path creates nodes with type='folder'."""
|
||||
tree_view = TreeView(root_instance)
|
||||
|
||||
tree_view.ensure_path("folder1.folder2")
|
||||
|
||||
for node in tree_view._state.items.values():
|
||||
assert node.type == "folder"
|
||||
|
||||
def test_i_cannot_ensure_path_with_empty_parts(self, root_instance):
|
||||
"""Test that ensure_path raises ValueError for paths with empty parts."""
|
||||
tree_view = TreeView(root_instance)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid path"):
|
||||
tree_view.ensure_path("folder1..folder2")
|
||||
|
||||
def test_i_cannot_ensure_path_with_only_spaces_parts(self, root_instance):
|
||||
"""Test that ensure_path raises ValueError for path parts with only spaces."""
|
||||
tree_view = TreeView(root_instance)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid path"):
|
||||
tree_view.ensure_path("folder1. .folder2")
|
||||
|
||||
def test_ensure_path_returns_last_node_id(self, root_instance):
|
||||
"""Test that ensure_path returns the ID of the last node in the path."""
|
||||
tree_view = TreeView(root_instance)
|
||||
|
||||
# Create a path and get the returned ID
|
||||
returned_id = tree_view.ensure_path("folder1.folder2.folder3")
|
||||
|
||||
# Verify the returned ID is not None
|
||||
assert returned_id is not None
|
||||
|
||||
# Verify the returned ID corresponds to folder3
|
||||
assert returned_id in tree_view._state.items
|
||||
assert tree_view._state.items[returned_id].label == "folder3"
|
||||
|
||||
# Verify we can use this ID to add a child
|
||||
leaf = TreeNode(label="file.txt", type="file")
|
||||
tree_view.add_node(leaf, parent_id=returned_id)
|
||||
|
||||
assert leaf.parent == returned_id
|
||||
assert leaf.id in tree_view._state.items[returned_id].children
|
||||
|
||||
def test_ensure_path_returns_existing_node_id(self, root_instance):
|
||||
"""Test that ensure_path returns ID even when path already exists."""
|
||||
tree_view = TreeView(root_instance)
|
||||
|
||||
# Create initial path
|
||||
first_id = tree_view.ensure_path("folder1.folder2")
|
||||
|
||||
# Ensure same path again
|
||||
second_id = tree_view.ensure_path("folder1.folder2")
|
||||
|
||||
# Should return the same ID
|
||||
assert first_id == second_id
|
||||
assert tree_view._state.items[first_id].label == "folder2"
|
||||
|
||||
def test_i_can_add_the_same_node_id_twice(self, root_instance):
|
||||
"""Test that adding a node with the same ID as an existing node raises ValueError."""
|
||||
tree_view = TreeView(root_instance)
|
||||
|
||||
node1 = TreeNode(label="Node", type="folder", id="existing_id")
|
||||
tree_view.add_node(node1)
|
||||
|
||||
node2 = TreeNode(label="Other Node", type="folder", id="existing_id")
|
||||
tree_view.add_node(node2)
|
||||
|
||||
assert len(tree_view._state.items) == 1, "Node should not have been added to items"
|
||||
assert tree_view._state.items[node1.id] == node2, "Node should not have been replaced"
|
||||
|
||||
|
||||
class TestTreeViewRender:
|
||||
@@ -1001,6 +810,7 @@ class TestTreeViewRender:
|
||||
|
||||
# Step 3: Compare
|
||||
assert matches(keyboard, expected)
|
||||
|
||||
|
||||
def test_multiple_root_nodes_are_rendered(self, tree_view):
|
||||
"""Test that multiple root nodes are rendered at the same level.
|
||||
@@ -1012,18 +822,18 @@ class TestTreeViewRender:
|
||||
"""
|
||||
root1 = TreeNode(label="Root 1", type="folder")
|
||||
root2 = TreeNode(label="Root 2", type="folder")
|
||||
|
||||
|
||||
tree_view.add_node(root1)
|
||||
tree_view.add_node(root2)
|
||||
|
||||
|
||||
rendered = tree_view.render()
|
||||
root_containers = find(rendered, Div(cls=Contains("mf-treenode-container")))
|
||||
|
||||
|
||||
assert len(root_containers) == 2, "Should have two root-level containers"
|
||||
|
||||
|
||||
root1_container = find_one(rendered, Div(data_node_id=root1.id))
|
||||
root2_container = find_one(rendered, Div(data_node_id=root2.id))
|
||||
|
||||
|
||||
expected_root1 = Div(
|
||||
Div(
|
||||
Div(None), # No icon, leaf node
|
||||
@@ -1034,7 +844,7 @@ class TestTreeViewRender:
|
||||
cls="mf-treenode-container",
|
||||
data_node_id=root1.id
|
||||
)
|
||||
|
||||
|
||||
expected_root2 = Div(
|
||||
Div(
|
||||
Div(None), # No icon, leaf node
|
||||
@@ -1045,6 +855,6 @@ class TestTreeViewRender:
|
||||
cls="mf-treenode-container",
|
||||
data_node_id=root2.id
|
||||
)
|
||||
|
||||
|
||||
assert matches(root1_container, expected_root1)
|
||||
assert matches(root2_container, expected_root2)
|
||||
|
||||
@@ -27,27 +27,21 @@ def reset_command_manager():
|
||||
class TestCommandDefault:
|
||||
|
||||
def test_i_can_create_a_command_with_no_params(self):
|
||||
command = Command('test', 'Command description', None, callback)
|
||||
command = Command('test', 'Command description', callback)
|
||||
assert command.id is not None
|
||||
assert command.name == 'test'
|
||||
assert command.description == 'Command description'
|
||||
assert command.execute() == "Hello World"
|
||||
|
||||
def test_commands_are_registered(self):
|
||||
command = Command('test', 'Command description', None, callback)
|
||||
def test_command_are_registered(self):
|
||||
command = Command('test', 'Command description', callback)
|
||||
assert CommandsManager.commands.get(str(command.id)) is command
|
||||
|
||||
def test_commands_with_the_same_key_share_the_same_id(self):
|
||||
command1 = Command('test', 'Command description', None, None, key="test_key")
|
||||
command2 = Command('test', 'Command description', None, None, key="test_key")
|
||||
|
||||
assert command1.id is command2.id
|
||||
|
||||
|
||||
class TestCommandBind:
|
||||
|
||||
def test_i_can_bind_a_command_to_an_element(self):
|
||||
command = Command('test', 'Command description', None, callback)
|
||||
command = Command('test', 'Command description', callback)
|
||||
elt = Button()
|
||||
updated = command.bind_ft(elt)
|
||||
|
||||
@@ -56,7 +50,7 @@ class TestCommandBind:
|
||||
assert matches(updated, expected)
|
||||
|
||||
def test_i_can_suppress_swapping_with_target_attr(self):
|
||||
command = Command('test', 'Command description', None, callback).htmx(target=None)
|
||||
command = Command('test', 'Command description', callback).htmx(target=None)
|
||||
elt = Button()
|
||||
updated = command.bind_ft(elt)
|
||||
|
||||
@@ -76,11 +70,11 @@ class TestCommandBind:
|
||||
|
||||
make_observable(data)
|
||||
bind(data, "value", on_data_change)
|
||||
command = Command('test', 'Command description', None, another_callback).bind(data)
|
||||
command = Command('test', 'Command description', another_callback).bind(data)
|
||||
|
||||
res = command.execute()
|
||||
|
||||
assert res == ["another callback result", "hello", "new value"]
|
||||
assert res == ["another callback result", ("hello", "new value")]
|
||||
|
||||
def test_i_can_bind_a_command_to_an_observable_2(self):
|
||||
data = Data("hello")
|
||||
@@ -94,14 +88,14 @@ class TestCommandBind:
|
||||
|
||||
make_observable(data)
|
||||
bind(data, "value", on_data_change)
|
||||
command = Command('test', 'Command description', None, another_callback).bind(data)
|
||||
command = Command('test', 'Command description', another_callback).bind(data)
|
||||
|
||||
res = command.execute()
|
||||
|
||||
assert res == ["another 1", "another 2", "hello", "new value"]
|
||||
assert res == ["another 1", "another 2", ("hello", "new value")]
|
||||
|
||||
def test_by_default_swap_is_set_to_outer_html(self):
|
||||
command = Command('test', 'Command description', None, callback)
|
||||
command = Command('test', 'Command description', callback)
|
||||
elt = Button()
|
||||
updated = command.bind_ft(elt)
|
||||
|
||||
@@ -119,28 +113,19 @@ class TestCommandBind:
|
||||
def another_callback():
|
||||
return return_values
|
||||
|
||||
command = Command('test', 'Command description', None, another_callback)
|
||||
command = Command('test', 'Command description', another_callback)
|
||||
|
||||
res = command.execute()
|
||||
|
||||
assert "hx_swap_oob" not in res[0].attrs
|
||||
assert res[1].attrs["hx-swap-oob"] == "true"
|
||||
assert res[3].attrs["hx-swap-oob"] == "true"
|
||||
|
||||
def test_i_can_send_parameters(self):
|
||||
command = Command('test', 'Command description', None, None, kwargs={"param": "value"}) # callback is not important
|
||||
elt = Button()
|
||||
updated = command.bind_ft(elt)
|
||||
|
||||
hx_vals = updated.attrs["hx-vals"]
|
||||
assert 'param' in hx_vals
|
||||
assert hx_vals['param'] == 'value'
|
||||
|
||||
|
||||
class TestCommandExecute:
|
||||
|
||||
def test_i_can_create_a_command_with_no_params(self):
|
||||
command = Command('test', 'Command description', None, callback)
|
||||
command = Command('test', 'Command description', callback)
|
||||
assert command.id is not None
|
||||
assert command.name == 'test'
|
||||
assert command.description == 'Command description'
|
||||
@@ -152,7 +137,7 @@ class TestCommandExecute:
|
||||
def callback_with_param(param):
|
||||
return f"Hello {param}"
|
||||
|
||||
command = Command('test', 'Command description', None, callback_with_param, args=["world"])
|
||||
command = Command('test', 'Command description', callback_with_param, "world")
|
||||
assert command.execute() == "Hello world"
|
||||
|
||||
def test_i_can_execute_a_command_with_open_parameter(self):
|
||||
@@ -161,7 +146,7 @@ class TestCommandExecute:
|
||||
def callback_with_param(name):
|
||||
return f"Hello {name}"
|
||||
|
||||
command = Command('test', 'Command description', None, callback_with_param)
|
||||
command = Command('test', 'Command description', callback_with_param)
|
||||
assert command.execute(client_response={"name": "world"}) == "Hello world"
|
||||
|
||||
def test_i_can_convert_arg_in_execute(self):
|
||||
@@ -170,7 +155,7 @@ class TestCommandExecute:
|
||||
def callback_with_param(number: int):
|
||||
assert isinstance(number, int)
|
||||
|
||||
command = Command('test', 'Command description', None, callback_with_param)
|
||||
command = Command('test', 'Command description', callback_with_param)
|
||||
command.execute(client_response={"number": "10"})
|
||||
|
||||
def test_swap_oob_is_added_when_multiple_elements_are_returned(self):
|
||||
@@ -179,7 +164,7 @@ class TestCommandExecute:
|
||||
def another_callback():
|
||||
return Div(id="first"), Div(id="second"), "hello", Div(id="third")
|
||||
|
||||
command = Command('test', 'Command description', None, another_callback)
|
||||
command = Command('test', 'Command description', another_callback)
|
||||
|
||||
res = command.execute()
|
||||
assert "hx-swap-oob" not in res[0].attrs
|
||||
@@ -192,7 +177,7 @@ class TestCommandExecute:
|
||||
def another_callback():
|
||||
return Div(id="first"), Div(), "hello", Div()
|
||||
|
||||
command = Command('test', 'Command description', None, another_callback)
|
||||
command = Command('test', 'Command description', another_callback)
|
||||
|
||||
res = command.execute()
|
||||
assert "hx-swap-oob" not in res[0].attrs
|
||||
@@ -203,9 +188,9 @@ class TestCommandExecute:
|
||||
class TestLambaCommand:
|
||||
|
||||
def test_i_can_create_a_command_from_lambda(self):
|
||||
command = LambdaCommand(None, lambda: "Hello World")
|
||||
command = LambdaCommand(lambda resp: "Hello World")
|
||||
assert command.execute() == "Hello World"
|
||||
|
||||
def test_by_default_target_is_none(self):
|
||||
command = LambdaCommand(None, lambda: "Hello World")
|
||||
command = LambdaCommand(lambda resp: "Hello World")
|
||||
assert command.get_htmx_params()["hx-swap"] == "none"
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from myfasthtml.core.utils import flatten, make_html_id, pascal_to_snake, snake_to_pascal
|
||||
|
||||
|
||||
@pytest.mark.parametrize("input_args,expected,test_description", [
|
||||
# Simple list without nesting
|
||||
(([1, 2, 3],), [1, 2, 3], "simple list"),
|
||||
|
||||
# Nested list (one level)
|
||||
(([1, [2, 3], 4],), [1, 2, 3, 4], "nested list one level"),
|
||||
|
||||
# Nested tuple
|
||||
(((1, (2, 3), 4),), [1, 2, 3, 4], "nested tuple"),
|
||||
|
||||
# Mixed list and tuple
|
||||
(([1, (2, 3), [4, 5]],), [1, 2, 3, 4, 5], "mixed list and tuple"),
|
||||
|
||||
# Deeply nested structure
|
||||
(([1, [2, [3, [4, 5]]]],), [1, 2, 3, 4, 5], "deeply nested structure"),
|
||||
|
||||
# Empty list
|
||||
(([],), [], "empty list"),
|
||||
|
||||
# Empty nested lists
|
||||
(([1, [], [2, []], 3],), [1, 2, 3], "empty nested lists"),
|
||||
|
||||
# Preserves order
|
||||
(([[3, 1], [4, 2]],), [3, 1, 4, 2], "preserves order"),
|
||||
|
||||
# Strings (should not be iterated)
|
||||
((["hello", ["world"]],), ["hello", "world"], "strings not iterated"),
|
||||
|
||||
# Mixed types
|
||||
(([1, "text", [2.5, True], None],), [1, "text", 2.5, True, None], "mixed types"),
|
||||
|
||||
# Multiple arguments with lists
|
||||
(([1, 2], [3, 4], 5), [1, 2, 3, 4, 5], "multiple arguments with lists"),
|
||||
|
||||
# Scalar values only
|
||||
((1, 2, 3), [1, 2, 3], "scalar values only"),
|
||||
|
||||
# Mixed scalars and lists
|
||||
((1, [2, 3], 4, [5, 6]), [1, 2, 3, 4, 5, 6], "mixed scalars and lists"),
|
||||
|
||||
# Multiple nested arguments
|
||||
(([1, [2]], [3, [4]], 5), [1, 2, 3, 4, 5], "multiple nested arguments"),
|
||||
|
||||
# No arguments
|
||||
((), [], "no arguments"),
|
||||
|
||||
# Complex real-world example
|
||||
(([1, [2, 3], [[4, 5], [6, 7]], [[[8, 9]]], 10],), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], "complex nesting"),
|
||||
])
|
||||
def test_i_can_flatten(input_args, expected, test_description):
|
||||
"""Test that flatten correctly handles various nested structures and arguments."""
|
||||
result = flatten(*input_args)
|
||||
assert result == expected, f"Failed for test case: {test_description}"
|
||||
|
||||
@pytest.mark.parametrize("string, expected", [
|
||||
("My Example String!", "My-Example-String_"),
|
||||
("123 Bad ID", "id_123-Bad-ID"),
|
||||
(None, None)
|
||||
])
|
||||
def test_i_can_have_valid_html_id(string, expected):
|
||||
assert make_html_id(string) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("input_str, expected, test_description", [
|
||||
("MyClass", "my_class", "simple PascalCase"),
|
||||
("myVariable", "my_variable", "camelCase"),
|
||||
("HTTPServer", "http_server", "short uppercase sequence"),
|
||||
("XMLHttpRequest", "xml_http_request", "long uppercase sequence"),
|
||||
("A", "a", "single letter"),
|
||||
("already_snake", "already_snake", "already snake_case"),
|
||||
("MyClass123", "my_class123", "with numbers"),
|
||||
("MyLongClassName", "my_long_class_name", "long class name"),
|
||||
(" MyClass ", "my_class", "with spaces to trim"),
|
||||
("iPhone", "i_phone", "starts lowercase then uppercase"),
|
||||
(None, None, "None input"),
|
||||
("", "", "empty string"),
|
||||
(" ", "", "only spaces"),
|
||||
])
|
||||
def test_i_can_convert_pascal_to_snake(input_str, expected, test_description):
|
||||
"""Test that pascal_to_snake correctly converts PascalCase/camelCase to snake_case."""
|
||||
result = pascal_to_snake(input_str)
|
||||
assert result == expected, f"Failed for test case: {test_description}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("input_str, expected, test_description", [
|
||||
("my_class", "MyClass", "simple snake_case"),
|
||||
("my_long_class_name", "MyLongClassName", "long class name"),
|
||||
("a", "A", "single letter"),
|
||||
("myclass", "Myclass", "no underscore"),
|
||||
(" my_class ", "MyClass", "with spaces to trim"),
|
||||
("my__class", "MyClass", "multiple consecutive underscores"),
|
||||
("_my_class", "MyClass", "starts with underscore"),
|
||||
("my_class_", "MyClass", "ends with underscore"),
|
||||
("_my_class_", "MyClass", "starts and ends with underscore"),
|
||||
("my_class_123", "MyClass123", "with numbers"),
|
||||
(None, None, "None input"),
|
||||
("", "", "empty string"),
|
||||
(" ", "", "only spaces"),
|
||||
("___", "", "only underscores"),
|
||||
])
|
||||
def test_i_can_convert_snake_to_pascal(input_str, expected, test_description):
|
||||
"""Test that snake_to_pascal correctly converts snake_case to PascalCase."""
|
||||
result = snake_to_pascal(input_str)
|
||||
assert result == expected, f"Failed for test case: {test_description}"
|
||||
@@ -34,13 +34,13 @@ def rt(user):
|
||||
|
||||
class TestingCommand:
|
||||
def test_i_can_trigger_a_command(self, user):
|
||||
command = Command('test', 'TestingCommand', None, new_value, args=["this is my new value"])
|
||||
command = Command('test', 'TestingCommand', new_value, "this is my new value")
|
||||
testable = TestableElement(user, mk.button('button', command))
|
||||
testable.click()
|
||||
assert user.get_content() == "this is my new value"
|
||||
|
||||
def test_error_is_raised_when_command_is_not_found(self, user):
|
||||
command = Command('test', 'TestingCommand', None, new_value, args=["this is my new value"])
|
||||
command = Command('test', 'TestingCommand', new_value, "this is my new value")
|
||||
CommandsManager.reset()
|
||||
testable = TestableElement(user, mk.button('button', command))
|
||||
|
||||
@@ -50,7 +50,7 @@ class TestingCommand:
|
||||
assert "not found." in str(exc_info.value)
|
||||
|
||||
def test_i_can_play_a_complex_scenario(self, user, rt):
|
||||
command = Command('test', 'TestingCommand', None, new_value, args=["this is my new value"])
|
||||
command = Command('test', 'TestingCommand', new_value, "this is my new value")
|
||||
|
||||
@rt('/')
|
||||
def get(): return mk.button('button', command)
|
||||
|
||||
@@ -74,8 +74,8 @@ def test_i_can_manage_notstr_success_path(ft, to_search, expected):
|
||||
(NotStr("hello my friend"), TestObject(NotStr, s="hello")),
|
||||
])
|
||||
def test_test_i_can_manage_notstr_failure_path(ft, to_search):
|
||||
res = find(ft, to_search)
|
||||
assert res == []
|
||||
with pytest.raises(AssertionError):
|
||||
find(ft, to_search)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('ft, expected', [
|
||||
@@ -85,4 +85,5 @@ def test_test_i_can_manage_notstr_failure_path(ft, to_search):
|
||||
(Div(id="id2"), Div(id="id1")),
|
||||
])
|
||||
def test_i_cannot_find(ft, expected):
|
||||
assert find(expected, ft) == []
|
||||
with pytest.raises(AssertionError):
|
||||
find(expected, ft)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import pytest
|
||||
from fastcore.basics import NotStr
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.icons.fluent_p3 import add20_regular
|
||||
from myfasthtml.test.matcher import *
|
||||
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, ErrorOutput, \
|
||||
ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren, TestObject, Skip, DoNotCheck, TestIcon, HasHtmx
|
||||
from myfasthtml.test.testclient import MyFT
|
||||
|
||||
|
||||
@@ -463,19 +465,6 @@ class TestPredicates:
|
||||
div = Div(hx_post="/url")
|
||||
assert HasHtmx(hx_post="/url").validate(div)
|
||||
|
||||
c = Command("c", "testing has_htmx", None, None)
|
||||
c = Command("c", "testing has_htmx", None)
|
||||
c.bind_ft(div)
|
||||
assert HasHtmx(command=c).validate(div)
|
||||
|
||||
def test_i_can_use_and(self):
|
||||
contains1 = Contains("value1")
|
||||
contains2 = Contains("value2")
|
||||
not_contains1 = DoesNotContain("value1")
|
||||
not_contains2 = DoesNotContain("value2")
|
||||
|
||||
assert And(contains1, contains2).validate("value1 value2")
|
||||
assert And(contains1, not_contains2).validate("value1")
|
||||
assert And(not_contains1, contains2).validate("value2")
|
||||
assert And(not_contains1, not_contains2).validate("value3")
|
||||
|
||||
assert not And(contains1, not_contains2).validate("value2")
|
||||
|
||||
Reference in New Issue
Block a user