diff --git a/integration/loadtesting/.gitignore b/integration/loadtesting/.gitignore new file mode 100644 index 000000000..33cc43fc4 --- /dev/null +++ b/integration/loadtesting/.gitignore @@ -0,0 +1,5 @@ +*.db +*.py[cod] +.web +__pycache__/ +env/ \ No newline at end of file diff --git a/integration/loadtesting/README.md b/integration/loadtesting/README.md new file mode 100644 index 000000000..8e3472148 --- /dev/null +++ b/integration/loadtesting/README.md @@ -0,0 +1,41 @@ + + +# Load testing for Reflex using Locust + +Steps to build locally - + + +Create virtual env +``` +python3 -m venv env +``` + +Activate the virtual env +``` +source env/bin/activate +``` + +Install dependencies +``` +pip install -r requirements.txt +``` + +Also, install locust and locust plugin +``` +pip install locust +pip install locust-plugins[websocket] +``` + +Start the reflex app using poetry +``` +poetry run reflex run +``` + +Start locust in a new terminal window +``` +locust +``` + +This will start a web app on `http://localhost:8089` + +Configure the number of user and the ramp up. Watch results flow in. When done, terminate the locust process and get aggregate data in the terminal. \ No newline at end of file diff --git a/integration/loadtesting/assets/favicon.ico b/integration/loadtesting/assets/favicon.ico new file mode 100644 index 000000000..166ae995e Binary files /dev/null and b/integration/loadtesting/assets/favicon.ico differ diff --git a/integration/loadtesting/assets/github.svg b/integration/loadtesting/assets/github.svg new file mode 100644 index 000000000..61c9d791b --- /dev/null +++ b/integration/loadtesting/assets/github.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/integration/loadtesting/locustfile.py b/integration/loadtesting/locustfile.py new file mode 100644 index 000000000..358aa627a --- /dev/null +++ b/integration/loadtesting/locustfile.py @@ -0,0 +1,239 @@ +from locust import task,between +from locust_plugins.users.socketio import SocketIOUser +import uuid +import json +import time + +""" +Pending things to be completed - +1. Error handling +2. Code cleanup and moving variables into config file +3. Documentation +""" + +class RecursiveJSONEncoder(json.JSONEncoder): + + level = 0 + def default(self, obj): + if isinstance(obj, dict): + self.level += 1 + if self.level == 1: + return json.dumps({key: self.default(value) for key, value in obj.items()}, cls=RecursiveJSONEncoder) + else: + return {key: self.default(value) for key, value in obj.items()} + + elif isinstance(obj, list): + return [self.default(element) if not isinstance(element, (str, int, float, bool, type(None))) else element for element in obj] + elif isinstance(obj, (str, int, float, bool, type(None))): + return obj + else: + return json.JSONEncoder.default(self, obj) + + +class ReflexLoadTest(SocketIOUser): + + token = None + + wait_time = between(1, 5) + + request_start_time = None + + def validate_init_connection_message(self, message:str) -> bool: + conn_response_code = "0" + if len(message.strip()) > 0: + message = message.strip() + message_code = message[0] + if message_code == conn_response_code: + extracted_message_json = json.loads(message[1:]) + self.session_sid = extracted_message_json['sid'] + self.session_data = extracted_message_json + return True + + else: + return False + + def validate_init_event_session_message(self, message: str) -> bool: + init_event_response_code = "40/_event" + if len(message) > 0 and message.count(",") > 0: + message_code, message_body = message.split(",") + if message_code == init_event_response_code: + extracted_message_json = json.loads(message_body) + self.event_sid = extracted_message_json['sid'] + self.event_session_data = extracted_message_json + return True + + else: + return False + + def validate_event_response_message(self, message: str) -> bool: + event_response_code = "42/_event" + if len(message) > 0 and message.count(","): + message_code = message.split(",")[0] + print(message_code) + if message_code == event_response_code: + return True + + def register_listener(self, callback_func, event_name): + self.is_waiting_for_message = True + self.callback_func = callback_func + self.event_name = event_name + self.request_start_time = time.time() + print("setting request start time here", self.request_start_time) + + def remove_listener(self): + self.is_waiting_for_message = False + self.callback_func = None + self.event_name = None + self.request_start_time = None + + + def on_message(self, message): + if self.is_waiting_for_message and self.callback_func: + message_match_flag = self.callback_func(message) + if message_match_flag == True: + print(message, time.time(), self.request_start_time) + res_time = time.time() - self.request_start_time + self.fire_response_time_event(res_time, message) + self.remove_listener() + + def fire_response_time_event(self, res_time, message): + self.environment.events.request.fire( + request_type="WSR", + name=self.event_name, + response_time=res_time, + response_length=len(message), + exception=None, + context=self.context(), + ) + + def wait_for_message(self): + counter = 0 + while self.is_waiting_for_message: + #Todo Add timeout exception here + time.sleep(0.05) + counter += 0.05 + + def get_conn_route(self): + # todo put this in config file + host = "localhost" + port = 8000 + protocol = "ws" + url = f"{protocol}://{host}:{str(port)}" + + event_suffix = "/_event/?EIO=4&transport=websocket" + conn_route = url + event_suffix + return conn_route + + def on_start(self): + #todo: Add custom exceptions + try: + self.connect_to_remote() + self.send_access_event() + except: + print("something went wrong") + + def connect_to_remote(self): + conn_route = self.get_conn_route() + self.register_listener(self.validate_init_connection_message, "connection_init_response") + self.connect(conn_route) + self.wait_for_message() + + + def send_access_event(self): + message = self.construct_event_message("access_event") + self.register_listener(self.validate_init_event_session_message, "event_namespace_access_response") + self.send(message, name="_event_namespace_access_request") + self.wait_for_message() + self.token = str(uuid.uuid4()) + + # def disconnect_socket_connection(self): + # print("disconnecting connection") + # self.send("41/_event", name="disconnect") + + def construct_event_message(self, event_type): + event_code = str(self.get_event_code(event_type)) + event_message = (self.get_event_message(event_type)) + return f'{event_code}/_event,{event_message}' + + def get_event_code(self, event_type): + match event_type: + case "access_event": + return 40 + + case _: + return 42 + + def get_event_message(self, event_type): + token = self.token + match event_type: + case "access_event": + return json.dumps([]) + + case "pure_event": + payload = { + 'token': token, + 'name': 'state.set_is_hydrated', + 'router_data': {'pathname': '/', 'query': {}, 'asPath': '/'}, + 'payload': {'value': True} + } + return json.dumps(RecursiveJSONEncoder().default(["event",payload])) + + + case "state_update": + payload = { + 'name': 'state.base_state.toggle_query', + 'payload': {}, + 'handler': None, + 'token': token, + 'router_data': {'pathname': '/', 'query': {}, 'asPath': '/'} + } + return json.dumps(RecursiveJSONEncoder().default(["event",payload])) + + + case "substate_update": + payload = { + 'name': 'state.base_state.query_state.query_api.delta_limit', + 'payload': {'limit': '20'}, + 'handler': None, + 'token': token, + 'router_data': {'pathname': '/', 'query': {}, 'asPath': '/'} + } + return json.dumps(RecursiveJSONEncoder().default(["event",payload])) + + + @task + def test_pure_function(self): + message = self.construct_event_message("pure_event") + print(message) + self.register_listener(self.validate_event_response_message, "pure_function_response") + self.send(message, name="test_pure_function") + self.wait_for_message() + + @task + def test_state_update(self): + message = self.construct_event_message("state_update") + print(message) + self.register_listener(self.validate_event_response_message, "state_update_response") + self.send(message, name="test_state_update") + self.wait_for_message() + + + @task + def test_substate_update(self): + message = self.construct_event_message("substate_update") + print(message) + self.register_listener(self.validate_event_response_message, "substate_update_response") + self.send(message, name="test_substate_update") + self.wait_for_message() + + + + + @task + def test_idle_connection(self): + self.sleep_with_heartbeat(10) + + + + +# 42/_event,["event","{\"token\":\"99c13759-97a1-48eb-fe71-e3ee8f034869\",\"name\":\"state.set_is_hydrated\",\"router_data\":{\"pathname\":\"/\",\"query\":{},\"asPath\":\"/\"},\"payload\":{\"value\":true}}"] \ No newline at end of file diff --git a/integration/loadtesting/rxconfig.py b/integration/loadtesting/rxconfig.py new file mode 100644 index 000000000..96ca2d582 --- /dev/null +++ b/integration/loadtesting/rxconfig.py @@ -0,0 +1,5 @@ +import reflex as rx + +config = rx.Config( + app_name="sandbox", +) \ No newline at end of file diff --git a/integration/loadtesting/sandbox/__init__.py b/integration/loadtesting/sandbox/__init__.py new file mode 100644 index 000000000..e1d286346 --- /dev/null +++ b/integration/loadtesting/sandbox/__init__.py @@ -0,0 +1 @@ +"""Base template for Reflex.""" diff --git a/integration/loadtesting/sandbox/components/__init__.py b/integration/loadtesting/sandbox/components/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/integration/loadtesting/sandbox/components/drawer.py b/integration/loadtesting/sandbox/components/drawer.py new file mode 100644 index 000000000..b0f10754f --- /dev/null +++ b/integration/loadtesting/sandbox/components/drawer.py @@ -0,0 +1,60 @@ +import reflex as rx + +from sandbox.states.queries import QueryAPI + + +def render_data(data: tuple[str, str]): + return rx.vstack( + rx.text(data[0], weight="bold"), + rx.input( + value=data[1], + width="100%", + on_change=lambda value: QueryAPI.update_data(value, data), + ), + width="100%", + spacing="2", + ) + + +def render_drawer_buttons(name: str, color: str, function: callable): + return rx.badge( + rx.text(name, width="100%", text_align="center"), + color_scheme=color, + on_click=function, + variant="surface", + padding="0.75em 1.25em", + width="100%", + cursor="pointer", + ) + + +def render_drawer(): + return rx.drawer.root( + rx.drawer.overlay(z_index="5"), + rx.drawer.portal( + rx.drawer.content( + rx.vstack( + rx.foreach(QueryAPI.selected_entry, render_data), + rx.vstack( + render_drawer_buttons( + "Commit", "grass", QueryAPI.commit_changes + ), + render_drawer_buttons("Close", "ruby", QueryAPI.delta_drawer), + padding="1em 0.5em", + width="inherit", + ), + bg=rx.color_mode_cond("#faf9fb", "#1a181a"), + height="100%", + width="100%", + padding="1.25em", + ), + top="auto", + left="auto", + height="100%", + width="25em", + on_interact_outside=QueryAPI.delta_drawer(), + ), + ), + direction="right", + open=QueryAPI.is_open, + ) diff --git a/integration/loadtesting/sandbox/components/navbar.py b/integration/loadtesting/sandbox/components/navbar.py new file mode 100644 index 000000000..3a05db603 --- /dev/null +++ b/integration/loadtesting/sandbox/components/navbar.py @@ -0,0 +1,40 @@ +import reflex as rx + +from sandbox.states.base import BaseState +from sandbox.styles import text + +navbar: dict[str, str] = { + "width": "100%", + "padding": "1em 1.15em", + "justify_content": "space-between", + "bg": rx.color_mode_cond( + "rgba(255, 255, 255, 0.81)", + "rgba(18, 17, 19, 0.81)", + ), + "align_items": "center", + "border_bottom": "1px solid rgba(46, 46, 46, 0.51)", +} + + +def render_navbar(): + return rx.hstack( + rx.hstack( + rx.box( + rx.text( + "REST API Admin Panel", + font_size="var(--chakra-fontSizes-lg)", + **text, + ), + ), + display="flex", + align_items="center", + ), + rx.hstack( + rx.button( + BaseState.is_request, on_click=BaseState.toggle_query, cursor="pointer" + ), + rx.color_mode.button(), + align_items="center", + ), + **navbar, + ) diff --git a/integration/loadtesting/sandbox/components/output.py b/integration/loadtesting/sandbox/components/output.py new file mode 100644 index 000000000..d7be6577a --- /dev/null +++ b/integration/loadtesting/sandbox/components/output.py @@ -0,0 +1,93 @@ +import reflex as rx + +from sandbox.states.queries import QueryAPI +from sandbox.components.drawer import render_drawer + + +def create_table_header(title: str): + return rx.table.column_header_cell(title) + + +def create_query_rows(data: dict[str, str]): + def fill_rows_with_data(data_): + return rx.table.cell( + f"{data_[1]}", + on_click=QueryAPI.display_selected_row(data), + cursor="pointer", + ) + + return rx.table.row( + rx.foreach(data, fill_rows_with_data), + _hover={"bg": rx.color(color="gray", shade=4)}, + ) + + +def create_pagination(): + return rx.hstack( + rx.hstack( + rx.text("Entries per page", weight="bold"), + rx.select( + QueryAPI.limits, default_value="10", on_change=QueryAPI.delta_limit + ), + align_items="center", + ), + rx.hstack( + rx.text( + f"Page {QueryAPI.current_page}/{QueryAPI.total_pages}", + width="100px", + weight="bold", + ), + rx.chakra.button_group( + rx.icon( + tag="chevron-left", on_click=QueryAPI.previous, cursor="pointer" + ), + rx.icon(tag="chevron-right", on_click=QueryAPI.next, cursor="pointer"), + is_attached=True, + ), + align_items="center", + spacing="1", + ), + align_items="center", + spacing="4", + ) + + +def render_output(): + return rx.center( + rx.cond( + QueryAPI.get_data, + rx.vstack( + render_drawer(), + create_pagination(), + rx.table.root( + rx.table.header( + rx.table.row( + rx.foreach(QueryAPI.get_table_headers, create_table_header) + ), + ), + rx.table.body( + rx.foreach(QueryAPI.paginated_data, create_query_rows) + ), + width="100%", + variant="surface", + size="1", + ), + rx.text( + "* Click a row to edit its contents.", + weight="bold", + size="1", + ), + width="100%", + overflow="auto", + padding="2em 2em", + ), + rx.spacer(), + ), + flex="60%", + bg=rx.color_mode_cond( + "#faf9fb", + "#1a181a", + ), + border_radius="10px", + overflow="auto", + ) diff --git a/integration/loadtesting/sandbox/components/query.py b/integration/loadtesting/sandbox/components/query.py new file mode 100644 index 000000000..5333b93ca --- /dev/null +++ b/integration/loadtesting/sandbox/components/query.py @@ -0,0 +1,213 @@ +import reflex as rx + +from sandbox.states.base import BaseState +from sandbox.states.queries import QueryState, QueryAPI +from sandbox.styles import text + + +def item_title(title: str): + return rx.hstack( + rx.text(title, font_size="var(--chakra-fontSizes-sm)", **text), + rx.chakra.accordion_icon(), + width="100%", + justify_content="space-between", + ) + + +def item_add_event(event_trigger: callable): + return rx.button( + rx.hstack( + rx.text("+"), + rx.text("add", weight="bold"), + width="100%", + justify_content="space-between", + ), + size="1", + on_click=event_trigger, + padding="0.35em 0.75em", + cursor="pointer", + color_scheme="gray", + ) + + +def form_item_entry(data: dict[str, str]): + + def create_entry(title: str, function: callable): + return ( + rx.input( + placeholder=title, + width="100%", + on_change=function, + variant="surface", + ), + ) + + return rx.hstack( + create_entry("key", lambda key: QueryState.update_keyy(key, data)), + create_entry("value", lambda value: QueryState.update_value(value, data)), + rx.button( + "DEL", + on_click=QueryState.remove_entry(data), + color_scheme="ruby", + variant="surface", + cursor="pointer", + ), + width="100%", + spacing="1", + ) + + +def form_item( + title: str, state: list[dict[str, str]], func: callable, event_trigger: callable +): + return rx.chakra.accordion( + rx.chakra.accordion_item( + rx.chakra.accordion_button(item_title(title)), + rx.chakra.accordion_panel( + item_add_event(event_trigger), + width="100%", + display="flex", + justify_content="end", + ), + rx.chakra.accordion_panel( + rx.vstack(rx.foreach(state, func), width="100%", spacing="1") + ), + ), + allow_toggle=True, + width="100%", + border="transparent", + ) + + +def form_body_param_item( + state: list[dict[str, str]], func: callable, event_trigger: callable +): + return rx.chakra.accordion( + rx.chakra.accordion_item( + rx.chakra.accordion_button(item_title("Body")), + rx.chakra.accordion_panel( + rx.match( + QueryState.current_req, + ( + "GET", + rx.select( + QueryState.get_params_body, + default_value="None", + width="100%", + ), + ), + ( + "POST", + rx.vstack( + rx.hstack( + item_add_event(event_trigger), + width="100%", + justify_content="end", + ), + rx.select( + QueryState.post_params_body, + default_value="JSON", + width="100%", + ), + rx.vstack( + rx.foreach(state, func), width="100%", spacing="1" + ), + width="100%", + ), + ), + ), + ), + ), + allow_toggle=True, + width="100%", + border="transparent", + ) + + +def form_request_item(): + return rx.chakra.accordion( + rx.chakra.accordion_item( + rx.chakra.accordion_button(item_title("Requests")), + rx.chakra.accordion_panel( + rx.hstack( + rx.select( + QueryState.req_methods, + width="120px", + default_value="GET", + on_change=QueryState.get_request, + ), + rx.input( + value=QueryState.req_url, + width="100%", + on_change=QueryState.set_req_url, + placeholder="https://example_site.com/api/v2/endpoint.json", + ), + width="100%", + ) + ), + ), + allow_toggle=True, + width="100%", + border="transparent", + ) + + +def render_query_form(): + return rx.vstack( + # form_request_item(), + form_item( + "Headers", QueryState.headers, form_item_entry, QueryState.add_header + ), + form_body_param_item(QueryState.body, form_item_entry, QueryState.add_body), + form_item( + "Cookies", QueryState.cookies, form_item_entry, QueryState.add_cookies + ), + width="100%", + spacing="2", + padding="0em 0.75em", + ) + + +def render_query_header(): + return rx.hstack( + rx.hstack( + rx.select( + QueryState.req_methods, + width="100px", + default_value="GET", + on_change=QueryState.get_request, + ), + rx.input( + value=QueryState.req_url, + width="100%", + on_change=QueryState.set_req_url, + placeholder="https://example_site.com/api/v2/endpoint.json", + ), + width="100%", + spacing="1", + ), + rx.button( + "Send", size="2", on_click=QueryAPI.run_get_request, cursor="pointer" + ), + width="100%", + border_bottom=rx.color_mode_cond( + "1px solid rgba(45, 45, 45, 0.05)", "1px solid rgba(45, 45, 45, 0.51)" + ), + padding="1em 0.75em", + justify_content="end", + ) + + +def render_query_component(): + return rx.vstack( + render_query_header(), + render_query_form(), + flex=["100%", "100%", "100%", "100%", "30%"], + display=BaseState.query_component_toggle, + padding_bottom="0.75em", + border_radius="10px", + bg=rx.color_mode_cond( + "#faf9fb", + "#1a181a", + ), + ) diff --git a/integration/loadtesting/sandbox/pages/__init__.py b/integration/loadtesting/sandbox/pages/__init__.py new file mode 100644 index 000000000..093f101ae --- /dev/null +++ b/integration/loadtesting/sandbox/pages/__init__.py @@ -0,0 +1 @@ +from .dashboard import dashboard diff --git a/integration/loadtesting/sandbox/pages/dashboard.py b/integration/loadtesting/sandbox/pages/dashboard.py new file mode 100644 index 000000000..7a64752c0 --- /dev/null +++ b/integration/loadtesting/sandbox/pages/dashboard.py @@ -0,0 +1,30 @@ +"""The dashboard page.""" + +import reflex as rx + +from sandbox.components.navbar import render_navbar +from sandbox.components.query import render_query_component +from sandbox.components.output import render_output +from sandbox.states.queries import QueryAPI + + +@rx.page("/", on_load=QueryAPI.run_get_request) +def dashboard() -> rx.Component: + """The dashboard page. + + Returns: + The UI for the dashboard page. + """ + return rx.vstack( + render_navbar(), + rx.hstack( + render_query_component(), + render_output(), + width="100%", + display="flex", + flex_wrap="wrap", + spacing="6", + padding="2em 1em", + ), + spacing="4", + ) diff --git a/integration/loadtesting/sandbox/sandbox.py b/integration/loadtesting/sandbox/sandbox.py new file mode 100644 index 000000000..82faa906a --- /dev/null +++ b/integration/loadtesting/sandbox/sandbox.py @@ -0,0 +1,9 @@ +import reflex as rx +from sandbox.pages import * + + +class State(rx.State): + """Define empty state to allow access to rx.State.router.""" + + +app = rx.App() diff --git a/integration/loadtesting/sandbox/states/__init__.py b/integration/loadtesting/sandbox/states/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/integration/loadtesting/sandbox/states/base.py b/integration/loadtesting/sandbox/states/base.py new file mode 100644 index 000000000..31addfa1c --- /dev/null +++ b/integration/loadtesting/sandbox/states/base.py @@ -0,0 +1,17 @@ +import reflex as rx + + +class BaseState(rx.State): + + query_component_toggle: str = "none" + + is_request: str = "New Request" + + def toggle_query(self): + self.query_component_toggle = ( + "flex" if self.query_component_toggle == "none" else "none" + ) + + self.is_request = ( + "New Request" if self.query_component_toggle == "none" else "Close Request" + ) diff --git a/integration/loadtesting/sandbox/states/queries.py b/integration/loadtesting/sandbox/states/queries.py new file mode 100644 index 000000000..ac47f933f --- /dev/null +++ b/integration/loadtesting/sandbox/states/queries.py @@ -0,0 +1,180 @@ +import uuid +import httpx +from sandbox.states.base import BaseState + + +# test URL: str = https://jsonplaceholder.typicode.com/posts +# test URL: str = https://jsonplaceholder.typicode.com/todos + + +class QueryState(BaseState): + + # vars for handling request calls ... + req_methods: list[str] = ["GET", "POST"] + req_url: str = "https://jsonplaceholder.typicode.com/posts" + current_req: str = "GET" + get_params_body: list[str] = ["JSON", "Raw", "None"] + post_params_body: list[str] = [ + "JSON", + "Raw", + "x-www-form-urlencoded", + "Form Data", + "Binary", + "None", + ] + + # params. for triggering API calls ... + url_param: dict[str, str] + headers: list[dict[str, str]] + body: list[dict[str, str]] + cookies: list[dict[str, str]] + + # vars for GET request ... + get_data: list[dict[str, str]] + get_table_headers: list[str] + paginated_data: list[dict[str, str]] + + # vars for pagination ... + number_of_rows: int + limits: list[str] = ["10", "20", "50"] + current_limit: int = 10 + offset: int = 0 + current_page: int = 1 + total_pages: int = 1 + formatted_headers: dict + + def get_request(self, method: str): + self.current_req = method + + def add_header(self): + self.headers.append( + {"id": str(uuid.uuid4()), "identifier": "headers", "key": "", "value": ""} + ) + + def add_body(self): + self.body.append( + {"id": str(uuid.uuid4()), "identifier": "body", "key": "", "value": ""} + ) + + def add_cookies(self): + self.cookies.append( + {"id": str(uuid.uuid4()), "identifier": "cookies", "key": "", "value": ""} + ) + + def pure(self): + return + + def remove_entry(self, data: dict[str, str]): + if data["identifier"] == "headers": + self.headers = [item for item in self.headers if item["id"] != data["id"]] + + if data["identifier"] == "body": + self.body = [item for item in self.body if item["id"] != data["id"]] + + if data["identifier"] == "cookies": + self.cookies = [item for item in self.cookies if item["id"] != data["id"]] + + async def update_attribute(self, data: dict[str, str], attribute: str, value: str): + data[attribute] = value + + if data["identifier"] == "headers": + self.headers = [ + data if item["id"] == data["id"] else item for item in self.headers + ] + + if data["identifier"] == "body": + self.body = [ + data if item["id"] == data["id"] else item for item in self.body + ] + + if data["identifier"] == "cookies": + self.cookies = [ + data if item["id"] == data["id"] else item for item in self.cookies + ] + + async def update_keyy(self, key: str, data: dict[str, str]): + await self.update_attribute(data, "key", key) + + async def update_value(self, value: str, data: dict[str, str]): + await self.update_attribute(data, "value", value) + + +class QueryAPI(QueryState): + + # vars to update row entries ... + is_open: bool = False + selected_entry: dict[str, str] + original_entry: dict[str, str] + + async def process_headers(self): + for item in self.headers: + if item["key"]: + self.formatted_headers[item["key"]] = item["value"] + + async def run_get_request(self): + await self.process_headers() + async with httpx.AsyncClient() as client: + res = await client.get(self.req_url, headers=self.formatted_headers) + + self.get_data = res.json() + self.number_of_rows = len(self.get_data) + self.get_table_headers = list(self.get_data[0].keys()) + + # Calculate the total number of pages + self.total_pages = ( + self.number_of_rows + self.current_limit - 1 + ) // self.current_limit + + # Initialize the data to the first page + self.paginate() + + def paginate(self): + start = self.offset + end = start + self.current_limit + self.paginated_data = self.get_data[start:end] + self.current_page = (self.offset // self.current_limit) + 1 + + def delta_limit(self, limit: str): + self.current_limit = int(limit) + self.offset = 0 + self.total_pages = ( + self.number_of_rows + self.current_limit - 1 + ) // self.current_limit + self.paginate() + + def pure(self): + return + + def previous(self): + if self.offset >= self.current_limit: + self.offset -= self.current_limit + else: + self.offset = 0 + + self.paginate() + + def next(self): + if self.offset + self.current_limit < self.number_of_rows: + self.offset += self.current_limit + + self.paginate() + + def delta_drawer(self): + self.is_open = not self.is_open + + def display_selected_row(self, data: dict[str, str]): + self.delta_drawer() + self.selected_entry = data.copy() + self.original_entry = data + + def update_data(self, value: str, data: tuple[str, str]): + self.selected_entry[data[0]] = value + + def commit_changes(self): + self.get_data = [ + self.selected_entry if item == self.original_entry else item + for item in self.get_data + ] + + self.paginate() + self.delta_drawer() diff --git a/integration/loadtesting/sandbox/styles.py b/integration/loadtesting/sandbox/styles.py new file mode 100644 index 000000000..9f1acb813 --- /dev/null +++ b/integration/loadtesting/sandbox/styles.py @@ -0,0 +1,6 @@ +"""Styles for the app.""" + +text: dict[str, str] = { + "font_family": "var(--chakra-fonts-branding)", + "font_weight": "var(--chakra-fontWeights-black)", +}