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.
- **Store Integration:** `useServerStore` must provide a `createServer` action that performs `POST /servers` with the appropriate payload (`{ name, password, is_default }`).
# Technical Design
### Current Implementation
@@ -80,7 +79,6 @@ graph LR
ServerStore -->|Updates servers list| AppLayout
```
# Testing
### Validation Approach
@@ -97,10 +95,9 @@ graph LR
- **Validation Error:** Empty server name disables the create button or shows an alert.
- **API Error Handling:** Catch and log API failures gracefully.
# 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.
- 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`.
@@ -5,66 +5,36 @@ sessionId: session-260727-090636-1x6i
# Requirements
### 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
- **In Scope**:
- Updating `useChannelStore` and `useCategoryStore` in Pinia to accept a `serverId` query parameter when fetching channels and categories.
- 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.
- Resetting or clearing channel and category state appropriately when switching servers.
- 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.
- Verifying and ensuring that channel and category queries correctly filter and isolate data per server.
- **Out of Scope**:
- Backend changes (already completed in previous task).
- Changes to other entities (servers, messages, etc.) unless directly related to channel/category scoping.
- Major backend restructuring (existing CRUD and filter endpoints are already fully implemented).
### 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
1. **Store Actions (`fetchChannels`, `fetchCategories`)**:
- Both actions must accept an optional `serverId?: string`.
- When `serverId` is provided, requests must be made to `/channels?server_id=...` and `/categories?server_id=...`.
2. **Server View Component (`/src/pages/server/index.vue`)**:
- 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`.
- Clear existing channels/categories or show loading states during the fetch transition.
1. **Add Server UI (`AppLayout.vue`)**:
- Provide a prominent button in the sidebar to open the "Create Server" dialog.
- Collect server details (`name`, optional `password`, `is_default`) and call `serverStore.createServer`.
- On successful creation, navigate to the new server's view (`/server/${newServer.id}`).
2. **Server Scoping Verification**:
- Ensure channels and categories fetched for a server correctly correspond to that server ID.
# Technical Design
### Current Implementation
- Currently, `useSessionStore.loadInitialData()` calls `serverStore.fetchServers()`, `categoryStore.fetchCategories()`, and `channelStore.fetchChannels()` without parameters, retrieving all records from `/channels` and `/categories`.
- `src/pages/server/index.vue` displays `channels` from `useChannelStore`, but does not trigger refetching when switching between different servers.
### 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.
- Backend server creation (`POST /servers`) and listing (`GET /servers`) are fully implemented.
- 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.
### Proposed Changes
1. **`src/stores/channel.ts`**:
- Modify `fetchChannels(serverId?: string)` to build query string `?server_id=${serverId}` if `serverId` is provided.
2. **`src/stores/category.ts`**:
- Modify `fetchCategories(serverId?: string)` to build query string `?server_id=${serverId}` if `serverId` is provided.
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.
1. **`frontend/src/layouts/AppLayout.vue`**:
- Ensure the server creation dialog and form bindings are fully wired up and intuitive.
2. **Review Channel & Category Scoping**:
- Verify that `useChannelStore.fetchChannels(serverId)` and `useCategoryStore.fetchCategories(serverId)` correctly pass `server_id` and that the backend filters properly.
@@ -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>
import {storeToRefs} from 'pinia'
import {useServerStore} from '@/stores/server'
import {ref} from 'vue'
import {useRouter} from 'vue-router'
const serverStore = useServerStore()
const router = useRouter()
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>
<template>
@@ -49,12 +95,70 @@ console.log(servers.value)
></v-avatar>
</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>
<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>
</template>
<style scoped>
.space-y-4 {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>
+7 -106
View File
@@ -2,13 +2,14 @@
import {storeToRefs} from 'pinia'
import {useChannelStore} from '@/stores/channel'
import {useCategoryStore} from '@/stores/category'
import {ref, watch, onMounted} from 'vue'
import {ref, watch} from 'vue'
import {useRoute} from 'vue-router'
import CreateChannelDialog from '@/components/channel/CreateChannelDialog.vue'
const props = defineProps<{
serverId: string
channelId?: string
}>();
}>()
const route = useRoute()
const channelStore = useChannelStore()
@@ -41,59 +42,6 @@ watch(
)
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>
<template>
@@ -125,59 +73,12 @@ const handleCancel = () => {
</v-list>
</v-navigation-drawer>
<v-dialog v-model="showDialog" width="400">
<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="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>
<CreateChannelDialog
v-model="showDialog"
:server-id="serverId"
/>
<v-main>
<router-view/>
</v-main>
</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 {
id: string
name: string
is_default: boolean
created_at: string
updated_at: string
}
export const useServerStore = defineStore("server", {
state: () => ({
servers: [] as Server[]
servers: [] as Server[],
loading: false,
error: null as string | null
}),
actions: {
async fetchServers() {
@@ -15,8 +21,30 @@ export const useServerStore = defineStore("server", {
const response = await api.get("/servers");
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() {
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::repositories::types::ChannelFilter;
use crate::repositories::{AnyResult, RepositoryContext};
use sea_orm::sea_query::OnConflict;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
@@ -21,14 +22,11 @@ impl ChannelRepository {
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();
if let Some(s_id) = server_id {
if let Some(s_id) = filter.server_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?)
}
+4
View File
@@ -24,3 +24,7 @@ pub struct MessageFilter {
pub before_id: Option<uuid::Uuid>,
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)]
pub struct ChannelQueryParams {
pub server_id: Option<Uuid>,
pub category_id: Option<Uuid>,
}
impl Default for ChannelQueryParams {
fn default() -> Self {
Self {
server_id: None,
category_id: None,
}
Self { server_id: None }
}
}
+6 -4
View File
@@ -2,14 +2,15 @@ use crate::core::state::AppState;
use crate::http::context::Superuser;
use crate::http::error::HTTPError;
use crate::routes::channel::dto::{
ChannelQueryParams, ChannelResponse, ChannelRolePermissionResponse, ChannelUserPermissionResponse,
CreateChannelRequest, SetChannelPermissionRequest, UpdateChannelRequest,
ChannelQueryParams, ChannelResponse, ChannelRolePermissionResponse,
ChannelUserPermissionResponse, CreateChannelRequest, SetChannelPermissionRequest,
UpdateChannelRequest,
};
use crate::routes::channel::mapper;
use axum::{
Json,
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use uuid::Uuid;
@@ -30,7 +31,8 @@ pub async fn get_all(
State(state): State<AppState>,
Query(filters): Query<ChannelQueryParams>,
) -> 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(
channels
.into_iter()
+9 -2
View File
@@ -1,7 +1,8 @@
use crate::models::{channel, channel_role_permission, channel_user_permission};
use crate::repositories::types::ChannelFilter;
use crate::routes::channel::dto::{
ChannelResponse, ChannelRolePermissionResponse, ChannelUserPermissionResponse,
CreateChannelRequest, UpdateChannelRequest,
ChannelQueryParams, ChannelResponse, ChannelRolePermissionResponse,
ChannelUserPermissionResponse, CreateChannelRequest, UpdateChannelRequest,
};
use sea_orm::Set;
use uuid::Uuid;
@@ -61,3 +62,9 @@ pub fn channel_role_permission_to_response(
permissions: model.permissions as u64,
}
}
pub fn query_params_to_channel_filter(params: ChannelQueryParams) -> ChannelFilter {
ChannelFilter {
server_id: params.server_id,
}
}