Implement Godot groups UI with browse, create, group page, and role management (task 909744ff1eee)

This commit is contained in:
2026-09-15 19:40:41 +00:00
parent 5b993aa358
commit 8e54fee03d
3 changed files with 604 additions and 0 deletions
+179
View File
@@ -0,0 +1,179 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/groups_ui.gd" id="1_gu"]
[node name="GroupsUI" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_gu")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 32.0
offset_top = 32.0
offset_right = -32.0
offset_bottom = -32.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 12
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 16
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "< Back"
[node name="TitleLabel" type="Label" parent="VBoxContainer/HeaderHBox"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 26
text = "Groups & Communities"
horizontal_alignment = 1
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Refresh"
[node name="StatusMessageLabel" type="Label" parent="VBoxContainer"]
layout_mode = 2
horizontal_alignment = 1
[node name="TabContainer" type="TabContainer" parent="VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3
current_tab = 0
[node name="Browse" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
layout_mode = 2
theme_override_constants/separation = 8
metadata/_tab_index = 0
[node name="FilterHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/Browse"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="MyGroupsCheck" type="CheckBox" parent="VBoxContainer/TabContainer/Browse/FilterHBox"]
layout_mode = 2
text = "Show only my groups"
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/Browse"]
layout_mode = 2
size_flags_vertical = 3
[node name="GroupsList" type="VBoxContainer" parent="VBoxContainer/TabContainer/Browse/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="Create" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
visible = false
layout_mode = 2
theme_override_constants/separation = 8
metadata/_tab_index = 1
[node name="FormVBox" type="VBoxContainer" parent="VBoxContainer/TabContainer/Create"]
custom_minimum_size = Vector2(480, 0)
layout_mode = 2
size_flags_horizontal = 4
theme_override_constants/separation = 12
[node name="NamePrompt" type="Label" parent="VBoxContainer/TabContainer/Create/FormVBox"]
layout_mode = 2
text = "Group Name:"
[node name="NameEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Create/FormVBox"]
layout_mode = 2
placeholder_text = "Enter unique group name..."
[node name="DescPrompt" type="Label" parent="VBoxContainer/TabContainer/Create/FormVBox"]
layout_mode = 2
text = "Group Description:"
[node name="DescEdit" type="TextEdit" parent="VBoxContainer/TabContainer/Create/FormVBox"]
custom_minimum_size = Vector2(0, 100)
layout_mode = 2
placeholder_text = "Describe the goals and community of your group..."
[node name="CreateButton" type="Button" parent="VBoxContainer/TabContainer/Create/FormVBox"]
custom_minimum_size = Vector2(160, 36)
layout_mode = 2
size_flags_horizontal = 4
text = "Create Group"
[node name="GroupDetails" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
visible = false
layout_mode = 2
theme_override_constants/separation = 8
metadata/_tab_index = 2
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/GroupDetails"]
layout_mode = 2
size_flags_vertical = 3
[node name="DetailsVBox" type="VBoxContainer" parent="VBoxContainer/TabContainer/GroupDetails/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 16
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox"]
layout_mode = 2
theme_override_constants/separation = 16
[node name="GroupNameLabel" type="Label" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/HeaderHBox"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 24
text = "Select a Group"
[node name="MemberCountLabel" type="Label" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/HeaderHBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "0 Members"
[node name="GroupDescLabel" type="Label" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox"]
layout_mode = 2
autowrap_mode = 2
text = "No description."
[node name="ActionsHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox"]
layout_mode = 2
theme_override_constants/separation = 12
[node name="JoinButton" type="Button" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/ActionsHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Join Group"
[node name="LeaveButton" type="Button" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/ActionsHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Leave Group"
[node name="DeleteButton" type="Button" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/ActionsHBox"]
custom_minimum_size = Vector2(110, 36)
layout_mode = 2
text = "Delete Group"
[node name="MembersVBox" type="VBoxContainer" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="MembersHeader" type="Label" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/MembersVBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "Members & Roles"
[node name="MembersList" type="VBoxContainer" parent="VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/MembersVBox"]
layout_mode = 2
theme_override_constants/separation = 6
+357
View File
@@ -0,0 +1,357 @@
extends Control
class_name GroupsUI
## Groups & Communities UI.
##
## Features:
## - Browse groups with pagination and "My Groups" filter
## - Create new group (creator becomes owner)
## - Detailed group page: description, member count, member list with roles
## - Join and Leave group actions
## - Role management for owner (promote to admin, demote to member, transfer ownership)
## - Delete group (owner only)
signal back_requested
@onready var back_button: Button = $VBoxContainer/HeaderHBox/BackButton
@onready var title_label: Label = $VBoxContainer/HeaderHBox/TitleLabel
@onready var refresh_button: Button = $VBoxContainer/HeaderHBox/RefreshButton
@onready var status_label: Label = $VBoxContainer/StatusMessageLabel
@onready var tab_container: TabContainer = $VBoxContainer/TabContainer
# Browse controls
@onready var my_groups_check: CheckBox = $VBoxContainer/TabContainer/Browse/FilterHBox/MyGroupsCheck
@onready var groups_list_container: VBoxContainer = $VBoxContainer/TabContainer/Browse/Scroll/GroupsList
# Create controls
@onready var new_group_name_edit: LineEdit = $VBoxContainer/TabContainer/Create/FormVBox/NameEdit
@onready var new_group_desc_edit: TextEdit = $VBoxContainer/TabContainer/Create/FormVBox/DescEdit
@onready var create_group_btn: Button = $VBoxContainer/TabContainer/Create/FormVBox/CreateButton
# Group Page controls
@onready var group_page_vbox: VBoxContainer = $VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox
@onready var group_name_label: Label = $VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/HeaderHBox/GroupNameLabel
@onready var member_count_label: Label = $VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/HeaderHBox/MemberCountLabel
@onready var group_desc_label: Label = $VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/GroupDescLabel
@onready var join_btn: Button = $VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/ActionsHBox/JoinButton
@onready var leave_btn: Button = $VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/ActionsHBox/LeaveButton
@onready var delete_btn: Button = $VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/ActionsHBox/DeleteButton
@onready var members_list_container: VBoxContainer = $VBoxContainer/TabContainer/GroupDetails/Scroll/DetailsVBox/MembersVBox/MembersList
var _api_client: Node = null
var _current_user_id: int = 0
var _active_group_id: int = 0
var _active_group_data: Dictionary = {}
func _ready() -> void:
_api_client = _get_api()
_wire_signals()
if _api_client != null and _api_client.has_token():
_init_user()
refresh_groups()
func _get_api() -> Node:
if has_node("/root/ApiClient"):
return get_node("/root/ApiClient")
return null
func _init_user() -> void:
if _api_client == null:
return
var me_res: Dictionary = await _api_client.get_me()
if me_res.ok and me_res.data is Dictionary:
_current_user_id = int(me_res.data.get("id", 0))
func _wire_signals() -> void:
if back_button:
back_button.pressed.connect(_on_back_pressed)
if refresh_button:
refresh_button.pressed.connect(refresh_groups)
if my_groups_check:
my_groups_check.toggled.connect(func(_t): refresh_groups())
if create_group_btn:
create_group_btn.pressed.connect(_on_create_group_pressed)
if join_btn:
join_btn.pressed.connect(_on_join_pressed)
if leave_btn:
leave_btn.pressed.connect(_on_leave_pressed)
if delete_btn:
delete_btn.pressed.connect(_on_delete_pressed)
func _on_back_pressed() -> void:
back_requested.emit()
if get_tree().current_scene == self:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
func show_message(text: String, is_error: bool = false) -> void:
if status_label:
status_label.text = text
status_label.modulate = Color(1.0, 0.4, 0.4) if is_error else Color(0.4, 1.0, 0.4)
# ── Browse Groups ──
func refresh_groups() -> void:
if _api_client == null or not _api_client.has_token():
return
var mine: bool = my_groups_check.button_pressed if my_groups_check else false
var res: Dictionary = await _api_client.list_groups(1, 50, mine)
_clear_children(groups_list_container)
if not res.ok:
show_message("Failed to load groups: " + res.detail, true)
return
var groups: Array = []
if res.data is Dictionary and res.data.has("groups"):
groups = res.data["groups"]
elif res.data is Array:
groups = res.data
if groups.is_empty():
var empty_lbl := Label.new()
empty_lbl.text = "No groups found. Create one in the Create Group tab!"
empty_lbl.modulate = Color(0.6, 0.6, 0.6)
groups_list_container.add_child(empty_lbl)
return
for g in groups:
var gid: int = int(g.get("id", 0))
var gname: String = str(g.get("name", "Group"))
var gdesc: String = str(g.get("description", ""))
var count: int = int(g.get("member_count", 0))
_build_group_card(gid, gname, gdesc, count)
func _build_group_card(gid: int, gname: String, gdesc: String, count: int) -> void:
var panel := PanelContainer.new()
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 12)
margin.add_theme_constant_override("margin_top", 12)
margin.add_theme_constant_override("margin_right", 12)
margin.add_theme_constant_override("margin_bottom", 12)
var hbox := HBoxContainer.new()
hbox.add_theme_constant_override("separation", 16)
var info_vbox := VBoxContainer.new()
info_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var name_lbl := Label.new()
name_lbl.text = gname
name_lbl.add_theme_font_size_override("font_size", 18)
info_vbox.add_child(name_lbl)
if gdesc != "":
var desc_lbl := Label.new()
desc_lbl.text = gdesc.left(100)
desc_lbl.modulate = Color(0.8, 0.8, 0.8)
info_vbox.add_child(desc_lbl)
var count_lbl := Label.new()
count_lbl.text = "%d member%s" % [count, "" if count == 1 else "s"]
count_lbl.modulate = Color(0.6, 0.8, 1.0)
info_vbox.add_child(count_lbl)
hbox.add_child(info_vbox)
var view_btn := Button.new()
view_btn.text = "View Group"
view_btn.custom_minimum_size = Vector2(110, 36)
view_btn.size_flags_vertical = Control.SIZE_SHRINK_CENTER
view_btn.pressed.connect(func(): open_group_page(gid))
hbox.add_child(view_btn)
margin.add_child(hbox)
panel.add_child(margin)
groups_list_container.add_child(panel)
# ── Create Group ──
func _on_create_group_pressed() -> void:
if _api_client == null:
return
var gname := new_group_name_edit.text.strip_edges()
var gdesc := new_group_desc_edit.text.strip_edges()
if gname == "":
show_message("Group name cannot be empty.", true)
return
var res: Dictionary = await _api_client.create_group(gname, gdesc)
if res.ok and res.data is Dictionary:
var gid: int = int(res.data.get("id", 0))
new_group_name_edit.text = ""
new_group_desc_edit.text = ""
show_message("Group '%s' created!" % gname)
await refresh_groups()
open_group_page(gid)
else:
show_message("Failed to create group: " + res.detail, true)
# ── Group Page Details ──
func open_group_page(group_id: int) -> void:
_active_group_id = group_id
if tab_container:
tab_container.current_tab = 2 # Details tab
await refresh_group_page(group_id)
func refresh_group_page(group_id: int) -> void:
if _api_client == null or group_id <= 0:
return
var res: Dictionary = await _api_client.get_group(group_id)
if not res.ok:
show_message("Failed to load group: " + res.detail, true)
return
_active_group_data = res.data if res.data is Dictionary else {}
var gname: String = str(_active_group_data.get("name", "Group"))
var gdesc: String = str(_active_group_data.get("description", "No description provided."))
var members: Array = _active_group_data.get("members", [])
var count: int = int(_active_group_data.get("member_count", members.size()))
var owner_id: int = int(_active_group_data.get("owner_id", 0))
if group_name_label:
group_name_label.text = gname
if group_desc_label:
group_desc_label.text = gdesc
if member_count_label:
member_count_label.text = "%d Members" % count
# Check membership of current user
var is_member := false
var my_role := ""
for m in members:
var uid: int = int(m.get("user_id", 0))
if uid == _current_user_id:
is_member = true
my_role = str(m.get("role", "member"))
break
var is_owner := (_current_user_id == owner_id) or my_role == "owner"
if join_btn:
join_btn.visible = not is_member
if leave_btn:
leave_btn.visible = is_member and not is_owner
if delete_btn:
delete_btn.visible = is_owner
_populate_members_list(members, is_owner)
func _populate_members_list(members: Array, can_manage_roles: bool) -> void:
_clear_children(members_list_container)
for m in members:
var uid: int = int(m.get("user_id", 0))
var uname: String = "User #%d" % uid
if m.has("user") and m["user"] is Dictionary:
uname = str(m["user"].get("username", uname))
var role: String = str(m.get("role", "member"))
var hbox := HBoxContainer.new()
hbox.add_theme_constant_override("separation", 12)
var name_lbl := Label.new()
name_lbl.text = "%s (ID: %d)" % [uname, uid]
name_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
hbox.add_child(name_lbl)
var role_badge := Label.new()
role_badge.text = "[%s]" % role.to_upper()
if role == "owner":
role_badge.modulate = Color(1.0, 0.8, 0.2)
elif role == "admin":
role_badge.modulate = Color(0.4, 0.8, 1.0)
else:
role_badge.modulate = Color(0.7, 0.7, 0.7)
hbox.add_child(role_badge)
# Role selector for owner (excluding self/owner)
if can_manage_roles and role != "owner":
var opt := OptionButton.new()
opt.add_item("Member")
opt.add_item("Admin")
opt.selected = 1 if role == "admin" else 0
opt.item_selected.connect(func(idx: int): _on_role_changed(uid, "admin" if idx == 1 else "member"))
hbox.add_child(opt)
var xfer_btn := Button.new()
xfer_btn.text = "Make Owner"
xfer_btn.pressed.connect(func(): _on_transfer_ownership(uid))
hbox.add_child(xfer_btn)
members_list_container.add_child(hbox)
func _on_join_pressed() -> void:
if _api_client == null or _active_group_id <= 0:
return
var res: Dictionary = await _api_client.join_group(_active_group_id)
if res.ok:
show_message("Joined group!")
refresh_group_page(_active_group_id)
else:
show_message("Failed to join group: " + res.detail, true)
func _on_leave_pressed() -> void:
if _api_client == null or _active_group_id <= 0:
return
var res: Dictionary = await _api_client.leave_group(_active_group_id)
if res.ok:
show_message("Left group.")
refresh_group_page(_active_group_id)
else:
show_message("Failed to leave group: " + res.detail, true)
func _on_delete_pressed() -> void:
if _api_client == null or _active_group_id <= 0:
return
var res: Dictionary = await _api_client.delete_group(_active_group_id)
if res.ok:
show_message("Group deleted.")
tab_container.current_tab = 0
refresh_groups()
else:
show_message("Failed to delete group: " + res.detail, true)
func _on_role_changed(uid: int, new_role: String) -> void:
if _api_client == null or _active_group_id <= 0:
return
var res: Dictionary = await _api_client.set_group_member_role(_active_group_id, uid, new_role)
if res.ok:
show_message("Role updated to %s." % new_role)
refresh_group_page(_active_group_id)
else:
show_message("Failed to change role: " + res.detail, true)
func _on_transfer_ownership(new_owner_id: int) -> void:
if _api_client == null or _active_group_id <= 0:
return
var res: Dictionary = await _api_client.transfer_group_ownership(_active_group_id, new_owner_id)
if res.ok:
show_message("Ownership transferred to User #%d!" % new_owner_id)
refresh_group_page(_active_group_id)
else:
show_message("Failed to transfer ownership: " + res.detail, true)
func _clear_children(node: Node) -> void:
if node == null:
return
for c in node.get_children():
c.queue_free()
+68
View File
@@ -0,0 +1,68 @@
extends SceneTree
## Headless smoke test for GroupsUI scene and script.
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
print("SMOKE: Starting Groups UI verification...")
var scene_res := load("res://scenes/groups_ui.tscn")
if scene_res == null:
printerr("FAIL: Could not load res://scenes/groups_ui.tscn")
quit(1)
return
var ui: Control = scene_res.instantiate()
if ui == null:
printerr("FAIL: Could not instantiate GroupsUI")
quit(1)
return
root.add_child(ui)
await process_frame
# Verify essential nodes
if ui.groups_list_container == null or ui.members_list_container == null:
printerr("FAIL: container nodes are null")
quit(1)
return
if ui.group_name_label == null or ui.member_count_label == null:
printerr("FAIL: label nodes are null")
quit(1)
return
# Verify signal declaration
if not ui.has_signal("back_requested"):
printerr("FAIL: back_requested signal missing")
quit(1)
return
# Test group card building
ui._build_group_card(1, "Alpha Guild", "Top tier world creators", 5)
if ui.groups_list_container.get_child_count() != 1:
printerr("FAIL: Group card not added to groups_list_container")
quit(1)
return
print("PASS: Group card building verified.")
# Test members list population with owner management
var mock_members := [
{"user_id": 10, "role": "owner", "user": {"username": "OwnerUser"}},
{"user_id": 20, "role": "admin", "user": {"username": "AdminUser"}},
{"user_id": 30, "role": "member", "user": {"username": "NormalUser"}}
]
ui._populate_members_list(mock_members, true)
if ui.members_list_container.get_child_count() != 3:
printerr("FAIL: Member list count mismatch")
quit(1)
return
print("PASS: Group members list population with role management verified.")
ui.queue_free()
print("SMOKE RESULT: PASS")
quit(0)