From c234c658b8b7cf97328e1e1d19d61f6c1febc077 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Mon, 31 Aug 2026 16:02:48 +0200 Subject: [PATCH 1/4] polish student group overview - use admin view to keep track of filters - use clickable groups - hide inactive groups by default --- src/api/OfflineApi/index.ts | 5 +- .../Admin/StudentGroupPanel/index.tsx | 90 ++++-------------- src/components/StudentGroup/index.tsx | 3 + src/pages/user/LocalDbView.tsx | 12 +++ src/pages/user/QuickGroupActions/index.tsx | 88 +++++++++++++++++ .../user/QuickGroupActions/styles.module.scss | 22 +++++ src/stores/ViewStores/AdminView.ts | 94 +++++++++++++++++++ src/stores/ViewStores/index.ts | 3 + 8 files changed, 244 insertions(+), 73 deletions(-) create mode 100644 src/pages/user/QuickGroupActions/index.tsx create mode 100644 src/pages/user/QuickGroupActions/styles.module.scss create mode 100644 src/stores/ViewStores/AdminView.ts diff --git a/src/api/OfflineApi/index.ts b/src/api/OfflineApi/index.ts index 480aca141..fbb3491f4 100644 --- a/src/api/OfflineApi/index.ts +++ b/src/api/OfflineApi/index.ts @@ -426,7 +426,10 @@ export default class OfflineApi { return rejectResponse({} as T, 400, 'Not implemented'); case 'studentGroups': return resolveResponse( - (await this.upsertStudentGroupRecord(data as Partial, id)) as unknown as T + (await this.upsertStudentGroupRecord( + (data as unknown as { data: Partial }).data, + id + )) as unknown as T ); case 'users': return resolveResponse(data as unknown as T); diff --git a/src/components/Admin/StudentGroupPanel/index.tsx b/src/components/Admin/StudentGroupPanel/index.tsx index 71735f7c0..1f23bdce7 100644 --- a/src/components/Admin/StudentGroupPanel/index.tsx +++ b/src/components/Admin/StudentGroupPanel/index.tsx @@ -16,8 +16,9 @@ import Badge from '@tdev-components/shared/Badge'; const StudentGroupPanel = observer(() => { const userStore = useStore('userStore'); const groupStore = useStore('studentGroupStore'); + const viewStore = useStore('viewStore'); + const adminView = viewStore.admin; const current = userStore.current; - const [searchFilter, setSearchFilter] = React.useState(''); if (!current?.hasElevatedAccess) { return null; @@ -28,6 +29,7 @@ const StudentGroupPanel = observer(() => {
- {(() => { - let searchRegex; - try { - searchRegex = new RegExp(searchFilter, 'i'); - } catch { - searchRegex = null; - } - - const matches = groupStore.managedStudentGroups - .filter((g) => !g.parentId) - .map((group) => { - let matchPriority = 0; - - if (searchRegex) { - // Group name, student name or student email starts with the search filter? - const startsWithMatch = - group.name.toLowerCase().startsWith(searchFilter.toLowerCase()) || - group.students?.some( - (s) => - s.name.toLowerCase().startsWith(searchFilter.toLowerCase()) || - s.email.toLowerCase().startsWith(searchFilter.toLowerCase()) - ); - - // Group name matches (RegExp)? - const groupNameMatch = searchRegex.test(group.name); - - // Student name or email matches (RegExp)? - const studentMatch = group.students?.some( - (s) => searchRegex.test(s.name) || searchRegex.test(s.email) - ); - - // Description matches (RegExp)? - const descriptionMatch = searchRegex.test(group.description ?? ''); - - if (startsWithMatch) { - matchPriority = 0; - } else if (studentMatch) { - matchPriority = 1; - } else if (groupNameMatch) { - matchPriority = 2; - } else if (descriptionMatch) { - matchPriority = 3; - } else { - // We have a search filter and this doesn't match it. - return null; - } - } - - return { - group: group, - matchPriority - }; - }) - .filter((group) => !!group); // Non-matched groups are null - filter them out. - - return _.orderBy( - matches, - ['matchPriority', '_pristine.name', 'createdAt'], - ['asc', 'asc', 'desc'] - ).map((match) => ( - - )); - })()} + {adminView.filteredStudentGroups.map((match) => ( + + ))}
); diff --git a/src/components/StudentGroup/index.tsx b/src/components/StudentGroup/index.tsx index eade791f5..b4f521549 100644 --- a/src/components/StudentGroup/index.tsx +++ b/src/components/StudentGroup/index.tsx @@ -54,6 +54,7 @@ const StudentGroup = observer((props: Props) => { const adminStore = useStore('adminStore'); const userStore = useStore('userStore'); const groupStore = useStore('studentGroupStore'); + const viewStore = useStore('viewStore'); const group = props.studentGroup; const isAdmin = group.isGroupAdmin; React.useEffect(() => { @@ -269,6 +270,7 @@ const StudentGroup = observer((props: Props) => { value={group.parentId || ''} onChange={(e) => { group.setParentId(e.target.value || null); + viewStore.admin.setGroupOpen(group.id, true); }} > @@ -491,6 +493,7 @@ const StudentGroup = observer((props: Props) => { {group.children.length} } + open={viewStore.admin.openGroupIds.has(group.id) ?? undefined} >
{group.children.map((child) => ( diff --git a/src/pages/user/LocalDbView.tsx b/src/pages/user/LocalDbView.tsx index d306b321f..ca75919b0 100644 --- a/src/pages/user/LocalDbView.tsx +++ b/src/pages/user/LocalDbView.tsx @@ -18,6 +18,7 @@ import DbImport from '@tdev-components/utils/DbActions/DbImport'; import DbExport from '@tdev-components/utils/DbActions/DbExport'; import DbDestroy from '@tdev-components/utils/DbActions/DbDestroy'; import { IfmColors } from '@tdev-components/shared/Colors'; +import QuickGroupActions from './QuickGroupActions'; const { OFFLINE_API, tdevConfig } = customFields; @@ -82,6 +83,17 @@ const LocalDbView = observer(() => { {sessionStore.apiMode} + {process.env.NODE_ENV === 'development' && ( + <> +
In Gruppen
+
+ Debug-View: Nur in development-Angezeigt +
+
+ +
+ + )}

Account

diff --git a/src/pages/user/QuickGroupActions/index.tsx b/src/pages/user/QuickGroupActions/index.tsx new file mode 100644 index 000000000..ded557d37 --- /dev/null +++ b/src/pages/user/QuickGroupActions/index.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import clsx from 'clsx'; +import styles from './styles.module.scss'; +import { observer } from 'mobx-react-lite'; +import { useStore } from '@tdev-hooks/useStore'; +import NavReloadRequest from '@tdev-components/Admin/ActionRequest/NavReloadRequest'; +import Badge from '@tdev-components/shared/Badge'; +import { orderBy } from 'es-toolkit/array'; +import StudentGroup from '@tdev-models/StudentGroup'; +import Button from '@tdev-components/shared/Button'; +import useBaseUrl from '@docusaurus/useBaseUrl'; +import Link from '@docusaurus/Link'; +import { action } from 'mobx'; + +interface Props { + groups: StudentGroup[]; + showHidden?: boolean; +} + +const GroupList = observer((props: Props) => { + const userStore = useStore('userStore'); + const socketStore = useStore('socketStore'); + const { groups, showHidden } = props; + const url = useBaseUrl('/admin?panel=studentGroups'); + const viewStore = useStore('viewStore'); + return ( +
    + {groups.map((group) => { + const children = orderBy( + group.children.filter((child) => showHidden || child.isActive), + ['isActive', 'name'], + ['desc', 'asc'] + ); + return ( +
  • +
    + {socketStore.connectedClients.get(group.id) ?? 0} +
    +
    + {userStore.current?.hasElevatedAccess ? ( + { + viewStore.admin.setGroupSearchFilter(group.name); + [...group.parentIds, group.id].forEach((id) => { + viewStore.admin.setGroupOpen(id, true); + }); + })} + > + {group.name} + + ) : ( + {group.name} + )} + +
    + +
  • + ); + })} +
+ ); +}); + +const QuickGroupActions = observer(() => { + const [showHidden, setShowHidden] = React.useState(false); + const groupStore = useStore('studentGroupStore'); + const hasHidden = groupStore.studentGroups.some((group) => !group.isActive); + const groups = orderBy( + groupStore.studentGroups.filter((group) => !group.parent && (showHidden || group.isActive)), + ['isActive', 'name'], + ['desc', 'asc'] + ); + + return ( +
+ + {hasHidden && ( +
+ ); +}); + +export default QuickGroupActions; diff --git a/src/pages/user/QuickGroupActions/styles.module.scss b/src/pages/user/QuickGroupActions/styles.module.scss new file mode 100644 index 000000000..df725efab --- /dev/null +++ b/src/pages/user/QuickGroupActions/styles.module.scss @@ -0,0 +1,22 @@ +.quickGroupActions { + --ifm-list-left-padding: 0.5em; + .item { + list-style: none; + position: relative; + padding-left: 2em; + .count { + position: absolute; + top: 0.25em; + left: -0.5em; + min-width: 32px; + line-height: 0; + display: flex; + justify-content: flex-end; + } + .name { + display: flex; + align-items: center; + gap: 0.5em; + } + } +} diff --git a/src/stores/ViewStores/AdminView.ts b/src/stores/ViewStores/AdminView.ts new file mode 100644 index 000000000..18e9fdc34 --- /dev/null +++ b/src/stores/ViewStores/AdminView.ts @@ -0,0 +1,94 @@ +import StudentGroup from '@tdev-models/StudentGroup'; +import { RootStore } from '@tdev-stores/rootStore'; +import { orderBy } from 'es-toolkit/array'; +import { action, computed, observable } from 'mobx'; + +export class AdminView { + readonly root: RootStore; + @observable accessor groupSearchFilter = ''; + openGroupIds = observable.set([]); + + constructor(root: RootStore) { + this.root = root; + } + + @action + setGroupSearchFilter(filter?: string) { + this.groupSearchFilter = filter ?? ''; + this.openGroupIds.clear(); + } + + @action + setGroupOpen(groupId: string, isOpen: boolean) { + if (isOpen) { + this.openGroupIds.add(groupId); + } else { + this.openGroupIds.delete(groupId); + } + } + + @computed + get filteredStudentGroups() { + const filter = new RegExp(this.groupSearchFilter, 'i'); + const startsWith = new RegExp(`^${this.groupSearchFilter}`, 'i'); + const groups = this.root.studentGroupStore.managedStudentGroups + .filter((g) => !g.parentId) + .map((group) => { + // Group name, student name or student email starts with the search filter? + const startsWithMatch = + startsWith.test(group.name) || + group.students?.some((s) => startsWith.test(s.name) || startsWith.test(s.email)); + if (startsWithMatch) { + return { + group: group, + matchPriority: 0 + }; + } + + // Student name or email matches (RegExp)? + const studentMatch = group.students?.some((s) => filter.test(s.name) || filter.test(s.email)); + if (studentMatch) { + return { + group: group, + matchPriority: 1 + }; + } + + const nestedSearch = (g: StudentGroup, test: (group: StudentGroup) => boolean): boolean => { + if (test(g)) { + return true; + } + for (const child of g.children) { + if (nestedSearch(child, test)) { + return true; + } + } + return false; + }; + + // Group name matches (RegExp)? + const groupNameMatch = nestedSearch(group, (g) => filter.test(g.name)); + if (groupNameMatch) { + return { + group: group, + matchPriority: 2 + }; + } + + // Description matches (RegExp)? + const descriptionMatch = nestedSearch(group, (g) => filter.test(g.description ?? '')); + if (descriptionMatch) { + return { + group: group, + matchPriority: 3 + }; + } + }) + .filter((group) => !!group); // Non-matched groups are null - filter them out. + return orderBy( + groups, + ['matchPriority', (m) => m.group._pristine.name, (m) => m.group.createdAt], + ['asc', 'asc', 'desc'] + ); + } +} diff --git a/src/stores/ViewStores/index.ts b/src/stores/ViewStores/index.ts index 90b25639b..83f3532ec 100644 --- a/src/stores/ViewStores/index.ts +++ b/src/stores/ViewStores/index.ts @@ -2,6 +2,7 @@ import { ViewStoreType, ViewStore as ViewStores, ViewStoreTypeMapping } from '@t import { RootStore } from '@tdev-stores/rootStore'; import { action, computed, observable, observableRef } from 'mobx'; import { PermissionsControlView } from './PermissionsControlView'; +import { AdminView } from './AdminView'; export interface ViewStoreProps { store: ViewStoreTypeMapping[T]; @@ -11,6 +12,7 @@ export default class ViewStore { readonly root: RootStore; stores = new Map(); @observableRef accessor permissionControl: PermissionsControlView = null as any; + @observableRef accessor admin: AdminView = null as any; @observable accessor fullscreenTargetId: string | null = null; @observable accessor isPageVisible: boolean = true; @observable accessor _presentationPanelState: null | 'open' | 'closed' = null; @@ -19,6 +21,7 @@ export default class ViewStore { constructor(store: RootStore) { this.root = store; this.permissionControl = new PermissionsControlView(store); + this.admin = new AdminView(store); } @action From d5c843e57c8ea77477c8054e448a88bc27dcf8f8 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Mon, 31 Aug 2026 16:03:55 +0200 Subject: [PATCH 2/4] rename viewStore.admin to viewStore.adminView --- src/components/Admin/StudentGroupPanel/index.tsx | 2 +- src/components/StudentGroup/index.tsx | 4 ++-- src/pages/user/QuickGroupActions/index.tsx | 4 ++-- src/stores/ViewStores/index.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/Admin/StudentGroupPanel/index.tsx b/src/components/Admin/StudentGroupPanel/index.tsx index 1f23bdce7..5bc2833d3 100644 --- a/src/components/Admin/StudentGroupPanel/index.tsx +++ b/src/components/Admin/StudentGroupPanel/index.tsx @@ -17,7 +17,7 @@ const StudentGroupPanel = observer(() => { const userStore = useStore('userStore'); const groupStore = useStore('studentGroupStore'); const viewStore = useStore('viewStore'); - const adminView = viewStore.admin; + const adminView = viewStore.adminView; const current = userStore.current; if (!current?.hasElevatedAccess) { diff --git a/src/components/StudentGroup/index.tsx b/src/components/StudentGroup/index.tsx index b4f521549..486ede2c6 100644 --- a/src/components/StudentGroup/index.tsx +++ b/src/components/StudentGroup/index.tsx @@ -270,7 +270,7 @@ const StudentGroup = observer((props: Props) => { value={group.parentId || ''} onChange={(e) => { group.setParentId(e.target.value || null); - viewStore.admin.setGroupOpen(group.id, true); + viewStore.adminView.setGroupOpen(group.id, true); }} > @@ -493,7 +493,7 @@ const StudentGroup = observer((props: Props) => { {group.children.length} } - open={viewStore.admin.openGroupIds.has(group.id) ?? undefined} + open={viewStore.adminView.openGroupIds.has(group.id) ?? undefined} >
{group.children.map((child) => ( diff --git a/src/pages/user/QuickGroupActions/index.tsx b/src/pages/user/QuickGroupActions/index.tsx index ded557d37..bf3ed06b6 100644 --- a/src/pages/user/QuickGroupActions/index.tsx +++ b/src/pages/user/QuickGroupActions/index.tsx @@ -41,9 +41,9 @@ const GroupList = observer((props: Props) => { { - viewStore.admin.setGroupSearchFilter(group.name); + viewStore.adminView.setGroupSearchFilter(group.name); [...group.parentIds, group.id].forEach((id) => { - viewStore.admin.setGroupOpen(id, true); + viewStore.adminView.setGroupOpen(id, true); }); })} > diff --git a/src/stores/ViewStores/index.ts b/src/stores/ViewStores/index.ts index 83f3532ec..e3a8811e4 100644 --- a/src/stores/ViewStores/index.ts +++ b/src/stores/ViewStores/index.ts @@ -12,7 +12,7 @@ export default class ViewStore { readonly root: RootStore; stores = new Map(); @observableRef accessor permissionControl: PermissionsControlView = null as any; - @observableRef accessor admin: AdminView = null as any; + @observableRef accessor adminView: AdminView = null as any; @observable accessor fullscreenTargetId: string | null = null; @observable accessor isPageVisible: boolean = true; @observable accessor _presentationPanelState: null | 'open' | 'closed' = null; @@ -21,7 +21,7 @@ export default class ViewStore { constructor(store: RootStore) { this.root = store; this.permissionControl = new PermissionsControlView(store); - this.admin = new AdminView(store); + this.adminView = new AdminView(store); } @action From 63eada6a000644605a08a3e9f323195830f2f265 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Mon, 31 Aug 2026 16:13:38 +0200 Subject: [PATCH 3/4] use the component in api view --- src/pages/user/ApiView.tsx | 21 ++----------------- src/pages/user/LocalDbView.tsx | 4 ++-- .../index.tsx | 4 ++-- .../styles.module.scss | 0 4 files changed, 6 insertions(+), 23 deletions(-) rename src/pages/user/{QuickGroupActions => QuickGroupOverview}/index.tsx (97%) rename src/pages/user/{QuickGroupActions => QuickGroupOverview}/styles.module.scss (100%) diff --git a/src/pages/user/ApiView.tsx b/src/pages/user/ApiView.tsx index 07083cdc2..f0b4b21d6 100644 --- a/src/pages/user/ApiView.tsx +++ b/src/pages/user/ApiView.tsx @@ -18,6 +18,7 @@ import { SIZE_M, SIZE_XS } from '@tdev-components/shared/iconSizes'; import { authClient } from '@tdev/auth-client'; import CodeThemeToggle from '@tdev-components/utils/CodeThemeToggle'; import customFields from '@tdev-components/utils/customFields'; +import QuickGroupOverview from './QuickGroupOverview'; const { OFFLINE_API } = customFields; @@ -110,25 +111,7 @@ const ApiView = observer(() => { {viewedUser && !userStore.isUserSwitched && ( <>
In Gruppen
- {groupStore.studentGroups.map((group) => { - return ( - -
{group.name}
-
- - {socketStore.connectedClients.get(group.id)} - - -
-
- ); - })} + )} diff --git a/src/pages/user/LocalDbView.tsx b/src/pages/user/LocalDbView.tsx index ca75919b0..a84abec93 100644 --- a/src/pages/user/LocalDbView.tsx +++ b/src/pages/user/LocalDbView.tsx @@ -18,7 +18,7 @@ import DbImport from '@tdev-components/utils/DbActions/DbImport'; import DbExport from '@tdev-components/utils/DbActions/DbExport'; import DbDestroy from '@tdev-components/utils/DbActions/DbDestroy'; import { IfmColors } from '@tdev-components/shared/Colors'; -import QuickGroupActions from './QuickGroupActions'; +import QuickGroupOverview from './QuickGroupOverview'; const { OFFLINE_API, tdevConfig } = customFields; @@ -90,7 +90,7 @@ const LocalDbView = observer(() => { Debug-View: Nur in development-Angezeigt
- +
)} diff --git a/src/pages/user/QuickGroupActions/index.tsx b/src/pages/user/QuickGroupOverview/index.tsx similarity index 97% rename from src/pages/user/QuickGroupActions/index.tsx rename to src/pages/user/QuickGroupOverview/index.tsx index bf3ed06b6..c903d859f 100644 --- a/src/pages/user/QuickGroupActions/index.tsx +++ b/src/pages/user/QuickGroupOverview/index.tsx @@ -62,7 +62,7 @@ const GroupList = observer((props: Props) => { ); }); -const QuickGroupActions = observer(() => { +const QuickGroupOverview = observer(() => { const [showHidden, setShowHidden] = React.useState(false); const groupStore = useStore('studentGroupStore'); const hasHidden = groupStore.studentGroups.some((group) => !group.isActive); @@ -85,4 +85,4 @@ const QuickGroupActions = observer(() => { ); }); -export default QuickGroupActions; +export default QuickGroupOverview; diff --git a/src/pages/user/QuickGroupActions/styles.module.scss b/src/pages/user/QuickGroupOverview/styles.module.scss similarity index 100% rename from src/pages/user/QuickGroupActions/styles.module.scss rename to src/pages/user/QuickGroupOverview/styles.module.scss From 2929b63692940b291f8886aa8cad2626da0f2115 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Mon, 31 Aug 2026 16:40:07 +0200 Subject: [PATCH 4/4] move to dd --- src/pages/user/ApiView.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/user/ApiView.tsx b/src/pages/user/ApiView.tsx index f0b4b21d6..4a09e658e 100644 --- a/src/pages/user/ApiView.tsx +++ b/src/pages/user/ApiView.tsx @@ -111,7 +111,9 @@ const ApiView = observer(() => { {viewedUser && !userStore.isUserSwitched && ( <>
In Gruppen
- +
+ +
)}