From 34009fc4562c9f653c18ba80736e52bde760eb51 Mon Sep 17 00:00:00 2001 From: Ivan Skvortsov Date: Mon, 16 Feb 2026 14:51:12 +0100 Subject: [PATCH 1/7] Feat: Introduced state management via Tanstack Query --- CONTRIBUTING.md | 1 + backend/app/api/v1/routes/events.py | 56 +++++++-- backend/app/api/v1/routes/fields.py | 24 +++- backend/app/api/v1/routes/tags.py | 24 +++- backend/app/core/guard.py | 14 ++- backend/app/core/handlers.py | 58 +++++++++ backend/app/factory.py | 7 ++ backend/app/main.py | 2 +- backend/tests/test_auth.py | 6 +- backend/tests/test_events.py | 2 +- backend/tests/test_events_extended.py | 2 +- backend/tests/test_fields_extended.py | 2 +- frontend/package-lock.json | 118 +++++++++++++++++- frontend/package.json | 2 + frontend/src/App.vue | 15 +++ frontend/src/main.ts | 3 + .../events/components/EventEditModal.vue | 27 ++-- .../modules/events/pages/EventCreatePage.vue | 74 +++++------ .../modules/events/pages/EventDetailsPage.vue | 59 +++++---- .../src/modules/events/pages/EventsPage.vue | 89 ++++++------- .../modules/fields/pages/FieldCreatePage.vue | 22 ++-- .../modules/fields/pages/FieldDetailsPage.vue | 59 +++++---- .../src/modules/fields/pages/FieldsPage.vue | 75 +++++------ .../src/modules/tags/pages/TagCreatePage.vue | 21 ++-- frontend/src/modules/tags/pages/TagsPage.vue | 69 +++++----- .../shared/components/layout/MainLayout.vue | 3 +- .../shared/components/layout/PageHeader.vue | 1 - .../components/layout/SyncStatusButton.vue | 36 ++++++ .../shared/composables/useEnhancedToast.ts | 79 +++++++----- frontend/src/shared/plugins/vue-query.ts | 18 +++ frontend/src/shared/utils/parseApiError.ts | 75 ++++++----- frontend/src/shared/utils/toast.ts | 34 ----- 32 files changed, 692 insertions(+), 385 deletions(-) create mode 100644 backend/app/core/handlers.py create mode 100644 frontend/src/shared/components/layout/SyncStatusButton.vue create mode 100644 frontend/src/shared/plugins/vue-query.ts delete mode 100644 frontend/src/shared/utils/toast.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index afe4399..7c22edf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,6 +23,7 @@ You can open an [issue](https://github.com/ivanskv2000/evsy/issues) — we’d l You can setup both the backend and frontend in dev mode with hot-reloading: ```bash +cp .env.example .env make dev ``` diff --git a/backend/app/api/v1/routes/events.py b/backend/app/api/v1/routes/events.py index 25ca3d3..349c81c 100644 --- a/backend/app/api/v1/routes/events.py +++ b/backend/app/api/v1/routes/events.py @@ -42,7 +42,13 @@ def create_event_route(event: EventCreate, db: Session = Depends(get_db)): db_fields = get_fields_by_ids(db, event.fields) if len(db_fields) != len(event.fields): - raise HTTPException(status_code=400, detail="One or more fields do not exist.") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "invalid_reference", + "message": "One or more referenced fields do not exist.", + }, + ) get_or_create_tags(db, event.tags) db_event = event_crud.create_event(db=db, event=event) @@ -63,7 +69,13 @@ def create_event_route(event: EventCreate, db: Session = Depends(get_db)): def get_event_route(event_id: int, db: Session = Depends(get_db)): db_event = event_crud.get_event(db=db, event_id=event_id) if db_event is None: - raise HTTPException(status_code=404, detail="Event not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": "resource_not_found", + "message": f"Event with id {event_id} not found", + }, + ) return db_event @@ -100,12 +112,24 @@ def update_event_route( db_fields = get_fields_by_ids(db, event.fields) if len(db_fields) != len(event.fields): - raise HTTPException(status_code=400, detail="One or more fields do not exist.") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "invalid_reference", + "message": "One or more referenced fields do not exist.", + }, + ) get_or_create_tags(db, event.tags) db_event = event_crud.update_event(db=db, event_id=event_id, event=event) if db_event is None: - raise HTTPException(status_code=404, detail="Event not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": "resource_not_found", + "message": f"Event with id {event_id} not found", + }, + ) return db_event @@ -122,7 +146,13 @@ def update_event_route( def delete_event_route(event_id: int, db: Session = Depends(get_db)): db_event = event_crud.delete_event(db=db, event_id=event_id) if db_event is None: - raise HTTPException(status_code=404, detail="Event not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": "resource_not_found", + "message": f"Event with id {event_id} not found", + }, + ) return db_event @@ -151,7 +181,13 @@ def get_event_json_schema( ): db_event = event_crud.get_event(db=db, event_id=event_id) if not db_event: - raise HTTPException(status_code=404, detail="Event not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": "resource_not_found", + "message": f"Event with id {event_id} not found", + }, + ) event = EventOut.model_validate(db_event) schema = generate_json_schema_for_event( @@ -187,7 +223,13 @@ def get_event_yaml_schema( ): db_event = event_crud.get_event(db=db, event_id=event_id) if not db_event: - raise HTTPException(status_code=404, detail="Event not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": "resource_not_found", + "message": f"Event with id {event_id} not found", + }, + ) event = EventOut.model_validate(db_event) schema = generate_json_schema_for_event( diff --git a/backend/app/api/v1/routes/fields.py b/backend/app/api/v1/routes/fields.py index 0ea6a6c..51e4c19 100644 --- a/backend/app/api/v1/routes/fields.py +++ b/backend/app/api/v1/routes/fields.py @@ -56,7 +56,13 @@ def get_field_route( ): db_field = field_crud.get_field(db=db, field_id=field_id) if db_field is None: - raise HTTPException(status_code=404, detail="Field not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": "resource_not_found", + "message": f"Field with id {field_id} not found", + }, + ) if with_event_count: count = field_crud.get_field_event_count(db=db, field_id=field_id) @@ -81,7 +87,13 @@ def update_field_route( ): db_field = field_crud.update_field(db=db, field_id=field_id, field=field) if db_field is None: - raise HTTPException(status_code=404, detail="Field not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": "resource_not_found", + "message": f"Field with id {field_id} not found", + }, + ) return db_field @@ -98,5 +110,11 @@ def update_field_route( def delete_field_route(field_id: int, db: Session = Depends(get_db)): db_field = field_crud.delete_field(db=db, field_id=field_id) if db_field is None: - raise HTTPException(status_code=404, detail="Field not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": "resource_not_found", + "message": f"Field with id {field_id} not found", + }, + ) return db_field diff --git a/backend/app/api/v1/routes/tags.py b/backend/app/api/v1/routes/tags.py index 97e499a..d8aa6b6 100644 --- a/backend/app/api/v1/routes/tags.py +++ b/backend/app/api/v1/routes/tags.py @@ -50,7 +50,13 @@ def list_tags_route(db: Session = Depends(get_db)): def get_tag_route(tag_id: str, db: Session = Depends(get_db)): db_tag = tag_crud.get_tag(db=db, tag_id=tag_id) if db_tag is None: - raise HTTPException(status_code=404, detail="Tag not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": "resource_not_found", + "message": f"Tag with id '{tag_id}' not found", + }, + ) return db_tag @@ -68,7 +74,13 @@ def get_tag_route(tag_id: str, db: Session = Depends(get_db)): def update_tag_route(tag_id: str, tag: TagCreate, db: Session = Depends(get_db)): db_tag = tag_crud.update_tag(db=db, tag_id=tag_id, tag=tag) if db_tag is None: - raise HTTPException(status_code=404, detail="Tag not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": "resource_not_found", + "message": f"Tag with id '{tag_id}' not found", + }, + ) return db_tag @@ -85,5 +97,11 @@ def update_tag_route(tag_id: str, tag: TagCreate, db: Session = Depends(get_db)) def delete_tag_route(tag_id: str, db: Session = Depends(get_db)): db_tag = tag_crud.delete_tag(db=db, tag_id=tag_id) if db_tag is None: - raise HTTPException(status_code=404, detail="Tag not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": "resource_not_found", + "message": f"Tag with id '{tag_id}' not found", + }, + ) return db_tag diff --git a/backend/app/core/guard.py b/backend/app/core/guard.py index 85be048..06d448f 100644 --- a/backend/app/core/guard.py +++ b/backend/app/core/guard.py @@ -1,4 +1,4 @@ -from fastapi import Depends, HTTPException +from fastapi import Depends, HTTPException, status from app.settings import get_settings @@ -6,12 +6,20 @@ def ensure_not_demo(settings=Depends(get_settings)): if settings.is_demo: raise HTTPException( - status_code=403, detail="This action is not allowed in demo mode." + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "code": "action_forbidden_in_demo", + "message": "This action is not allowed in demo mode.", + }, ) def ensure_dev(settings=Depends(get_settings)): if not settings.is_dev: raise HTTPException( - status_code=403, detail="This action is allowed only in development." + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "code": "action_requires_dev_mode", + "message": "This action is allowed only in development.", + }, ) diff --git a/backend/app/core/handlers.py b/backend/app/core/handlers.py new file mode 100644 index 0000000..55fecb1 --- /dev/null +++ b/backend/app/core/handlers.py @@ -0,0 +1,58 @@ +from fastapi import Request, status +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field +from starlette.exceptions import HTTPException as StarletteHTTPException + + +class ValidationErrorDetail(BaseModel): + loc: tuple[str | int, ...] = Field(..., description="Location of the error in the request") + msg: str = Field(..., description="A human-readable message for the error") + type: str = Field(..., description="The type of the error") + + +class ErrorResponse(BaseModel): + code: str = Field(..., description="A unique, machine-readable error code") + message: str = Field(..., description="A human-readable message for the error") + details: list[ValidationErrorDetail] | None = Field( + None, description="Optional details for validation errors" + ) + + +async def http_exception_handler(request: Request, exc: StarletteHTTPException): + """ + Handler for FastAPI's built-in HTTPException. + + This handler formats the error into a standard ErrorResponse model. + It supports passing a dictionary with 'code' and 'message' in exc.detail. + """ + if isinstance(exc.detail, dict): + # If detail is a dict, assume it matches our convention + code = exc.detail.get("code", "http_exception") + message = exc.detail.get("message", "An unexpected error occurred.") + else: + # If detail is a string, wrap it in the standard format + code = "http_exception" + message = str(exc.detail) + + return JSONResponse( + status_code=exc.status_code, + content=jsonable_encoder(ErrorResponse(code=code, message=message)), + ) + + +async def validation_exception_handler(request: Request, exc: RequestValidationError): + """Handler for Pydantic's RequestValidationError.""" + details = [ + ValidationErrorDetail(loc=err["loc"], msg=err["msg"], type=err["type"]) + for err in exc.errors() + ] + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content=jsonable_encoder( + ErrorResponse( + code="validation_error", message="Input validation failed", details=details + ) + ), + ) \ No newline at end of file diff --git a/backend/app/factory.py b/backend/app/factory.py index 7e3d0f7..a6ae867 100644 --- a/backend/app/factory.py +++ b/backend/app/factory.py @@ -8,6 +8,10 @@ from app.modules.auth.schemas import UserCreate from app.modules.auth.service import create_user_if_not_exists from app.settings import Settings +from app.core.handlers import http_exception_handler, validation_exception_handler + +from fastapi.exceptions import RequestValidationError +from starlette.exceptions import HTTPException as StarletteHTTPException def create_app(settings: Settings, SessionLocal: sessionmaker) -> FastAPI: @@ -77,6 +81,9 @@ async def lifespan(app: FastAPI): app.include_router(admin.router, prefix="/v1", tags=["admin"]) app.include_router(auth.router, prefix="/v1", tags=["auth"]) + app.add_exception_handler(StarletteHTTPException, http_exception_handler) + app.add_exception_handler(RequestValidationError, validation_exception_handler) + @app.get("/") def read_root(): return {"message": "Welcome to the Evsy API!"} diff --git a/backend/app/main.py b/backend/app/main.py index 2469c92..8560aa1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,4 +13,4 @@ sys.exit(1) engine, SessionLocal = init_db(settings) -app = create_app(settings, SessionLocal) +app = create_app(settings, SessionLocal) \ No newline at end of file diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index f166ec2..1e695dd 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -21,7 +21,7 @@ def test_signup_duplicate_email(client, auth_data): # Repeat signup to trigger duplicate error response = client.post("/v1/auth/signup", json=auth_data) assert response.status_code == 400 - assert "already exists" in response.json()["detail"].lower() + assert "already exists" in response.json()["message"].lower() def test_login_with_valid_credentials(client, auth_data): @@ -38,7 +38,7 @@ def test_login_with_invalid_password(client, auth_data): json={"email": auth_data["email"], "password": "wrongpassword"}, ) assert response.status_code == 401 - assert "invalid credentials" in response.json()["detail"].lower() + assert "invalid credentials" in response.json()["message"].lower() def test_login_with_unknown_email(client): @@ -47,7 +47,7 @@ def test_login_with_unknown_email(client): json={"email": "unknown@example.com", "password": "irrelevant"}, ) assert response.status_code == 401 - assert "invalid credentials" in response.json()["detail"].lower() + assert "invalid credentials" in response.json()["message"].lower() def test_me_requires_auth(client): diff --git a/backend/tests/test_events.py b/backend/tests/test_events.py index 077e1a4..c793673 100644 --- a/backend/tests/test_events.py +++ b/backend/tests/test_events.py @@ -37,7 +37,7 @@ def test_create_event_with_invalid_field(auth_client): }, ) assert response.status_code == 400 - assert "fields" in response.json()["detail"].lower() + assert "fields" in response.json()["message"].lower() def test_create_event_with_new_tag(auth_client): diff --git a/backend/tests/test_events_extended.py b/backend/tests/test_events_extended.py index 4b0f6b4..787ae8e 100644 --- a/backend/tests/test_events_extended.py +++ b/backend/tests/test_events_extended.py @@ -119,7 +119,7 @@ def test_get_nonexistent_event(auth_client): """Test getting event that doesn't exist""" response = auth_client.get("/v1/events/99999") assert response.status_code == 404 - assert "not found" in response.json()["detail"].lower() + assert "not found" in response.json()["message"].lower() def test_update_nonexistent_event(auth_client): diff --git a/backend/tests/test_fields_extended.py b/backend/tests/test_fields_extended.py index b9a0624..b7dab0b 100644 --- a/backend/tests/test_fields_extended.py +++ b/backend/tests/test_fields_extended.py @@ -210,7 +210,7 @@ def test_get_nonexistent_field(auth_client): """Test getting field that doesn't exist""" response = auth_client.get("/v1/fields/99999") assert response.status_code == 404 - assert "not found" in response.json()["detail"].lower() + assert "not found" in response.json()["message"].lower() def test_update_nonexistent_field(auth_client): diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dffe09e..d221681 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,14 +1,15 @@ { "name": "evsy-frontend", - "version": "0.3.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "evsy-frontend", - "version": "0.3.0", + "version": "1.1.0", "dependencies": { "@tailwindcss/vite": "^4.1.4", + "@tanstack/vue-query": "^5.92.9", "@tanstack/vue-table": "^8.21.3", "@vee-validate/zod": "^4.15.0", "@vueuse/core": "^13.1.0", @@ -31,6 +32,7 @@ "@eslint/js": "^9.25.0", "@iconify-json/radix-icons": "^1.2.2", "@iconify/vue": "^4.3.0", + "@tanstack/vue-query-devtools": "^6.1.5", "@types/node": "^22.14.1", "@vitejs/plugin-vue": "^5.2.2", "@vue/eslint-config-prettier": "^10.2.0", @@ -1351,6 +1353,43 @@ "vite": "^5.2.0 || ^6" } }, + "node_modules/@tanstack/match-sorter-utils": { + "version": "8.19.4", + "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.19.4.tgz", + "integrity": "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==", + "license": "MIT", + "dependencies": { + "remove-accents": "0.5.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.93.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.93.0.tgz", + "integrity": "sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/table-core": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", @@ -1372,6 +1411,75 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/vue-query": { + "version": "5.92.9", + "resolved": "https://registry.npmjs.org/@tanstack/vue-query/-/vue-query-5.92.9.tgz", + "integrity": "sha512-jjAZcqKveyX0C4w/6zUqbnqk/XzuxNWaFsWjGTJWULVFizUNeLGME2gf9vVSDclIyiBhR13oZJPPs6fJgfpIJQ==", + "license": "MIT", + "dependencies": { + "@tanstack/match-sorter-utils": "^8.19.4", + "@tanstack/query-core": "5.90.20", + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@vue/composition-api": "^1.1.2", + "vue": "^2.6.0 || ^3.3.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@tanstack/vue-query-devtools": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/@tanstack/vue-query-devtools/-/vue-query-devtools-6.1.5.tgz", + "integrity": "sha512-5tQf/4GKfDyvu+Jqv/Fq1im93nXQyge0yWphCmwNHz6Y6+kqA9swJY2G/kLU2TaT2s1CE+elt5EfYNGE1tsDqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.93.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/vue-query": "^5.92.9", + "vue": "^3.3.0" + } + }, + "node_modules/@tanstack/vue-query/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/@tanstack/vue-table": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/vue-table/-/vue-table-8.21.3.tgz", @@ -3898,6 +4006,12 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/remove-accents": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", + "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==", + "license": "MIT" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0dea409..9567839 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@tailwindcss/vite": "^4.1.4", + "@tanstack/vue-query": "^5.92.9", "@tanstack/vue-table": "^8.21.3", "@vee-validate/zod": "^4.15.0", "@vueuse/core": "^13.1.0", @@ -36,6 +37,7 @@ "@eslint/js": "^9.25.0", "@iconify-json/radix-icons": "^1.2.2", "@iconify/vue": "^4.3.0", + "@tanstack/vue-query-devtools": "^6.1.5", "@types/node": "^22.14.1", "@vitejs/plugin-vue": "^5.2.2", "@vue/eslint-config-prettier": "^10.2.0", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 0066f43..d3b6227 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -4,8 +4,21 @@ import MainLayout from '@/shared/components/layout/MainLayout.vue' import { Toaster } from '@/shared/ui/sonner' import { useAuthStore } from '@/modules/auth/stores/useAuthStore' import router from './router' +import { VueQueryDevtools } from '@tanstack/vue-query-devtools' +import { useEnhancedToast } from './shared/composables/useEnhancedToast' +import { queryClient } from './shared/plugins/vue-query' const auth = useAuthStore() +const { showError } = useEnhancedToast() + +const globalErrorHandler = (error: unknown) => { + showError(error) +} + +const queryCache = queryClient.getQueryCache() +const mutationCache = queryClient.getMutationCache() +queryCache.config.onError = globalErrorHandler +mutationCache.config.onError = globalErrorHandler if (auth.token) { auth.fetchCurrentUser().catch(() => { @@ -33,4 +46,6 @@ window.addEventListener('message', async event => { + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 822239d..2471c6c 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,5 +1,6 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' +import { installVueQuery } from './shared/plugins/vue-query' import router from './router' import './index.css' import App from '@/App.vue' @@ -9,4 +10,6 @@ const app = createApp(App) app.use(router) app.use(pinia) +installVueQuery(app) + app.mount('#app') diff --git a/frontend/src/modules/events/components/EventEditModal.vue b/frontend/src/modules/events/components/EventEditModal.vue index f765172..abe83b8 100644 --- a/frontend/src/modules/events/components/EventEditModal.vue +++ b/frontend/src/modules/events/components/EventEditModal.vue @@ -9,12 +9,9 @@ import { import type { EventFormValues } from '@/modules/events/validation/eventSchema.ts' import EventForm from './EventForm.vue' import type { Event } from '@/modules/events/types' -import type { Field } from '@/modules/fields/types' import { fieldApi } from '@/modules/fields/api' -import { ref, onMounted } from 'vue' -import type { Tag } from '@/modules/tags/types' import { tagApi } from '@/modules/tags/api' -import { useAsyncTask } from '@/shared/composables/useAsyncTask' +import { useQuery } from '@tanstack/vue-query' defineProps<{ description?: string @@ -25,18 +22,14 @@ defineProps<{ isSaving?: boolean }>() -const fields = ref([]) -const tags = ref([]) -const { run: loadFields, isLoading: isLoadingFields } = useAsyncTask() -const { run: loadTags, isLoading: isLoadingTags } = useAsyncTask() +const { data: fields, isLoading: isLoadingFields } = useQuery({ + queryKey: ['fields'], + queryFn: () => fieldApi.getAll(), +}) -onMounted(() => { - loadFields(async () => { - fields.value = await fieldApi.getAll() - }) - loadTags(async () => { - tags.value = await tagApi.getAll() - }) +const { data: tags, isLoading: isLoadingTags } = useQuery({ + queryKey: ['tags'], + queryFn: () => tagApi.getAll(), }) @@ -52,8 +45,8 @@ onMounted(() => { -import EventForm from '@/modules/events/components/EventForm.vue' import { useRouter } from 'vue-router' -import { useEnhancedToast } from '@/shared/composables/useEnhancedToast' -import { ref, onMounted } from 'vue' +import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query' import { eventApi } from '@/modules/events/api' -import { useAsyncTask } from '@/shared/composables/useAsyncTask' -import type { EventFormValues } from '@/modules/events/validation/eventSchema.ts' -import Header from '@/shared/components/layout/PageHeader.vue' -import { Card, CardContent } from '@/shared/ui/card' import { fieldApi } from '@/modules/fields/api' -import type { Field } from '@/modules/fields/types' -import type { Tag } from '@/modules/tags/types' import { tagApi } from '@/modules/tags/api' +import type { Event } from '@/modules/events/types' +import type { EventFormValues } from '@/modules/events/validation/eventSchema' +import EventForm from '@/modules/events/components/EventForm.vue' +import Header from '@/shared/components/layout/PageHeader.vue' +import { Card, CardContent } from '@/shared/ui/card' +import { useEnhancedToast } from '@/shared/composables/useEnhancedToast' -const fields = ref([]) -const tags = ref([]) +const router = useRouter() +const queryClient = useQueryClient() +const { showCreated } = useEnhancedToast() -const { isLoading, run } = useAsyncTask() -const { run: loadFields, isLoading: isLoadingFields } = useAsyncTask() -const { run: loadTags, isLoading: isLoadingTags } = useAsyncTask() +// const isInitialLoading = ref(true) -const isInitialLoading = ref(true) +const { data: tags, isLoading: isLoadingTags } = useQuery({ + queryKey: ['tags'], + queryFn: () => tagApi.getAll(), +}) -const { showCreated } = useEnhancedToast() -const router = useRouter() +const { data: fields, isLoading: isLoadingFields } = useQuery({ + queryKey: ['fields'], + queryFn:() => fieldApi.getAll(), +}) + +const { mutate: createEvent, isPending: isSaving } = useMutation({ + mutationFn: (values: EventFormValues) => eventApi.create(values), + onSuccess: (createdEvent: Event) => { + showCreated('Event') + queryClient.invalidateQueries({ queryKey: ['events'] }) + router.push(`/events/${createdEvent.id}`) + } +}) const onSubmit = (values: EventFormValues) => { - run( - () => eventApi.create(values), - created => { - router.push(`/events/${created.id}`) - showCreated('Event') - } - ) + createEvent(values) } - -onMounted(() => { - loadFields(async () => { - fields.value = await fieldApi.getAll() - }) - loadTags(async () => { - tags.value = await tagApi.getAll() - }) - - isInitialLoading.value = false -})