From 8e54fee03d08780d3453f3165aa271eb3c20c269 Mon Sep 17 00:00:00 2001 From: daddy Date: Tue, 15 Sep 2026 19:40:41 +0000 Subject: [PATCH] Implement Godot groups UI with browse, create, group page, and role management (task 909744ff1eee) --- scenes/groups_ui.tscn | 179 ++++++++++++++++ scripts/groups_ui.gd | 357 ++++++++++++++++++++++++++++++++ scripts/smoke_test_groups_ui.gd | 68 ++++++ 3 files changed, 604 insertions(+) create mode 100644 scenes/groups_ui.tscn create mode 100644 scripts/groups_ui.gd create mode 100644 scripts/smoke_test_groups_ui.gd diff --git a/scenes/groups_ui.tscn b/scenes/groups_ui.tscn new file mode 100644 index 0000000..3d5db78 --- /dev/null +++ b/scenes/groups_ui.tscn @@ -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 diff --git a/scripts/groups_ui.gd b/scripts/groups_ui.gd new file mode 100644 index 0000000..0e283ea --- /dev/null +++ b/scripts/groups_ui.gd @@ -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() diff --git a/scripts/smoke_test_groups_ui.gd b/scripts/smoke_test_groups_ui.gd new file mode 100644 index 0000000..2ac103e --- /dev/null +++ b/scripts/smoke_test_groups_ui.gd @@ -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)