This commit is contained in:
2026-07-27 10:38:22 +02:00
parent 7b33b76b3d
commit ed4cb2a39c
11 changed files with 316 additions and 179 deletions
+1 -4
View File
@@ -30,7 +30,6 @@ The goal of this task is to add an "Add Server" button and creation functionalit
- Cancel and Create buttons with loading state handling. - Cancel and Create buttons with loading state handling.
- **Store Integration:** `useServerStore` must provide a `createServer` action that performs `POST /servers` with the appropriate payload (`{ name, password, is_default }`). - **Store Integration:** `useServerStore` must provide a `createServer` action that performs `POST /servers` with the appropriate payload (`{ name, password, is_default }`).
# Technical Design # Technical Design
### Current Implementation ### Current Implementation
@@ -80,7 +79,6 @@ graph LR
ServerStore -->|Updates servers list| AppLayout ServerStore -->|Updates servers list| AppLayout
``` ```
# Testing # Testing
### Validation Approach ### Validation Approach
@@ -97,10 +95,9 @@ graph LR
- **Validation Error:** Empty server name disables the create button or shows an alert. - **Validation Error:** Empty server name disables the create button or shows an alert.
- **API Error Handling:** Catch and log API failures gracefully. - **API Error Handling:** Catch and log API failures gracefully.
# Delivery Steps # Delivery Steps
### Step 1: Extend server Pinia store with createServer action ### * Step 1: Extend server Pinia store with createServer action
Extend the server Pinia store with createServer action. Extend the server Pinia store with createServer action.
- Update `frontend/src/stores/server.ts` to include `createServer(payload: { name: string; password?: string | null; is_default?: boolean })`. - Update `frontend/src/stores/server.ts` to include `createServer(payload: { name: string; password?: string | null; is_default?: boolean })`.
- Handle error states, API POST requests to `/servers`, and state updates to push the newly created server to `this.servers`. - Handle error states, API POST requests to `/servers`, and state updates to push the newly created server to `this.servers`.
@@ -5,66 +5,36 @@ sessionId: session-260727-090636-1x6i
# Requirements # Requirements
### Overview & Goals ### Overview & Goals
The goal of this task is to adapt the frontend data-loading policy for channels and categories. Previously, all channels and categories were fetched globally once upon initial load. Now, whenever the user swaps servers (or enters a server view), channels and categories should be reloaded specifically for that active server using the backend query parameters (`server_id`) implemented in the previous backend task. The goal of this task is to add an "Add Server" button and creation modal on the frontend (leveraging existing backend server creation endpoints), and investigate/address any server scoping issues with channels and categories when multiple servers exist.
### Scope ### Scope
- **In Scope**: - **In Scope**:
- Updating `useChannelStore` and `useCategoryStore` in Pinia to accept a `serverId` query parameter when fetching channels and categories. - Adding an "Add Server" button in the frontend layout/navigation (e.g. in `AppLayout.vue` or sidebar) with a creation dialog supporting server name, password, and default flags.
- Updating `src/pages/server/index.vue` (and/or router navigation / lifecycle hooks) to trigger fetching of channels and categories filtered by the current `serverId` whenever the active server changes. - Verifying and ensuring that channel and category queries correctly filter and isolate data per server.
- Resetting or clearing channel and category state appropriately when switching servers.
- **Out of Scope**: - **Out of Scope**:
- Backend changes (already completed in previous task). - Major backend restructuring (existing CRUD and filter endpoints are already fully implemented).
- Changes to other entities (servers, messages, etc.) unless directly related to channel/category scoping.
### User Stories ### User Stories
- **As a user**, when I switch from one server to another, I want the channels and categories list to be reloaded automatically for the newly selected server so that I only see relevant content. - **As a user**, I want an "Add Server" button in the UI so that I can easily create a new server.
- **As a user**, when I switch between servers, I want to ensure that channels and categories are strictly scoped to the active server.
### Functional Requirements ### Functional Requirements
1. **Store Actions (`fetchChannels`, `fetchCategories`)**: 1. **Add Server UI (`AppLayout.vue`)**:
- Both actions must accept an optional `serverId?: string`. - Provide a prominent button in the sidebar to open the "Create Server" dialog.
- When `serverId` is provided, requests must be made to `/channels?server_id=...` and `/categories?server_id=...`. - Collect server details (`name`, optional `password`, `is_default`) and call `serverStore.createServer`.
2. **Server View Component (`/src/pages/server/index.vue`)**: - On successful creation, navigate to the new server's view (`/server/${newServer.id}`).
- On component mount or when `serverId` route parameter changes (e.g. via `watch(() => route.params.serverId, ...)`), trigger fetching of channels and categories for the new `serverId`. 2. **Server Scoping Verification**:
- Clear existing channels/categories or show loading states during the fetch transition. - Ensure channels and categories fetched for a server correctly correspond to that server ID.
# Technical Design # Technical Design
### Current Implementation ### Current Implementation
- Currently, `useSessionStore.loadInitialData()` calls `serverStore.fetchServers()`, `categoryStore.fetchCategories()`, and `channelStore.fetchChannels()` without parameters, retrieving all records from `/channels` and `/categories`. - Backend server creation (`POST /servers`) and listing (`GET /servers`) are fully implemented.
- `src/pages/server/index.vue` displays `channels` from `useChannelStore`, but does not trigger refetching when switching between different servers. - Frontend `useServerStore` has `createServer` and `fetchServers`.
- `AppLayout.vue` already includes a dialog structure for creating servers triggered by an `mdi-plus` icon in the navigation drawer.
### Key Decisions
- **Query Parameter Usage**: Leverage the backend endpoints `/channels?server_id=<id>` and `/categories?server_id=<id>` that were created previously.
- **Store & Lifecycle Integration**: Use Vue router route watchers in `src/pages/server/index.vue` (or a dedicated composable/watcher) to detect `serverId` changes and invoke store fetch actions.
### Proposed Changes ### Proposed Changes
1. **`src/stores/channel.ts`**: 1. **`frontend/src/layouts/AppLayout.vue`**:
- Modify `fetchChannels(serverId?: string)` to build query string `?server_id=${serverId}` if `serverId` is provided. - Ensure the server creation dialog and form bindings are fully wired up and intuitive.
2. **`src/stores/category.ts`**: 2. **Review Channel & Category Scoping**:
- Modify `fetchCategories(serverId?: string)` to build query string `?server_id=${serverId}` if `serverId` is provided. - Verify that `useChannelStore.fetchChannels(serverId)` and `useCategoryStore.fetchCategories(serverId)` correctly pass `server_id` and that the backend filters properly.
3. **`src/pages/server/index.vue`**:
- Add a watcher on `route.params.serverId` (or `serverId` prop).
- On mount and when `serverId` changes, call `channelStore.fetchChannels(serverId)` and `categoryStore.fetchCategories(serverId)`.
### File Structure
- **Modified Files**:
- `frontend/src/stores/channel.ts`
- `frontend/src/stores/category.ts`
- `frontend/src/pages/server/index.vue`
### Risks & Mitigations
- **Race conditions during rapid server switching**: Mitigated by handling loading states or canceling/ignoring stale promises if necessary, or simply allowing the latest fetch response to overwrite the store state.
# Delivery Steps
### ✓ Step 1: Update channel and category stores to support server-scoped fetching
Update Pinia stores for channels and categories to support filtered fetching by server_id.
- Update `useChannelStore.fetchChannels(serverId?: string)` to accept an optional `serverId` and pass it as a query parameter (`/channels?server_id=...`).
- Update `useCategoryStore.fetchCategories(serverId?: string)` to accept an optional `serverId` and pass it as a query parameter (`/categories?server_id=...`).
- Clear channels and categories when no server is selected or when swapping servers.
### ✓ Step 2: Integrate server-scoped data loading into server view and router navigation
Integrate server-scoped loading into the server view lifecycle and route changes.
- Update `/src/pages/server/index.vue` to watch `serverId` (or trigger on route parameter change / component mount) and fetch channels and categories specifically for the active `serverId`.
- Clear channels and categories when switching between servers or leaving the server view.
- Ensure proper loading states and error handling during server switching.
@@ -0,0 +1,130 @@
<script lang="ts" setup>
import {ref, watch} from 'vue'
import {useChannelStore} from '@/stores/channel'
const props = defineProps<{
modelValue: boolean
serverId: string
categoryId?: string | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'created', channel: any): void
}>()
const channelStore = useChannelStore()
const formData = ref({
name: '',
channel_type: 'text',
position: 0
})
const isSubmitting = ref(false)
const channelTypeOptions = [
{title: 'Text', value: 'text'},
{title: 'Voice', value: 'voice'},
{title: 'DM', value: 'dm'}
]
const resetForm = () => {
formData.value = {
name: '',
channel_type: 'text',
position: 0
}
}
const handleClose = () => {
emit('update:modelValue', false)
resetForm()
}
const handleSubmit = async () => {
if (!formData.value.name.trim()) {
alert('Channel name is required')
return
}
isSubmitting.value = true
try {
const newChannel = await channelStore.createChannel({
name: formData.value.name,
channel_type: formData.value.channel_type,
server_id: props.serverId,
category_id: props.categoryId ?? null,
position: formData.value.position
})
emit('created', newChannel)
handleClose()
} catch (error) {
console.error('Failed to create channel:', error)
} finally {
isSubmitting.value = false
}
}
watch(
() => props.modelValue,
(isOpen) => {
if (!isOpen) {
resetForm()
}
}
)
</script>
<template>
<v-dialog :model-value="modelValue" width="400" @update:model-value="handleClose">
<v-card>
<v-card-title>Create Channel</v-card-title>
<v-card-text>
<div class="mt-4 space-y-4">
<v-text-field
v-model="formData.name"
density="compact"
label="Channel Name"
outlined
@keyup.enter="handleSubmit"
></v-text-field>
<v-select
v-model="formData.channel_type"
:items="channelTypeOptions"
density="compact"
label="Channel Type"
outlined
></v-select>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
:disabled="isSubmitting"
variant="text"
@click="handleClose"
>
Cancel
</v-btn>
<v-btn
:disabled="!formData.name.trim()"
:loading="isSubmitting"
color="primary"
variant="tonal"
@click="handleSubmit"
>
Create
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.space-y-4 {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>
+106 -2
View File
@@ -1,11 +1,57 @@
<script lang="ts" setup> <script lang="ts" setup>
import {storeToRefs} from 'pinia' import {storeToRefs} from 'pinia'
import {useServerStore} from '@/stores/server' import {useServerStore} from '@/stores/server'
import {ref} from 'vue'
import {useRouter} from 'vue-router'
const serverStore = useServerStore() const serverStore = useServerStore()
const router = useRouter()
const {servers} = storeToRefs(serverStore) const {servers} = storeToRefs(serverStore)
console.log(servers.value)
const showDialog = ref(false)
const formData = ref({
name: '',
password: '',
is_default: false
})
const isSubmitting = ref(false)
const resetForm = () => {
formData.value = {
name: '',
password: '',
is_default: false
}
}
const handleSubmit = async () => {
if (!formData.value.name.trim()) {
alert('Server name is required');
return;
}
isSubmitting.value = true;
try {
const newServer = await serverStore.createServer({
name: formData.value.name,
password: formData.value.password || null,
is_default: formData.value.is_default
});
showDialog.value = false;
resetForm();
router.push(`/server/${newServer.id}`);
} catch (error) {
console.error('Failed to create server:', error);
} finally {
isSubmitting.value = false;
}
}
const handleCancel = () => {
showDialog.value = false;
resetForm();
}
</script> </script>
<template> <template>
@@ -49,12 +95,70 @@ console.log(servers.value)
></v-avatar> ></v-avatar>
</router-link> </router-link>
<v-btn
class="d-block mx-auto mb-9"
color="grey-lighten-2"
icon="mdi-plus"
size="28"
variant="flat"
@click="showDialog = true"
></v-btn>
</v-navigation-drawer> </v-navigation-drawer>
<router-view/> <router-view/>
<v-dialog v-model="showDialog" width="400">
<v-card>
<v-card-title>Create Server</v-card-title>
<v-card-text>
<div class="mt-4 space-y-4">
<v-text-field
v-model="formData.name"
density="compact"
label="Server Name"
outlined
@keyup.enter="handleSubmit"
></v-text-field>
<v-text-field
v-model="formData.password"
density="compact"
label="Password (optional)"
outlined
type="password"
@keyup.enter="handleSubmit"
></v-text-field>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
:disabled="isSubmitting"
variant="text"
@click="handleCancel"
>
Cancel
</v-btn>
<v-btn
:disabled="!formData.name.trim()"
:loading="isSubmitting"
color="primary"
variant="tonal"
@click="handleSubmit"
>
Create
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-app> </v-app>
</template> </template>
<style scoped> <style scoped>
.space-y-4 {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style> </style>
+7 -106
View File
@@ -2,13 +2,14 @@
import {storeToRefs} from 'pinia' import {storeToRefs} from 'pinia'
import {useChannelStore} from '@/stores/channel' import {useChannelStore} from '@/stores/channel'
import {useCategoryStore} from '@/stores/category' import {useCategoryStore} from '@/stores/category'
import {ref, watch, onMounted} from 'vue' import {ref, watch} from 'vue'
import {useRoute} from 'vue-router' import {useRoute} from 'vue-router'
import CreateChannelDialog from '@/components/channel/CreateChannelDialog.vue'
const props = defineProps<{ const props = defineProps<{
serverId: string serverId: string
channelId?: string channelId?: string
}>(); }>()
const route = useRoute() const route = useRoute()
const channelStore = useChannelStore() const channelStore = useChannelStore()
@@ -41,59 +42,6 @@ watch(
) )
const showDialog = ref(false) const showDialog = ref(false)
const formData = ref({
name: '',
channel_type: 'text',
server_id: null,
category_id: null,
position: 0
})
const isSubmitting = ref(false)
const channelTypeOptions = [
{title: 'Text', value: 'text'},
{title: 'Voice', value: 'voice'},
{title: 'DM', value: 'dm'}
]
const resetForm = () => {
formData.value = {
name: '',
channel_type: 'text',
server_id: null,
category_id: null,
position: 0
}
}
const handleSubmit = async () => {
if (!formData.value.name.trim()) {
alert('Channel name is required');
return;
}
isSubmitting.value = true;
try {
await channelStore.createChannel({
name: formData.value.name,
channel_type: formData.value.channel_type,
server_id: formData.value.server_id,
category_id: formData.value.category_id,
position: formData.value.position
});
showDialog.value = false;
resetForm();
} catch (error) {
console.error('Failed to create channel:', error);
} finally {
isSubmitting.value = false;
}
}
const handleCancel = () => {
showDialog.value = false;
resetForm();
}
</script> </script>
<template> <template>
@@ -125,59 +73,12 @@ const handleCancel = () => {
</v-list> </v-list>
</v-navigation-drawer> </v-navigation-drawer>
<v-dialog v-model="showDialog" width="400"> <CreateChannelDialog
<v-card> v-model="showDialog"
<v-card-title>Create Channel</v-card-title> :server-id="serverId"
<v-card-text> />
<div class="mt-4 space-y-4">
<v-text-field
v-model="formData.name"
density="compact"
label="Channel Name"
outlined
@keyup.enter="handleSubmit"
></v-text-field>
<v-select
v-model="formData.channel_type"
:items="channelTypeOptions"
density="compact"
label="Channel Type"
outlined
></v-select>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
:disabled="isSubmitting"
variant="text"
@click="handleCancel"
>
Cancel
</v-btn>
<v-btn
:disabled="!formData.name.trim()"
:loading="isSubmitting"
color="primary"
variant="tonal"
@click="handleSubmit"
>
Create
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-main> <v-main>
<router-view/> <router-view/>
</v-main> </v-main>
</template> </template>
<style scoped>
.space-y-4 {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>
+29 -1
View File
@@ -3,11 +3,17 @@ import {useApi} from "@/composables/useApi.ts";
interface Server { interface Server {
id: string id: string
name: string
is_default: boolean
created_at: string
updated_at: string
} }
export const useServerStore = defineStore("server", { export const useServerStore = defineStore("server", {
state: () => ({ state: () => ({
servers: [] as Server[] servers: [] as Server[],
loading: false,
error: null as string | null
}), }),
actions: { actions: {
async fetchServers() { async fetchServers() {
@@ -15,8 +21,30 @@ export const useServerStore = defineStore("server", {
const response = await api.get("/servers"); const response = await api.get("/servers");
this.servers = await response.json(); this.servers = await response.json();
}, },
async createServer(payload: { name: string; password?: string | null; is_default?: boolean }) {
this.loading = true;
this.error = null;
try {
const api = useApi();
const response = await api.post("/servers", payload);
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to create server');
}
const newServer = await response.json();
this.servers.push(newServer);
return newServer;
} catch (err) {
this.error = err instanceof Error ? err.message : 'Unknown error';
throw err;
} finally {
this.loading = false;
}
},
reset() { reset() {
this.servers = []; this.servers = [];
this.loading = false;
this.error = null;
} }
} }
}); });
+3 -5
View File
@@ -1,4 +1,5 @@
use crate::models::{channel, channel_role_permission, channel_user_permission}; use crate::models::{channel, channel_role_permission, channel_user_permission};
use crate::repositories::types::ChannelFilter;
use crate::repositories::{AnyResult, RepositoryContext}; use crate::repositories::{AnyResult, RepositoryContext};
use sea_orm::sea_query::OnConflict; use sea_orm::sea_query::OnConflict;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
@@ -21,14 +22,11 @@ impl ChannelRepository {
Ok(channel::Entity::find().all(&self.context.db).await?) Ok(channel::Entity::find().all(&self.context.db).await?)
} }
pub async fn filter(&self, server_id: Option<Uuid>, category_id: Option<Uuid>) -> AnyResult<Vec<channel::Model>> { pub async fn filter(&self, filter: ChannelFilter) -> AnyResult<Vec<channel::Model>> {
let mut query = channel::Entity::find(); let mut query = channel::Entity::find();
if let Some(s_id) = server_id { if let Some(s_id) = filter.server_id {
query = query.filter(channel::Column::ServerId.eq(s_id)); query = query.filter(channel::Column::ServerId.eq(s_id));
} }
if let Some(c_id) = category_id {
query = query.filter(channel::Column::CategoryId.eq(c_id));
}
Ok(query.all(&self.context.db).await?) Ok(query.all(&self.context.db).await?)
} }
+4
View File
@@ -24,3 +24,7 @@ pub struct MessageFilter {
pub before_id: Option<uuid::Uuid>, pub before_id: Option<uuid::Uuid>,
pub limit: Option<u64>, pub limit: Option<u64>,
} }
pub struct ChannelFilter {
pub server_id: Option<uuid::Uuid>,
}
+1 -5
View File
@@ -7,15 +7,11 @@ use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, ToSchema, utoipa::IntoParams)] #[derive(Debug, Serialize, Deserialize, ToSchema, utoipa::IntoParams)]
pub struct ChannelQueryParams { pub struct ChannelQueryParams {
pub server_id: Option<Uuid>, pub server_id: Option<Uuid>,
pub category_id: Option<Uuid>,
} }
impl Default for ChannelQueryParams { impl Default for ChannelQueryParams {
fn default() -> Self { fn default() -> Self {
Self { Self { server_id: None }
server_id: None,
category_id: None,
}
} }
} }
+6 -4
View File
@@ -2,14 +2,15 @@ use crate::core::state::AppState;
use crate::http::context::Superuser; use crate::http::context::Superuser;
use crate::http::error::HTTPError; use crate::http::error::HTTPError;
use crate::routes::channel::dto::{ use crate::routes::channel::dto::{
ChannelQueryParams, ChannelResponse, ChannelRolePermissionResponse, ChannelUserPermissionResponse, ChannelQueryParams, ChannelResponse, ChannelRolePermissionResponse,
CreateChannelRequest, SetChannelPermissionRequest, UpdateChannelRequest, ChannelUserPermissionResponse, CreateChannelRequest, SetChannelPermissionRequest,
UpdateChannelRequest,
}; };
use crate::routes::channel::mapper; use crate::routes::channel::mapper;
use axum::{ use axum::{
Json,
extract::{Path, Query, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
Json,
}; };
use uuid::Uuid; use uuid::Uuid;
@@ -30,7 +31,8 @@ pub async fn get_all(
State(state): State<AppState>, State(state): State<AppState>,
Query(filters): Query<ChannelQueryParams>, Query(filters): Query<ChannelQueryParams>,
) -> Result<Json<Vec<ChannelResponse>>, HTTPError> { ) -> Result<Json<Vec<ChannelResponse>>, HTTPError> {
let channels = state.repositories.channel.filter(filters.server_id, filters.category_id).await?; let params = mapper::query_params_to_channel_filter(filters);
let channels = state.repositories.channel.filter(params).await?;
Ok(Json( Ok(Json(
channels channels
.into_iter() .into_iter()
+9 -2
View File
@@ -1,7 +1,8 @@
use crate::models::{channel, channel_role_permission, channel_user_permission}; use crate::models::{channel, channel_role_permission, channel_user_permission};
use crate::repositories::types::ChannelFilter;
use crate::routes::channel::dto::{ use crate::routes::channel::dto::{
ChannelResponse, ChannelRolePermissionResponse, ChannelUserPermissionResponse, ChannelQueryParams, ChannelResponse, ChannelRolePermissionResponse,
CreateChannelRequest, UpdateChannelRequest, ChannelUserPermissionResponse, CreateChannelRequest, UpdateChannelRequest,
}; };
use sea_orm::Set; use sea_orm::Set;
use uuid::Uuid; use uuid::Uuid;
@@ -61,3 +62,9 @@ pub fn channel_role_permission_to_response(
permissions: model.permissions as u64, permissions: model.permissions as u64,
} }
} }
pub fn query_params_to_channel_filter(params: ChannelQueryParams) -> ChannelFilter {
ChannelFilter {
server_id: params.server_id,
}
}