Files
UnrealEngine/Engine/Source/Programs/SubmitTool/Private/View/Widgets/SJiraWidget.cpp
2025-05-18 13:04:45 +08:00

630 lines
17 KiB
C++

// Copyright Epic Games, Inc. All Rights Reserved.
#include "SJiraWidget.h"
#include "Models/Tag.h"
#include "Models/ModelInterface.h"
#include "Logic/JiraService.h"
#include "Framework/Application/SlateApplication.h"
#include "View/SubmitToolStyle.h"
#include "Widgets/Layout/SScrollBorder.h"
#include "Widgets/Layout/SUniformGridPanel.h"
#include "Widgets/Input/SHyperlink.h"
#include "Widgets/Input/SEditableTextBox.h"
#include "Widgets/Input/SCheckBox.h"
#include "Widgets/SWindow.h"
#include "Widgets/Views/SListView.h"
#include "Widgets/Layout/SScrollBox.h"
#include "Widgets/SToolTip.h"
#include "SubmitToolUtils.h"
namespace JiraIssuesColumns
{
static const FName Checked("Checked");
static const FName IssueType("IssueType");
static const FName Issue("Issue");
static const FName Status("Status");
static const FName Summary("Summary");
};
constexpr const TCHAR* FilteredStatus[] = {
TEXT("Closed"),
TEXT("Done"),
TEXT("Work Complete"),
};
void SJiraWidget::Construct(const FArguments& InArgs)
{
ModelInterface = InArgs._ModelInterface;
JiraService = ModelInterface->GetJiraService();
PresubmitCallbackHandle = ModelInterface->PrepareSubmitCallBack.AddSP(this, &SJiraWidget::OnSubmitCallback);
MainWindow = InArgs._ParentWindow.ToSharedRef();
ParentWindow = nullptr;
UsernameField = SNew(SEditableTextBox).Text(FText::FromString(ModelInterface->GetUsername())).IsEnabled_Lambda([this] { return !JiraService.Pin()->bOngoingRequest; });
PasswordField = SNew(SEditableTextBox).IsPassword(true).IsEnabled_Lambda([this] { return !JiraService.Pin()->bOngoingRequest; });;
CredentialSection = BuildCredentialsWidget();
IssuesSection = BuildIssuesWidget();
ChildSlot.AttachWidget(CredentialSection.ToSharedRef());
JiraService.Pin()->OnJiraIssuesRetrievedCallback.BindSP(this, &SJiraWidget::JiraIssuesAvailable);
if(JiraService.Pin()->GetIssues().Num() > 0)
{
JiraIssuesAvailable(true);
}
}
SJiraWidget::~SJiraWidget()
{
if (this->PresubmitCallbackHandle.IsValid())
{
this->ModelInterface->PrepareSubmitCallBack.Remove(this->PresubmitCallbackHandle);
}
}
void SJiraWidget::OnSubmitCallback()
{
if (this->ParentWindow != nullptr && this->ParentWindow->IsVisible())
{
this->ParentWindow->HideWindow();
}
}
void SJiraWidget::Open(const FTag* InTargetTag)
{
if(!ParentWindow.IsValid())
{
ParentWindow = SNew(SWindow)
.SizingRule(ESizingRule::UserSized)
.SupportsMaximize(false)
.SupportsMinimize(false)
.MinWidth(850)
.MinHeight(400);
ParentWindow->SetRequestDestroyWindowOverride(FRequestDestroyWindowOverride::CreateLambda([](const TSharedRef<SWindow>& Window) {Window->HideWindow(); }));
FSlateApplication::Get().AddWindowAsNativeChild(ParentWindow.ToSharedRef(), MainWindow.ToSharedRef(), false);
FDeprecateSlateVector2D NewPosition = MainWindow->GetPositionInScreen();
NewPosition.X += MainWindow->GetSizeInScreen().X;
ParentWindow->MoveWindowTo(NewPosition);
FSubmitToolUtils::EnsureWindowIsInView(ParentWindow.ToSharedRef(), true);
ParentWindow->SetContent(AsShared());
}
TargetTag = InTargetTag;
ParentWindow->SetTitle(FText::FromString(FString::Format(TEXT("Select issues for Tag '{0}'"), { TargetTag->Definition.TagLabel })));
ParentWindow->BringToFront();
ParentWindow->ShowWindow();
}
TSharedPtr<SWidget> SJiraWidget::BuildCredentialsWidget()
{
return SNew(SBox)
.HAlign(EHorizontalAlignment::HAlign_Center)
.VAlign(EVerticalAlignment::VAlign_Center)
[
SNew(SVerticalBox)
+ SVerticalBox::Slot()
.Padding(0, -15, 0, 0)
.HAlign(EHorizontalAlignment::HAlign_Center)
.VAlign(EVerticalAlignment::VAlign_Center)
.AutoHeight()
[
SNew(STextBlock)
.Text_Lambda([this]() {return JiraService.Pin()->bOngoingRequest ? NSLOCTEXT("JiraWindow", "Login", "Login in Progress...") : FText(); })
]
+SVerticalBox::Slot()
.AutoHeight()
[
SNew(SHorizontalBox)
+SHorizontalBox::Slot()
.AutoWidth()
.HAlign(EHorizontalAlignment::HAlign_Center)
.VAlign(EVerticalAlignment::VAlign_Center)
.Padding(5)
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Username:")))
]
+SHorizontalBox::Slot()
.Padding(5)
[
UsernameField.ToSharedRef()
]
]
+SVerticalBox::Slot()
.AutoHeight()
[
SNew(SHorizontalBox)
+SHorizontalBox::Slot()
.AutoWidth()
.HAlign(EHorizontalAlignment::HAlign_Center)
.VAlign(EVerticalAlignment::VAlign_Center)
.Padding(5)
[
SNew(STextBlock)
.Text(FText::FromString(TEXT("Password:")))
]
+SHorizontalBox::Slot()
.Padding(5)
[
PasswordField.ToSharedRef()
]
]
+SVerticalBox::Slot()
.AutoHeight()
.Padding(5)
[
SNew(SUniformGridPanel)
.SlotPadding(FAppStyle::GetMargin("StandardDialog.SlotPadding"))
.MinDesiredSlotWidth(FAppStyle::GetFloat("StandardDialog.MinDesiredSlotWidth"))
+SUniformGridPanel::Slot(0, 0)
[
SNew(SButton)
.IsEnabled_Lambda([&JiraService = JiraService] { return !JiraService.Pin()->bOngoingRequest; })
.OnClicked(this, &SJiraWidget::Login)
.ButtonStyle(FAppStyle::Get(), "PrimaryButton")
[
SNew(STextBlock)
.MinDesiredWidth(130)
.Justification(ETextJustify::Center)
.Text(FText::FromString("Login"))
]
]
+SUniformGridPanel::Slot(1, 0)
[
SNew(SButton)
.IsEnabled_Lambda([&ModelInterface = ModelInterface] { return FModelInterface::GetInputEnabled(); })
.OnClicked_Lambda([this]() { ParentWindow->HideWindow(); return FReply::Handled(); })
[
SNew(STextBlock)
.MinDesiredWidth(130)
.Justification(ETextJustify::Center)
.Text(FText::FromString("Close"))
]
]
]
];
}
TSharedPtr<SWidget> SJiraWidget::BuildIssuesWidget()
{
TSharedRef scroll = SNew(SScrollBar);
return SNew(SVerticalBox)
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(5)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.AutoWidth()
.VAlign(VAlign_Center)
[
SNew(STextBlock)
.Justification(ETextJustify::Center)
.Text(FText::FromString("Filter:"))
]
+ SHorizontalBox::Slot()
.FillWidth(1)
.Padding(5, 0, 0, 0)
[
SNew(SEditableTextBox)
.Text_Lambda([&text = FilterText] {return FText::FromString(text); })
.OnTextChanged_Lambda([this](FText text) { this->ApplyFilter(text.ToString()); })
]
+ SHorizontalBox::Slot()
.AutoWidth()
.VAlign(VAlign_Center)
.Padding(5,0)
[
SNew(SButton)
.ButtonStyle(FAppStyle::Get(), "InvisibleButton")
.IsFocusable(false)
.OnClicked_Lambda([this]() { bIncludeClosedJira = !bIncludeClosedJira; ApplyFilter(FilterText); return FReply::Handled(); })
[
SNew(STextBlock)
.Justification(ETextJustify::Center)
.MinDesiredWidth(60)
.Text(FText::FromString(TEXT("Include Closed")))
]
]
+ SHorizontalBox::Slot()
.VAlign(VAlign_Center)
.AutoWidth()
[
SNew(SCheckBox)
.IsChecked_Lambda([this]() { return bIncludeClosedJira ? ECheckBoxState::Checked : ECheckBoxState::Unchecked; })
.OnCheckStateChanged_Lambda([this](ECheckBoxState newState) { bIncludeClosedJira = static_cast<bool>(newState); ApplyFilter(FilterText); })
]
]
+ SVerticalBox::Slot()
.Padding(2)
.FillHeight(1)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.FillWidth(1)
[
SNew(SScrollBox)
.Orientation(EOrientation::Orient_Horizontal)
+ SScrollBox::Slot()
.FillSize(1)
[
SAssignNew(ListView, SListView<TSharedPtr<FJiraIssue>>)
.SelectionMode(ESelectionMode::Single)
.ExternalScrollbar(scroll)
.ListItemsSource(&this->JiraIssuesFiltered)
.OnKeyDownHandler(this, &SJiraWidget::OnListKeyDown)
.OnSelectionChanged_Lambda([this](TSharedPtr<FJiraIssue> InIssue, ESelectInfo::Type InSelectInfo)
{
if (InIssue.IsValid())
{
SelectedIssue = InIssue;
}
else
{
SelectedIssue = nullptr;
}
})
.OnMouseButtonDoubleClick(this, &SJiraWidget::OnJiraDoubleClicked)
.OnGenerateRow(this, &SJiraWidget::GenerateRow)
.HeaderRow
(
SNew(SHeaderRow)
+SHeaderRow::Column(JiraIssuesColumns::Checked)
.DefaultLabel(NSLOCTEXT("JiraWindow", "Checked", " "))
.ManualWidth(24)
.SortMode(this, &SJiraWidget::GetSortMode, JiraIssuesColumns::Checked)
.OnSort(this, &SJiraWidget::OnColumnSort)
+ SHeaderRow::Column(JiraIssuesColumns::Issue)
.DefaultLabel(NSLOCTEXT("JiraWindow", "Issue", "Issue"))
.ManualWidth(96)
.SortMode(this, &SJiraWidget::GetSortMode, JiraIssuesColumns::Issue)
.InitialSortMode(EColumnSortMode::Ascending)
.OnSort(this, &SJiraWidget::OnColumnSort)
+ SHeaderRow::Column(JiraIssuesColumns::IssueType)
.DefaultLabel(NSLOCTEXT("JiraWindow", "IssueType", "Type"))
.ManualWidth(96)
.SortMode(this, &SJiraWidget::GetSortMode, JiraIssuesColumns::IssueType)
.OnSort(this, &SJiraWidget::OnColumnSort)
+ SHeaderRow::Column(JiraIssuesColumns::Status)
.DefaultLabel(NSLOCTEXT("JiraWindow", "Status", "Status"))
.ManualWidth(124)
.SortMode(this, &SJiraWidget::GetSortMode, JiraIssuesColumns::Status)
.OnSort(this, &SJiraWidget::OnColumnSort)
+ SHeaderRow::Column(JiraIssuesColumns::Summary)
.DefaultLabel(NSLOCTEXT("JiraWindow", "Summary", "Summary"))
.FillWidth(1.0)
.SortMode(this, &SJiraWidget::GetSortMode, JiraIssuesColumns::Summary)
.OnSort(this, &SJiraWidget::OnColumnSort)
)
]
]
+ SHorizontalBox::Slot()
.AutoWidth()
[
scroll
]
]
+ SVerticalBox::Slot()
.AutoHeight()
.HAlign(HAlign_Right)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.AutoWidth()
.HAlign(HAlign_Right)
.Padding(5)
[
SNew(SButton)
.IsEnabled_Lambda(FModelInterface::GetInputEnabled)
.Text_Lambda([&issue = SelectedIssue, &targetTag = TargetTag]() -> FText {
if (issue == nullptr || targetTag == nullptr)
{
return FText::FromString("Select issue");
}
FString IssueKey = issue->Key;
TArray<FString> CurrentValues = targetTag->GetValues();
if (!CurrentValues.ContainsByPredicate([IssueKey](const FString& Value) { return Value.Equals(IssueKey, ESearchCase::IgnoreCase); }))
{
return FText::FromString("Add issue");
}
return FText::FromString("Remove issue");
})
.OnClicked(this, &SJiraWidget::OnSelectIssueClicked)
.IsEnabled_Lambda([&issue = SelectedIssue]() -> bool {return issue != nullptr; })
.ButtonStyle(FSubmitToolStyle::Get(), "PrimaryButton")
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(5)
.HAlign(HAlign_Right)
[
SNew(SButton)
.Text(FText::FromString("Close"))
.OnClicked(this, &SJiraWidget::OnCloseClicked)
]
];
}
FReply SJiraWidget::Login()
{
ModelInterface->SetLogin(UsernameField->GetText().ToString(), PasswordField->GetText().ToString());
JiraService.Pin()->FetchJiraTickets(true);
return FReply::Handled();
}
FReply SJiraWidget::OnListKeyDown(const FGeometry& MyGeometry, const FKeyEvent& InKeyEvent)
{
if (SelectedIssue != nullptr && InKeyEvent.GetKey() == EKeys::Enter)
{
this->ProcessIssueSelected(SelectedIssue);
return FReply::Handled();
}
return SCompoundWidget::OnPreviewKeyDown(MyGeometry, InKeyEvent);
}
void SJiraWidget::JiraIssuesAvailable(bool bValidResponse)
{
if(bValidResponse)
{
const TMap<FString, FJiraIssue>& Issues = this->JiraService.Pin()->GetIssues();
this->JiraIssues.Empty(Issues.Num());
for(TPair<FString, FJiraIssue> Issue : Issues)
{
this->JiraIssues.Add(MakeShared<FJiraIssue>(Issue.Value));
}
// Force update table
ApplyFilter(this->FilterText);
ChildSlot.AttachWidget(IssuesSection.ToSharedRef());
}
else
{
ChildSlot.AttachWidget(CredentialSection.ToSharedRef());
}
}
FReply SJiraWidget::OnSelectIssueClicked()
{
if (SelectedIssue.IsValid())
{
this->ProcessIssueSelected(SelectedIssue);
}
return FReply::Handled();
}
void SJiraWidget::OnJiraDoubleClicked(TSharedPtr<FJiraIssue> InIssue)
{
if (InIssue.IsValid())
{
this->ProcessIssueSelected(InIssue);
}
}
void SJiraWidget::ProcessIssueSelected(TSharedPtr<FJiraIssue> InIssue)
{
if (InIssue.IsValid())
{
TArray<FString> CurrentValues = TargetTag->GetValues();
if (!CurrentValues.ContainsByPredicate([InIssue](const FString& Value) { return Value.Equals(InIssue->Key, ESearchCase::IgnoreCase); }))
{
if(CurrentValues.Contains(TEXT("none")))
{
CurrentValues.Remove(TEXT("none"));
}
CurrentValues.Add(InIssue->Key);
}
else
{
CurrentValues.Remove(InIssue->Key);
}
ModelInterface->SetTagValues(*TargetTag, CurrentValues);
}
}
FReply SJiraWidget::OnCloseClicked()
{
this->ParentWindow->HideWindow();
return FReply::Handled();
}
class SJiraIssueNode : public SMultiColumnTableRow<TSharedRef<FJiraIssue>>
{
public:
SLATE_BEGIN_ARGS(SJiraIssueNode) {}
SLATE_ARGUMENT(const FTag*, TargetTag)
SLATE_ARGUMENT(SJiraWidget*, ParentWidget)
SLATE_END_ARGS()
void Construct(const FArguments& InArgs, const TSharedRef<STableViewBase>& InOwnerTable, TSharedPtr<FJiraIssue> InNode)
{
Node = InNode;
TargetTag = InArgs._TargetTag;
ParentWidget = InArgs._ParentWidget;
SMultiColumnTableRow<TSharedRef<FJiraIssue>>::Construct(
FSuperRowType::FArguments()
.Padding(1.0f),
InOwnerTable
);
}
virtual TSharedRef<SWidget> GenerateWidgetForColumn(const FName& InColumnName) override
{
TSharedRef<SBorder> box = SNew(SBorder).ToolTip(SNew(SToolTip).Text(FText::FromString(Node->Description)));
if (InColumnName == JiraIssuesColumns::Checked)
{
box->SetContent(SNew(SCheckBox)
.OnCheckStateChanged_Lambda([this](ECheckBoxState InCheckState)
{
ParentWidget->ProcessIssueSelected(Node);
})
.IsChecked_Lambda([this]
{
bool InTag = TargetTag->GetValues().ContainsByPredicate([this](const FString& Value) { return Value.Equals(Node->Key, ESearchCase::IgnoreCase); });
return InTag ? ECheckBoxState::Checked : ECheckBoxState::Unchecked;
}));
}
else if(InColumnName == JiraIssuesColumns::IssueType)
{
box->SetContent(SNew(STextBlock)
.Text(FText::FromString(Node->IssueType)));
}
else if(InColumnName == JiraIssuesColumns::Issue)
{
box->SetToolTip(SNew(SToolTip).Text(FText::FromString(TEXT("View in Jira"))));
box->SetContent(SNew(SHorizontalBox)
+SHorizontalBox::Slot()
.AutoWidth()
[
SNew(SHyperlink)
.Text(FText::FromString(Node->Key))
.OnNavigate_Lambda([&Link = Node->Link]() {FPlatformProcess::LaunchURL(*Link, NULL, NULL); })
.HighlightText(FText::FromString(Node->Link))
]);
}
else if(InColumnName == JiraIssuesColumns::Status)
{
box->SetContent(SNew(STextBlock).Text(FText::FromString(Node->Status)));
}
else if(InColumnName == JiraIssuesColumns::Summary)
{
box->SetContent(SNew(STextBlock).Text(FText::FromString(Node->Summary)));
}
return box;
}
TSharedPtr<FJiraIssue> Node;
const FTag* TargetTag;
SJiraWidget* ParentWidget;
};
TSharedRef<ITableRow> SJiraWidget::GenerateRow(TSharedPtr<FJiraIssue> InItem, const TSharedRef<STableViewBase>& OwnerTable)
{
return SNew(SJiraIssueNode, OwnerTable, InItem).TargetTag(TargetTag).ParentWidget(this);
}
void SJiraWidget::OnColumnSort(EColumnSortPriority::Type InSortPriority, const FName& InColumnId, EColumnSortMode::Type InSortDirection)
{
SortMode = InSortDirection;
SortByColumn = InColumnId;
if(InColumnId == JiraIssuesColumns::IssueType)
{
if(InSortDirection == EColumnSortMode::Ascending)
{
Algo::SortBy(JiraIssues, &FJiraIssue::IssueType);
}
else
{
Algo::Reverse(JiraIssues);
}
}
else if(InColumnId == JiraIssuesColumns::Issue)
{
if(InSortDirection == EColumnSortMode::Ascending)
{
Algo::SortBy(JiraIssues, &FJiraIssue::Key);
}
else
{
Algo::Reverse(JiraIssues);
}
}
else if(InColumnId == JiraIssuesColumns::Status)
{
if(InSortDirection == EColumnSortMode::Ascending)
{
Algo::SortBy(JiraIssues, &FJiraIssue::Status);
}
else
{
Algo::Reverse(JiraIssues);
}
}
else if(InColumnId == JiraIssuesColumns::Summary)
{
if(InSortDirection == EColumnSortMode::Ascending)
{
Algo::SortBy(JiraIssues, &FJiraIssue::Summary);
}
else
{
Algo::Reverse(JiraIssues);
}
}
this->ApplyFilter(this->FilterText);
}
EColumnSortMode::Type SJiraWidget::GetSortMode(const FName ColumnId) const
{
if(ColumnId == SortByColumn)
{
return SortMode;
}
return EColumnSortMode::None;
}
void SJiraWidget::ApplyFilter(const FString& InText)
{
FilterText = InText;
JiraIssuesFiltered = JiraIssues.FilterByPredicate([this](const TSharedPtr<FJiraIssue>& Issue) -> bool
{
if(!bIncludeClosedJira)
{
for(const TCHAR* status : FilteredStatus)
{
if(Issue->Status.Equals(status))
{
return false;
}
}
}
if(Issue->Status.Equals("Closed") && !bIncludeClosedJira)
{
return false;
}
return FilterText.IsEmpty() ? true : Issue->Summary.Contains(FilterText);
});
if(ListView != nullptr)
{
ListView->RequestListRefresh();
}
}