django-spire 0.22.4__py3-none-any.whl → 0.23.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. django_spire/auth/group/forms.py +3 -4
  2. django_spire/auth/group/utils.py +1 -2
  3. django_spire/auth/group/views/form_views.py +22 -14
  4. django_spire/auth/group/views/page_views.py +1 -1
  5. django_spire/auth/mfa/utils.py +1 -1
  6. django_spire/auth/permissions/consts.py +2 -1
  7. django_spire/auth/permissions/decorators.py +1 -2
  8. django_spire/auth/permissions/permissions.py +1 -3
  9. django_spire/comment/factories.py +1 -1
  10. django_spire/comment/mixins.py +1 -1
  11. django_spire/comment/views.py +1 -1
  12. django_spire/consts.py +1 -1
  13. django_spire/contrib/breadcrumb/breadcrumbs.py +1 -1
  14. django_spire/contrib/form/confirmation_forms.py +1 -1
  15. django_spire/contrib/form/utils.py +26 -16
  16. django_spire/contrib/generic_views/modal_views.py +1 -1
  17. django_spire/contrib/generic_views/portal_views.py +18 -55
  18. django_spire/contrib/ordering/validators.py +4 -8
  19. django_spire/contrib/queryset/enums.py +2 -3
  20. django_spire/contrib/queryset/mixins.py +17 -6
  21. django_spire/contrib/session/controller.py +0 -1
  22. django_spire/core/context_processors.py +1 -1
  23. django_spire/core/management/commands/spire_startapp_pkg/user_input.py +1 -1
  24. django_spire/core/middleware/maintenance.py +2 -1
  25. django_spire/core/middleware.py +2 -1
  26. django_spire/core/redirect/generic_redirect.py +1 -1
  27. django_spire/core/redirect/safe_redirect.py +2 -1
  28. django_spire/core/shortcuts.py +4 -2
  29. django_spire/core/table/__init__.py +0 -0
  30. django_spire/core/table/enums.py +18 -0
  31. django_spire/core/templates/django_spire/card/infinite_scroll_card.html +3 -137
  32. django_spire/core/templates/django_spire/container/infinite_scroll_container.html +64 -0
  33. django_spire/core/templates/django_spire/infinite_scroll/base.html +348 -0
  34. django_spire/core/templates/django_spire/infinite_scroll/element/footer.html +11 -0
  35. django_spire/core/templates/django_spire/infinite_scroll/scroll.html +142 -0
  36. django_spire/core/templates/django_spire/item/infinite_scroll_item.html +33 -0
  37. django_spire/core/templates/django_spire/lazy_tab/element/lazy_tab_section_element.html +19 -0
  38. django_spire/core/templates/django_spire/lazy_tab/element/lazy_tab_trigger_element.html +15 -0
  39. django_spire/core/templates/django_spire/lazy_tab/lazy_tab.html +157 -0
  40. django_spire/core/templates/django_spire/page/infinite_scroll_list_page.html +7 -0
  41. django_spire/core/templates/django_spire/table/base.html +185 -373
  42. django_spire/core/templates/django_spire/table/element/footer.html +7 -15
  43. django_spire/core/templates/django_spire/table/element/header.html +1 -1
  44. django_spire/core/templates/django_spire/table/element/row.html +15 -7
  45. django_spire/core/templatetags/spire_core_tags.py +1 -2
  46. django_spire/file/fields.py +1 -1
  47. django_spire/file/interfaces.py +1 -1
  48. django_spire/file/views.py +1 -1
  49. django_spire/history/activity/utils.py +1 -1
  50. django_spire/history/mixins.py +0 -4
  51. django_spire/notification/app/context_data.py +3 -1
  52. django_spire/notification/app/views/json_views.py +1 -1
  53. django_spire/notification/app/views/page_views.py +2 -1
  54. django_spire/notification/app/views/template_views.py +2 -2
  55. django_spire/profiling/middleware/profiling.py +2 -2
  56. django_spire/profiling/panel.py +2 -2
  57. django_spire/testing/__init__.py +0 -0
  58. django_spire/testing/playwright/__init__.py +64 -0
  59. django_spire/testing/playwright/components/__init__.py +45 -0
  60. django_spire/testing/playwright/components/accordion.py +55 -0
  61. django_spire/testing/playwright/components/attribute_element.py +73 -0
  62. django_spire/testing/playwright/components/base_session_filter_form.py +57 -0
  63. django_spire/testing/playwright/components/breadcrumb_element.py +56 -0
  64. django_spire/testing/playwright/components/card.py +102 -0
  65. django_spire/testing/playwright/components/dropdown.py +87 -0
  66. django_spire/testing/playwright/components/infinite_scroll.py +158 -0
  67. django_spire/testing/playwright/components/lazy_tab.py +92 -0
  68. django_spire/testing/playwright/components/modal.py +101 -0
  69. django_spire/testing/playwright/components/navigation.py +119 -0
  70. django_spire/testing/playwright/components/notification_bell.py +59 -0
  71. django_spire/testing/playwright/components/theme_selector.py +46 -0
  72. django_spire/testing/playwright/components/toast.py +72 -0
  73. django_spire/testing/playwright/fixtures.py +54 -0
  74. django_spire/testing/playwright/pages/__init__.py +6 -0
  75. django_spire/testing/playwright/pages/base.py +24 -0
  76. django_spire/theme/models.py +1 -1
  77. django_spire/theme/tests/test_context_processor.py +0 -1
  78. django_spire/theme/views/json_views.py +1 -1
  79. django_spire/theme/views/page_views.py +1 -1
  80. {django_spire-0.22.4.dist-info → django_spire-0.23.2.dist-info}/METADATA +4 -1
  81. {django_spire-0.22.4.dist-info → django_spire-0.23.2.dist-info}/RECORD +84 -54
  82. {django_spire-0.22.4.dist-info → django_spire-0.23.2.dist-info}/WHEEL +0 -0
  83. {django_spire-0.22.4.dist-info → django_spire-0.23.2.dist-info}/licenses/LICENSE.md +0 -0
  84. {django_spire-0.22.4.dist-info → django_spire-0.23.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from playwright.sync_api import Locator
7
+
8
+
9
+ class Dropdown:
10
+ """Playwright component for django_spire/dropdown/dropdown.html"""
11
+
12
+ menu_selector: str = '.position-absolute.shadow-lg.card'
13
+ trigger_selector: str = '[x-bind="trigger"]'
14
+
15
+ def __init__(self, parent_locator: Locator) -> None:
16
+ self.parent = parent_locator
17
+
18
+ @property
19
+ def menu(self) -> Locator:
20
+ return self.parent.page.locator(self.menu_selector).filter(
21
+ has=self.parent.page.locator(':visible')
22
+ ).first
23
+
24
+ @property
25
+ def trigger(self) -> Locator:
26
+ return self.parent.locator(self.trigger_selector)
27
+
28
+ def click_option(self, text: str) -> None:
29
+ self.menu.locator(f'text={text}').click()
30
+
31
+ def close(self) -> None:
32
+ if self.is_open():
33
+ self.trigger.click()
34
+
35
+ def get_option(self, text: str) -> Locator:
36
+ return self.menu.locator(f'text={text}')
37
+
38
+ def has_option(self, text: str) -> bool:
39
+ return self.get_option(text).is_visible()
40
+
41
+ def is_open(self) -> bool:
42
+ return self.menu.is_visible()
43
+
44
+ def open(self) -> None:
45
+ if not self.is_open():
46
+ self.trigger.click()
47
+
48
+
49
+ class EllipsisDropdown(Dropdown):
50
+ """Playwright component for django_spire/dropdown/ellipsis_dropdown.html"""
51
+
52
+ trigger_selector: str = '.bi-three-dots-vertical'
53
+
54
+ def click_delete(self) -> None:
55
+ self.click_option('Delete')
56
+
57
+ def click_edit(self) -> None:
58
+ self.click_option('Edit')
59
+
60
+ def click_view(self) -> None:
61
+ self.click_option('View')
62
+
63
+ def has_delete_option(self) -> bool:
64
+ return self.has_option('Delete')
65
+
66
+ def has_edit_option(self) -> bool:
67
+ return self.has_option('Edit')
68
+
69
+ def has_view_option(self) -> bool:
70
+ return self.has_option('View')
71
+
72
+
73
+ class EllipsisModalDropdown(EllipsisDropdown):
74
+ """
75
+ Playwright component for django_spire/dropdown/ellipsis_modal_dropdown.html
76
+ Dropdown options trigger modals via dispatch_modal_view()
77
+ """
78
+
79
+ pass
80
+
81
+
82
+ class EllipsisTableDropdown(EllipsisDropdown):
83
+ """
84
+ Playwright component for django_spire/dropdown/ellipsis_table_dropdown.html
85
+ Used in table rows, positioned start-0 instead of end-0
86
+ """
87
+ trigger_selector: str = 'td .bi-three-dots-vertical'
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from playwright.sync_api import Locator, Page
7
+
8
+
9
+ class InfiniteScroll:
10
+ """
11
+ Playwright component for django_spire/infinite_scroll/base.html
12
+ and django_spire/infinite_scroll/scroll.html
13
+ """
14
+
15
+ def __init__(self, page: Page, container_selector: str = '[x-ref="scroll_container"]') -> None:
16
+ self.container_selector = container_selector
17
+ self.page = page
18
+
19
+ @property
20
+ def content_container(self) -> Locator:
21
+ return self.page.locator('[x-ref="content_container"]')
22
+
23
+ @property
24
+ def loaded_count_text(self) -> Locator:
25
+ return self.page.locator('[x-text="loaded_count"]')
26
+
27
+ @property
28
+ def scroll_container(self) -> Locator:
29
+ return self.page.locator(self.container_selector)
30
+
31
+ @property
32
+ def spinner(self) -> Locator:
33
+ return self.page.locator('.spinner-border')
34
+
35
+ @property
36
+ def total_count_text(self) -> Locator:
37
+ return self.page.locator('[x-text="total_count"]')
38
+
39
+ def get_loaded_count(self) -> int:
40
+ return int(self.loaded_count_text.inner_text())
41
+
42
+ def get_total_count(self) -> int:
43
+ return int(self.total_count_text.inner_text())
44
+
45
+ def is_loading(self) -> bool:
46
+ return self.spinner.is_visible()
47
+
48
+ def scroll_to_bottom(self) -> None:
49
+ self.scroll_container.evaluate('el => el.scrollTop = el.scrollHeight')
50
+
51
+ def scroll_to_top(self) -> None:
52
+ self.scroll_container.evaluate('el => el.scrollTop = 0')
53
+
54
+ def wait_for_count_to_increase(self, initial_count: int, timeout: int = 5000) -> None:
55
+ self.page.wait_for_function(
56
+ f'() => parseInt(document.querySelector("[x-text=\\"loaded_count\\"]").textContent) > {initial_count}',
57
+ timeout=timeout
58
+ )
59
+
60
+ def wait_for_items_to_load(self) -> None:
61
+ self.loaded_count_text.wait_for()
62
+
63
+
64
+ class InfiniteScrollList(InfiniteScroll):
65
+ """Playwright component for infinite scroll with list items"""
66
+
67
+ item_selector: str = '[data-row-id]'
68
+
69
+ @property
70
+ def items(self) -> Locator:
71
+ return self.page.locator(self.item_selector)
72
+
73
+ def get_item(self, index: int) -> Locator:
74
+ return self.items.nth(index)
75
+
76
+ def get_item_count(self) -> int:
77
+ return self.items.count()
78
+
79
+ def get_item_ids(self) -> list[str]:
80
+ items = self.items
81
+ return [items.nth(i).get_attribute('data-row-id') for i in range(items.count())]
82
+
83
+
84
+ class InfiniteScrollTable(InfiniteScroll):
85
+ """Playwright component for django_spire/table/base.html"""
86
+
87
+ row_selector: str = 'tbody tr[data-row-id]'
88
+ skeleton_selector: str = '.skeleton-box'
89
+
90
+ def __init__(self, page: Page, container_selector: str = '.table-container[x-ref="scroll_container"]') -> None:
91
+ super().__init__(page, container_selector)
92
+
93
+ @property
94
+ def rows(self) -> Locator:
95
+ return self.page.locator(self.row_selector)
96
+
97
+ @property
98
+ def select_all_checkbox(self) -> Locator:
99
+ return self.page.locator('thead input[type="checkbox"]')
100
+
101
+ @property
102
+ def selected_count_text(self) -> Locator:
103
+ return self.page.locator('[x-text="selected_rows.size"]')
104
+
105
+ @property
106
+ def skeleton_rows(self) -> Locator:
107
+ return self.page.locator(self.skeleton_selector)
108
+
109
+ @property
110
+ def table(self) -> Locator:
111
+ return self.page.locator('table')
112
+
113
+ def click_header(self, header_text: str) -> None:
114
+ self.get_header(header_text).click()
115
+
116
+ def deselect_all_rows(self) -> None:
117
+ self.select_all_checkbox.click()
118
+
119
+ def deselect_row(self, index: int) -> None:
120
+ self.get_row(index).locator('input[type="checkbox"]').click()
121
+
122
+ def get_first_row_text(self) -> str:
123
+ return self.rows.first.inner_text()
124
+
125
+ def get_header(self, header_text: str) -> Locator:
126
+ return self.page.locator(f'th:has-text("{header_text}")')
127
+
128
+ def get_row(self, index: int) -> Locator:
129
+ return self.rows.nth(index)
130
+
131
+ def get_row_count(self) -> int:
132
+ return self.rows.count()
133
+
134
+ def get_selected_count(self) -> int:
135
+ return int(self.selected_count_text.inner_text())
136
+
137
+ def get_sort_icon(self, header_text: str) -> Locator:
138
+ return self.get_header(header_text).locator('i.bi')
139
+
140
+ def is_sorted_ascending(self, header_text: str) -> bool:
141
+ icon = self.get_sort_icon(header_text)
142
+ return 'bi-chevron-up' in (icon.get_attribute('class') or '')
143
+
144
+ def is_sorted_descending(self, header_text: str) -> bool:
145
+ icon = self.get_sort_icon(header_text)
146
+ return 'bi-chevron-down' in (icon.get_attribute('class') or '')
147
+
148
+ def select_all_rows(self) -> None:
149
+ self.select_all_checkbox.click()
150
+
151
+ def select_row(self, index: int) -> None:
152
+ self.get_row(index).locator('input[type="checkbox"]').click()
153
+
154
+ def wait_for_rows_to_load(self) -> None:
155
+ self.rows.first.wait_for()
156
+
157
+ def wait_for_table(self) -> None:
158
+ self.table.wait_for()
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from playwright.sync_api import Locator, Page
9
+
10
+
11
+ class LazyTab:
12
+ """Playwright component for django_spire/lazy_tab/lazy_tab.html"""
13
+
14
+ selected_class: str = 'tab-item'
15
+
16
+ def __init__(self, page: Page, container_selector: str, tab_id: str | None = None) -> None:
17
+ self.container_selector = container_selector
18
+ self.page = page
19
+ self.tab_id = tab_id
20
+
21
+ @property
22
+ def container(self) -> Locator:
23
+ return self.page.locator(self.container_selector)
24
+
25
+ @property
26
+ def sections(self) -> Locator:
27
+ return self.container.locator('[role="tabpanel"]')
28
+
29
+ @property
30
+ def triggers(self) -> Locator:
31
+ return self.container.locator('[role="tab"]')
32
+
33
+ def click_tab(self, index: int) -> None:
34
+ self.get_trigger(index).click()
35
+
36
+ def get_section(self, index: int) -> Locator:
37
+ return self.sections.nth(index)
38
+
39
+ def get_section_count(self) -> int:
40
+ return self.sections.count()
41
+
42
+ def get_trigger(self, index: int) -> Locator:
43
+ return self.triggers.nth(index)
44
+
45
+ def get_trigger_count(self) -> int:
46
+ return self.triggers.count()
47
+
48
+ def get_url_param(self) -> str | None:
49
+ if not self.tab_id:
50
+ return None
51
+
52
+ url = self.page.url
53
+ match = re.search(f'{self.tab_id}=([^&]+)', url)
54
+
55
+ if match:
56
+ return match.group(1)
57
+
58
+ return None
59
+
60
+ def get_visible_section(self) -> Locator | None:
61
+ for i in range(self.sections.count()):
62
+ section = self.sections.nth(i)
63
+
64
+ if section.is_visible():
65
+ return section
66
+
67
+ return None
68
+
69
+ def get_visible_section_text(self) -> str:
70
+ section = self.get_visible_section()
71
+
72
+ if section:
73
+ return section.inner_text()
74
+
75
+ return ''
76
+
77
+ def is_loading(self, index: int) -> bool:
78
+ section = self.get_section(index)
79
+ spinner = section.locator('.spinner-border')
80
+ return spinner.is_visible()
81
+
82
+ def is_tab_selected(self, index: int) -> bool:
83
+ trigger = self.get_trigger(index)
84
+ classes = trigger.get_attribute('class') or ''
85
+ return self.selected_class in classes
86
+
87
+ def wait_for_section_content(self, index: int, timeout: int = 5000) -> None:
88
+ section = self.get_section(index)
89
+ section.locator('.spinner-border').wait_for(state='hidden', timeout=timeout)
90
+
91
+ def wait_for_tabs_to_load(self) -> None:
92
+ self.triggers.first.wait_for()
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from playwright.sync_api import Locator, Page
7
+
8
+
9
+ class Modal:
10
+ """Playwright component for django_spire/modal/modal.html"""
11
+
12
+ def __init__(self, page: Page) -> None:
13
+ self.page = page
14
+
15
+ @property
16
+ def close_button(self) -> Locator:
17
+ return self.modal.locator('[\\@click*="close_modal"]').first
18
+
19
+ @property
20
+ def content(self) -> Locator:
21
+ return self.modal.locator('.bg-app-layer-one.card')
22
+
23
+ @property
24
+ def modal(self) -> Locator:
25
+ return self.page.locator('[x-show="show_modal"]').last
26
+
27
+ @property
28
+ def overlay(self) -> Locator:
29
+ return self.page.locator('[\\@click="close_modal"]').first
30
+
31
+ def close(self) -> None:
32
+ self.close_button.click()
33
+
34
+ def close_by_overlay(self) -> None:
35
+ self.overlay.click()
36
+
37
+ def is_open(self) -> bool:
38
+ return self.modal.is_visible()
39
+
40
+ def wait_for_close(self, timeout: int = 5000) -> None:
41
+ self.modal.wait_for(state='hidden', timeout=timeout)
42
+
43
+ def wait_for_open(self, timeout: int = 5000) -> None:
44
+ self.modal.wait_for(state='visible', timeout=timeout)
45
+
46
+
47
+ class TitleModal(Modal):
48
+ """Playwright component for django_spire/modal/title_modal.html"""
49
+
50
+ @property
51
+ def title(self) -> Locator:
52
+ return self.modal.locator('.text-uppercase.text-app-secondary-dark.h6').first
53
+
54
+ def get_title_text(self) -> str:
55
+ return self.title.inner_text()
56
+
57
+
58
+ class FormModal(TitleModal):
59
+ """Playwright component for modals containing forms"""
60
+
61
+ @property
62
+ def cancel_button(self) -> Locator:
63
+ return self.modal.locator('button:has-text("Cancel"), [type="button"]:has-text("Cancel")').first
64
+
65
+ @property
66
+ def form(self) -> Locator:
67
+ return self.modal.locator('form')
68
+
69
+ @property
70
+ def submit_button(self) -> Locator:
71
+ return self.modal.locator('button[type="submit"], input[type="submit"]').first
72
+
73
+ def cancel(self) -> None:
74
+ self.cancel_button.click()
75
+
76
+ def fill_field(self, name: str, value: str) -> None:
77
+ self.form.locator(f'[name="{name}"]').fill(value)
78
+
79
+ def get_field_value(self, name: str) -> str:
80
+ return self.form.locator(f'[name="{name}"]').input_value()
81
+
82
+ def submit(self) -> None:
83
+ self.submit_button.click()
84
+
85
+
86
+ class DeleteModal(TitleModal):
87
+ """Playwright component for delete confirmation modals"""
88
+
89
+ @property
90
+ def cancel_button(self) -> Locator:
91
+ return self.modal.locator('button:has-text("Cancel")').first
92
+
93
+ @property
94
+ def confirm_button(self) -> Locator:
95
+ return self.modal.locator('button:has-text("Delete"), button:has-text("Confirm")').first
96
+
97
+ def cancel(self) -> None:
98
+ self.cancel_button.click()
99
+
100
+ def confirm(self) -> None:
101
+ self.confirm_button.click()
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from playwright.sync_api import Locator, Page
7
+
8
+
9
+ class SideNavigation:
10
+ """Playwright component for django_spire/navigation/side_navigation.html"""
11
+
12
+ def __init__(self, page: Page) -> None:
13
+ self.page = page
14
+
15
+ @property
16
+ def container(self) -> Locator:
17
+ return self.page.locator('.side-navigation')
18
+
19
+ @property
20
+ def links(self) -> Locator:
21
+ return self.container.locator('a.nav-link')
22
+
23
+ @property
24
+ def scroll_container(self) -> Locator:
25
+ return self.container.locator('[x-ref="scroll_container"]')
26
+
27
+ def click_link(self, text: str) -> None:
28
+ self.get_link_by_text(text).click()
29
+
30
+ def get_link_by_text(self, text: str) -> Locator:
31
+ return self.container.locator(f'a.nav-link:has-text("{text}")')
32
+
33
+ def get_link_count(self) -> int:
34
+ return self.links.count()
35
+
36
+ def get_link_texts(self) -> list[str]:
37
+ return [self.links.nth(i).inner_text() for i in range(self.get_link_count())]
38
+
39
+ def has_link(self, text: str) -> bool:
40
+ return self.get_link_by_text(text).count() > 0
41
+
42
+ def is_visible(self) -> bool:
43
+ return self.container.is_visible()
44
+
45
+
46
+ class TopNavigation:
47
+ """Playwright component for django_spire/navigation/top_navigation.html"""
48
+
49
+ def __init__(self, page: Page) -> None:
50
+ self.page = page
51
+
52
+ @property
53
+ def container(self) -> Locator:
54
+ return self.page.locator('.top-navigation')
55
+
56
+ @property
57
+ def notification_bell(self) -> Locator:
58
+ return self.container.locator('.bi-bell')
59
+
60
+ @property
61
+ def theme_selector(self) -> Locator:
62
+ return self.container.locator('.bi-sun-fill, .bi-moon-fill')
63
+
64
+ @property
65
+ def title(self) -> Locator:
66
+ return self.container.locator('.fs-5.fw-bold, .fs-md-1.fw-bold').first
67
+
68
+ @property
69
+ def user_menu(self) -> Locator:
70
+ return self.container.locator('.bi-person-circle')
71
+
72
+ def click_notification_bell(self) -> None:
73
+ self.notification_bell.click()
74
+
75
+ def click_theme_selector(self) -> None:
76
+ self.theme_selector.click()
77
+
78
+ def click_user_menu(self) -> None:
79
+ self.user_menu.click()
80
+
81
+ def get_title_text(self) -> str:
82
+ return self.title.inner_text()
83
+
84
+ def is_visible(self) -> bool:
85
+ return self.container.is_visible()
86
+
87
+
88
+ class UserMenu:
89
+ """Playwright component for user dropdown menu in top navigation"""
90
+
91
+ def __init__(self, page: Page) -> None:
92
+ self.page = page
93
+
94
+ @property
95
+ def menu(self) -> Locator:
96
+ return self.page.locator('.dropdown-menu[aria-labelledby="dropdownMenuButton1"]')
97
+
98
+ @property
99
+ def trigger(self) -> Locator:
100
+ return self.page.locator('#dropdownMenuButton1')
101
+
102
+ def click_admin_panel(self) -> None:
103
+ self.menu.locator('a:has-text("Admin Panel")').click()
104
+
105
+ def click_change_password(self) -> None:
106
+ self.menu.locator('a:has-text("Change Password")').click()
107
+
108
+ def click_logout(self) -> None:
109
+ self.menu.locator('a:has-text("Logout")').click()
110
+
111
+ def click_theme_dashboard(self) -> None:
112
+ self.menu.locator('a:has-text("Theme Dashboard")').click()
113
+
114
+ def is_open(self) -> bool:
115
+ return self.menu.is_visible()
116
+
117
+ def open(self) -> None:
118
+ if not self.is_open():
119
+ self.trigger.click()
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from playwright.sync_api import Locator, Page
7
+
8
+
9
+ class NotificationBell:
10
+ """Playwright component for django_spire/notification/app/element/notification_bell.html"""
11
+
12
+ def __init__(self, page: Page) -> None:
13
+ self.page = page
14
+
15
+ @property
16
+ def badge(self) -> Locator:
17
+ return self.bell.locator('.badge, .position-absolute')
18
+
19
+ @property
20
+ def bell(self) -> Locator:
21
+ return self.page.locator('.bi-bell').first
22
+
23
+ @property
24
+ def dropdown(self) -> Locator:
25
+ return self.page.locator('.notification-dropdown, [x-show*="notification"]')
26
+
27
+ def click(self) -> None:
28
+ self.bell.click()
29
+
30
+ def get_badge_count(self) -> int:
31
+ if not self.has_badge():
32
+ return 0
33
+
34
+ text = self.badge.inner_text()
35
+
36
+ if text.isdigit():
37
+ return int(text)
38
+
39
+ return 0
40
+
41
+ def has_badge(self) -> bool:
42
+ return self.badge.count() > 0 and self.badge.is_visible()
43
+
44
+ def has_notifications(self) -> bool:
45
+ return self.has_badge() and self.get_badge_count() > 0
46
+
47
+ def is_dropdown_open(self) -> bool:
48
+ return self.dropdown.is_visible()
49
+
50
+ def is_visible(self) -> bool:
51
+ return self.bell.is_visible()
52
+
53
+ def open_dropdown(self) -> None:
54
+ if not self.is_dropdown_open():
55
+ self.click()
56
+
57
+ def close_dropdown(self) -> None:
58
+ if self.is_dropdown_open():
59
+ self.click()
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from playwright.sync_api import Locator, Page
7
+
8
+
9
+ class ThemeSelector:
10
+ """Playwright component for django_spire/theme/element/theme_selector.html"""
11
+
12
+ def __init__(self, page: Page) -> None:
13
+ self.page = page
14
+
15
+ @property
16
+ def icon(self) -> Locator:
17
+ return self.page.locator('.bi-sun-fill, .bi-moon-fill').first
18
+
19
+ def click(self) -> None:
20
+ self.icon.click()
21
+
22
+ def get_current_mode(self) -> str:
23
+ html = self.page.locator('html')
24
+ return html.get_attribute('data-theme') or 'light'
25
+
26
+ def get_current_theme_family(self) -> str:
27
+ html = self.page.locator('html')
28
+ return html.get_attribute('data-theme-family') or ''
29
+
30
+ def is_dark_mode(self) -> bool:
31
+ return self.get_current_mode() == 'dark'
32
+
33
+ def is_light_mode(self) -> bool:
34
+ return self.get_current_mode() == 'light'
35
+
36
+ def is_visible(self) -> bool:
37
+ return self.icon.is_visible()
38
+
39
+ def toggle(self) -> None:
40
+ self.click()
41
+
42
+ def wait_for_theme_change(self, expected_mode: str, timeout: int = 5000) -> None:
43
+ self.page.wait_for_function(
44
+ f'() => document.documentElement.getAttribute("data-theme") === "{expected_mode}"',
45
+ timeout=timeout
46
+ )