Skip to content
代码片段 群组 项目
提交 809df040 编辑于 作者: Neil Wang's avatar Neil Wang
浏览文件

feat: #649 用户查看子组迭代的议题列表时按照史诗分组

上级 e0cb305c
No related branches found
No related tags found
无相关合并请求
显示
290 个添加105 个删除
......@@ -79,8 +79,8 @@ class Issue {
'',
issueJson['confidential'] ?? false,
issueJson['state'] ?? '',
null,
null,
issueJson['epic']?['title'] == null ? null : Epic(issueJson['epic']?['title']),
issueJson['weight'],
((issueJson['assignees']?['nodes'] ?? []) as List).map((e) => Assignee.fromGraphQLJson(e)).toList(),
);
}
......
import 'package:jihu_gitlab_app/core/domain/issue.dart';
import 'package:jihu_gitlab_app/core/domain/project.dart';
import 'package:jihu_gitlab_app/core/net/api.dart';
import 'package:jihu_gitlab_app/core/net/http_client.dart';
import 'package:jihu_gitlab_app/modules/issues/list/group_projects_gq_request_body.dart';
class GroupIssuesFetcher {
final List<Project> _groupProjects = [];
final String? destinationHost;
final String fullPath;
GroupIssuesFetcher(this.destinationHost, this.fullPath);
Future<List<Issue>> fetchIssues(Map Function() bodyProvider) async {
var res = await HttpClient.instance().post(destinationHost == null ? Api.graphql() : destinationHost! + Api.graphql(), bodyProvider());
var issues = res.body()?['data']?['group']?['issues'];
if (issues == null) return [];
await _syncGroupProjects(issues['nodes']);
return issues['nodes'].map<Issue>((e) => Issue.fromGroupGraphQLJson(e, _groupProjects.firstWhere((element) => element.id == e['projectId']))).toList();
}
Future<void> _syncGroupProjects(issueList) async {
var nextRequestProjectIds = issueList
.where((element) {
int pjId = element['projectId'] as int;
var index = _groupProjects.indexWhere((element) => element.id == pjId);
return index < 0;
})
.map((e) => e['projectId'] as int)
.toSet()
.toList();
if (nextRequestProjectIds.isEmpty) return;
var projectIds = nextRequestProjectIds.map((e) => 'gid://gitlab/Project/$e').toList();
List<Project> projects = await _getGroupProjects(projectIds);
if (projects.isNotEmpty) _groupProjects.addAll(projects);
}
Future<List<Project>> _getGroupProjects(List<dynamic> projectIds) async {
if (projectIds.isEmpty) return [];
var projectsResp = await HttpClient.instance().post(Api.graphql(), getGroupProjectsGraphQLRequestBody(fullPath, projectIds));
var projectsData = projectsResp.body()['data']?['group'];
if (projectsData == null) return [];
var projectList = projectsData['projects']?['nodes'] ?? [];
return List<Project>.from(projectList.map((e) => Project.fromGraphQLJson(e)).toList());
}
}
......@@ -2,15 +2,15 @@ import 'dart:core';
import 'package:flutter/cupertino.dart';
import 'package:jihu_gitlab_app/core/domain/issue.dart';
import 'package:jihu_gitlab_app/core/domain/project.dart';
import 'package:jihu_gitlab_app/core/load_state.dart';
import 'package:jihu_gitlab_app/core/log_helper.dart';
import 'package:jihu_gitlab_app/core/net/api.dart';
import 'package:jihu_gitlab_app/core/net/http_client.dart';
import 'package:jihu_gitlab_app/l10n/jihu_localizations.dart';
import 'package:jihu_gitlab_app/modules/issues/list/group_issues_fetcher.dart';
import 'package:jihu_gitlab_app/modules/issues/list/project_issues_fetcher.dart';
import 'group_issues_gq_request_body.dart';
import 'group_projects_gq_request_body.dart';
import 'project_issues_gq_request_body.dart';
enum IssueMenuStateOptions {
......@@ -38,7 +38,6 @@ class IssuesModel {
static const int _size = 20;
LoadState _loadState = LoadState.loadingState;
List<Issue> _issues = [];
final List<Project> _groupProjects = [];
IssueMenuStateOptions _state = IssueMenuStateOptions.open;
String? _afterCursor;
String? destinationHost;
......@@ -48,6 +47,8 @@ class IssuesModel {
late bool _isProject;
String? _fullPath;
String? _labelName;
late final GroupIssuesFetcher _groupIssuesFetcher;
late final ProjectIssuesFetcher _projectIssuesFetcher;
void init({required bool isProject, int? projectId, String? fullPath, String? labelName}) {
_isProject = isProject;
......@@ -57,6 +58,8 @@ class IssuesModel {
assert(_projectId != null);
}
_fullPath = fullPath;
_groupIssuesFetcher = GroupIssuesFetcher(destinationHost, fullPath ?? '');
_projectIssuesFetcher = ProjectIssuesFetcher();
}
Future<bool> refresh(String text, Function setState) async {
......@@ -98,56 +101,12 @@ class IssuesModel {
}
Future<List<Issue>> _getProjectIssues(String text, {int? size}) async {
var resp = destinationHost == null
? await HttpClient.instance()
.post(Api.graphql(), getProjectIssuesGraphQLRequestBody(_fullPath ?? '', text, size ?? _size, afterCursor: _afterCursor, labelName: _labelName, state: _state.name))
: await HttpClient.instance().post(
'${destinationHost!}${Api.graphql()}', getProjectIssuesGraphQLRequestBody(_fullPath ?? '', text, size ?? _size, afterCursor: _afterCursor, labelName: _labelName, state: _state.name));
var project = resp.body()['data']['project'];
if (project == null) return [];
var issuesData = project['issues'];
_hasNextPage = issuesData['pageInfo']['hasNextPage'];
_afterCursor = issuesData['pageInfo']['endCursor'];
return issuesData['nodes'].map<Issue>((e) => Issue.fromProjectGraphQLJson(e, project)).toList();
return _projectIssuesFetcher.fetchIssues(
destinationHost, () => getProjectIssuesGraphQLRequestBody(_fullPath ?? '', text, size ?? _size, afterCursor: _afterCursor, labelName: _labelName, state: _state.name));
}
Future<List<Issue>> _getGroupIssues(String text, int? size) async {
var issuesResp =
await HttpClient.instance().post(Api.graphql(), getGroupIssuesGraphQLRequestBody(_fullPath ?? '', text, size ?? _size, afterCursor: _afterCursor, labelName: _labelName, state: _state.name));
var issuesData = issuesResp.body()['data']?['group'];
if (issuesData == null) return [];
var issuesContent = issuesData['issues'] ?? {};
var issueList = issuesContent['nodes'] ?? [];
await _syncGroupProjects(issueList);
if (_groupProjects.isEmpty) return [];
_hasNextPage = issuesContent['pageInfo']?['hasNextPage'] ?? false;
_afterCursor = issuesContent['pageInfo']?['endCursor'];
return issueList.map<Issue>((e) => Issue.fromGroupGraphQLJson(e, _groupProjects.firstWhere((element) => element.id == e['projectId']))).toList();
}
Future<void> _syncGroupProjects(issueList) async {
var nextRequestProjectIds = issueList
.where((element) {
int pjId = element['projectId'] as int;
var index = _groupProjects.indexWhere((element) => element.id == pjId);
return index < 0;
})
.map((e) => e['projectId'] as int)
.toSet()
.toList();
if (nextRequestProjectIds.isEmpty) return;
var projectIds = nextRequestProjectIds.map((e) => 'gid://gitlab/Project/$e').toList();
List<Project> projects = await _getGroupProjects(projectIds);
if (projects.isNotEmpty) _groupProjects.addAll(projects);
}
Future<List<Project>> _getGroupProjects(List<dynamic> projectIds) async {
if (projectIds.isEmpty) return [];
var projectsResp = await HttpClient.instance().post(Api.graphql(), getGroupProjectsGraphQLRequestBody(_fullPath ?? '', projectIds));
var projectsData = projectsResp.body()['data']?['group'];
if (projectsData == null) return [];
var projectList = projectsData['projects']?['nodes'] ?? [];
return List<Project>.from(projectList.map((e) => Project.fromGraphQLJson(e)).toList());
return _groupIssuesFetcher.fetchIssues(() => getGroupIssuesGraphQLRequestBody(_fullPath ?? '', text, size ?? _size, afterCursor: _afterCursor, labelName: _labelName, state: _state.name));
}
void loadingPage() {
......
......@@ -114,22 +114,16 @@ class _IssuesViewState extends State<IssuesView> {
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Expanded(
child: Row(
children: [
SvgPicture.asset(
widget.displayOptions.displayStateIcon
? (widget.issuesFetcher()[index].state == "closed" ? "assets/images/issue_list_close.svg" : "assets/images/issue_list_open.svg")
: "assets/images/issue.svg",
height: 16,
width: 16,
),
SizedBox(width: 50, child: Text(" ${widget.issuesFetcher()[index].iid}", style: const TextStyle(fontSize: 12, color: Color(0xFF66696D)))),
if (widget.displayOptions.displayWeight) ..._weightInfo(widget.issuesFetcher()[index]),
if (!widget.isProject && widget.displayOptions.displayProject) ..._projectInfo(widget.issuesFetcher()[index].project.name)
],
),
SvgPicture.asset(
widget.displayOptions.displayStateIcon
? (widget.issuesFetcher()[index].state == "closed" ? "assets/images/issue_list_close.svg" : "assets/images/issue_list_open.svg")
: "assets/images/issue.svg",
height: 16,
width: 16,
),
SizedBox(width: 50, child: Text(" ${widget.issuesFetcher()[index].iid}", style: const TextStyle(fontSize: 12, color: Color(0xFF66696D)))),
if (widget.displayOptions.displayWeight) ..._weightInfo(widget.issuesFetcher()[index]),
if (!widget.isProject && widget.displayOptions.displayProject) ..._projectInfo(widget.issuesFetcher()[index].project.name),
assigneesAvatars(issue),
],
)
......@@ -175,7 +169,7 @@ class _IssuesViewState extends State<IssuesView> {
if (issue.weight == null) return [];
return [
SvgPicture.asset('assets/images/weight.svg', height: 16, width: 16, colorFilter: const ColorFilter.mode(Color(0xFFACACAC), BlendMode.srcIn)),
SizedBox(width: 50, child: Text(" ${issue.weight}", style: const TextStyle(fontSize: 12, color: Color(0xFF66696D))))
SizedBox(width: 28, child: Text(" ${issue.weight}", style: const TextStyle(fontSize: 12, color: Color(0xFF66696D))))
];
}
......@@ -186,7 +180,9 @@ class _IssuesViewState extends State<IssuesView> {
height: 16,
width: 16,
),
Text(" $projectName", style: const TextStyle(fontSize: 12, color: Color(0xFF66696D)))
Expanded(
child: Text(" $projectName", style: const TextStyle(fontSize: 12, color: Color(0xFF66696D)), overflow: TextOverflow.ellipsis),
)
];
}
}
import 'package:jihu_gitlab_app/core/domain/issue.dart';
import 'package:jihu_gitlab_app/core/net/api.dart';
import 'package:jihu_gitlab_app/core/net/http_client.dart';
class ProjectIssuesFetcher {
Future<List<Issue>> fetchIssues(String? destinationHost, Map Function() bodyProvider) async {
var res = await HttpClient.instance().post(destinationHost == null ? Api.graphql() : destinationHost + Api.graphql(), bodyProvider());
var issues = res.body()?['data']?['project']?['issues'];
var project = res.body()?['data']?['project'];
if (issues == null) return [];
return issues['nodes'].map<Issue>((e) => Issue.fromProjectGraphQLJson(e, project)).toList();
}
}
......@@ -14,6 +14,7 @@ import 'package:jihu_gitlab_app/l10n/jihu_localizations.dart';
import 'package:jihu_gitlab_app/modules/issues/details/issue_details_page.dart';
import 'package:jihu_gitlab_app/modules/issues/list/issues_view.dart';
import 'package:jihu_gitlab_app/modules/iteration/details/iteration_model.dart';
import 'package:jihu_gitlab_app/modules/iteration/list/iteration_list_view.dart';
import 'package:provider/provider.dart';
class IterationIssuesPage extends StatefulWidget {
......@@ -26,7 +27,7 @@ class IterationIssuesPage extends StatefulWidget {
}
class _IterationIssuesPageState extends State<IterationIssuesPage> {
final IterationIssuesModel _model = IterationIssuesModel();
late final IterationIssuesModel _model = (widget.arguments['source'] as IterationListViewSource).iterationIssuesModel;
@override
void initState() {
......@@ -170,6 +171,7 @@ class _IterationIssuesPageState extends State<IterationIssuesPage> {
isEmpty: () => _model.loadState == LoadState.successState && _model.isEmpty,
onRefresh: _modelRefresh(),
scrollable: false,
destinationHost: _model.destinationHost,
onListItemTap: (index) {
final issue = issues[index];
final params = <String, dynamic>{
......@@ -187,7 +189,11 @@ class _IterationIssuesPageState extends State<IterationIssuesPage> {
},
selectedIndex: -1,
selectedColor: Colors.transparent,
displayOptions: IssuesViewDisplayOptions(false, true, true),
displayOptions: IssuesViewDisplayOptions(
(widget.arguments['source'] as IterationListViewSource) == IterationListViewSource.group,
true,
true,
),
);
}
......
import 'package:jihu_gitlab_app/core/domain/issue.dart';
import 'package:jihu_gitlab_app/core/load_state.dart';
import 'package:jihu_gitlab_app/core/log_helper.dart';
import 'package:jihu_gitlab_app/core/net/api.dart';
import 'package:jihu_gitlab_app/core/net/http_client.dart';
import 'package:jihu_gitlab_app/modules/issues/list/project_issues_fetcher.dart';
class IterationIssuesModel {
LoadState loadState = LoadState.loadingState;
List<Issue> issues = [];
Map<String, List<Issue>> epics = {};
List<Issue> unknownIssues = [];
late String _groupFullPath;
late String _id;
late String groupFullPath;
late String id;
String? destinationHost;
late ProjectIssuesFetcher _projectIssuesFetcher;
void init(Map arguments) {
_groupFullPath = arguments['fullPath'];
_id = arguments['iterationId'];
groupFullPath = arguments['fullPath'];
id = arguments['iterationId'];
destinationHost = arguments['destinationHost'];
_projectIssuesFetcher = ProjectIssuesFetcher();
}
Future<bool> refresh() async {
......@@ -24,7 +24,7 @@ class IterationIssuesModel {
epics = {};
unknownIssues = [];
try {
var issues = await _getIterationIssues();
var issues = await getIterationIssues();
buildEpicsAndUnknownIssues(issues);
loadState = LoadState.successState;
return Future.value(true);
......@@ -51,12 +51,8 @@ class IterationIssuesModel {
}
}
Future<List<Issue>> _getIterationIssues() async {
var res = await HttpClient.instance().post(destinationHost == null ? Api.graphql() : destinationHost! + Api.graphql(), iterationIssuesRequestBody("", _groupFullPath, _id, 100));
var issues = res.body()?['data']?['project']?['issues'];
var project = res.body()?['data']?['project'];
if (issues == null) return [];
return issues['nodes'].map<Issue>((e) => Issue.fromProjectGraphQLJson(e, project)).toList();
Future<List<Issue>> getIterationIssues() async {
return _projectIssuesFetcher.fetchIssues(destinationHost, () => iterationIssuesRequestBody("", groupFullPath, id, 100));
}
}
......
import 'package:jihu_gitlab_app/core/domain/issue.dart';
import 'package:jihu_gitlab_app/modules/issues/list/group_issues_fetcher.dart';
import 'package:jihu_gitlab_app/modules/iteration/details/iteration_model.dart';
class SubgroupIterationIssuesModel extends IterationIssuesModel {
late final GroupIssuesFetcher groupIssuesFetcher;
@override
void init(Map arguments) {
super.init(arguments);
groupIssuesFetcher = GroupIssuesFetcher(destinationHost, groupFullPath);
}
@override
Future<List<Issue>> getIterationIssues() async {
return groupIssuesFetcher.fetchIssues(() => subgroupIterationIssuesRequestBody("", groupFullPath, id, 100));
}
}
Map<String, dynamic> subgroupIterationIssuesRequestBody(String afterCursor, String fullPath, String iterationId, int size) => {
"query": """
query iterationIssues(\$fullPath: ID!, \$id: ID!, \$afterCursor: String = "", \$firstPageSize: Int) {
group(fullPath: \$fullPath) {
id
path
fullPath
name
issues(
iterationId: [\$id]
sort: RELATIVE_POSITION_ASC,
after: \$afterCursor
first: \$firstPageSize
) {
...IterationIssues
}
}
}
fragment IterationIssues on IssueConnection {
count
pageInfo {
...PageInfo
}
nodes {
id
iid
title
webUrl
state
projectId
epic {
title
}
weight
author {
id
name
username
avatarUrl
}
assignees {
nodes {
id
name
username
avatarUrl
}
}
}
}
fragment PageInfo on PageInfo {
hasNextPage
endCursor
}
""",
"variables": {"afterCursor": afterCursor, "fullPath": fullPath, "id": iterationId, "firstPageSize": size}
};
import 'package:flutter/material.dart';
import 'package:jihu_gitlab_app/core/widgets/toast.dart';
import 'package:jihu_gitlab_app/l10n/jihu_localizations.dart';
import 'package:jihu_gitlab_app/modules/iteration/details/iteration_model.dart';
import 'package:jihu_gitlab_app/modules/iteration/details/iteration_page.dart';
import 'package:jihu_gitlab_app/modules/iteration/details/subgroup_iteration_issues_model.dart';
import '../models/cadence_iteration.dart';
import '../widgets/iteration_tag.dart';
enum IterationListViewSource { project, group }
enum IterationListViewSource {
project,
group;
IterationIssuesModel get iterationIssuesModel {
if (this == project) return IterationIssuesModel();
return SubgroupIterationIssuesModel();
}
}
class IterationListView extends StatefulWidget {
final List<CadenceIteration> iterations;
......@@ -29,17 +37,14 @@ class _IterationListViewState extends State<IterationListView> {
itemCount: widget.iterations.length,
itemBuilder: (context, index) => InkWell(
onTap: () {
if (widget.source == IterationListViewSource.group) {
Toast.show(JiHuLocalizations.dictionary().projectsIterationItemDeveloping);
} else {
Navigator.of(context).pushNamed(IterationPage.routeName, arguments: {
'fullPath': widget.fullPath,
'iterationId': widget.iterations[index].id,
'iteration': widget.iterations[index],
'title': widget.title,
'destinationHost': widget.destinationHost,
});
}
Navigator.of(context).pushNamed(IterationPage.routeName, arguments: {
'fullPath': widget.fullPath,
'iterationId': widget.iterations[index].id,
'iteration': widget.iterations[index],
'title': widget.title,
'destinationHost': widget.destinationHost,
'source': widget.source,
});
},
child: Container(
padding: const EdgeInsets.all(12),
......
......@@ -52,5 +52,5 @@ Future<void> issueDetailsPageNavigationTest(WidgetTester tester, String targetSt
}
expect(find.byType(IssueDetailsPage), findsNothing);
expect(find.text(targetString), findsOneWidget);
verify(client.post<dynamic>("/api/graphql", getGroupIssuesGraphQLRequestBody('highsof-t', '', 20))).called(1);
verify(client.post<dynamic>("/api/graphql", getGroupIssuesGraphQLRequestBody('highsof-t', '', 20))).called(2);
}
import 'package:flutter_test/flutter_test.dart';
import 'package:jihu_gitlab_app/modules/iteration/details/subgroup_iteration_issues_model.dart';
void main() {
test('Should get subgroupIterationIssuesRequestBody as expect', () {
expect(subgroupIterationIssuesRequestBody('after', 'full', '1', 10), {
"query": """
query iterationIssues(\$fullPath: ID!, \$id: ID!, \$afterCursor: String = "", \$firstPageSize: Int) {
group(fullPath: \$fullPath) {
id
path
fullPath
name
issues(
iterationId: [\$id]
sort: RELATIVE_POSITION_ASC,
after: \$afterCursor
first: \$firstPageSize
) {
...IterationIssues
}
}
}
fragment IterationIssues on IssueConnection {
count
pageInfo {
...PageInfo
}
nodes {
id
iid
title
webUrl
state
projectId
epic {
title
}
weight
author {
id
name
username
avatarUrl
}
assignees {
nodes {
id
name
username
avatarUrl
}
}
}
}
fragment PageInfo on PageInfo {
hasNextPage
endCursor
}
""",
"variables": {"afterCursor": 'after', "fullPath": 'full', "id": '1', "firstPageSize": 10}
});
});
}
......@@ -5,17 +5,22 @@ import 'package:jihu_gitlab_app/core/net/http_client.dart';
import 'package:jihu_gitlab_app/core/net/response.dart';
import 'package:jihu_gitlab_app/core/widgets/tips_view.dart';
import 'package:jihu_gitlab_app/core/widgets/warning_view.dart';
import 'package:jihu_gitlab_app/modules/issues/list/group_projects_gq_request_body.dart';
import 'package:jihu_gitlab_app/modules/iteration/details/subgroup_iteration_issues_model.dart';
import 'package:jihu_gitlab_app/modules/projects/group_details/iterations/subgroup_cadence_iteration_model.dart';
import 'package:jihu_gitlab_app/modules/projects/group_details/iterations/subgroup_iterations_page.dart';
import 'package:jihu_gitlab_app/routers.dart';
import 'package:mockito/mockito.dart';
import 'package:provider/provider.dart';
import '../../../../core/net/http_request_test.mocks.dart';
import '../../../../mocker/tester.dart';
import '../../../../test_data/issue.dart';
final client = MockHttpClient();
void main() {
setUp(() {
setUp(() async {
HttpClient.injectInstanceForTesting(client);
});
......@@ -116,6 +121,7 @@ void main() {
});
testWidgets('Should display page with List View', (WidgetTester tester) async {
ConnectionProvider().reset(Tester.jihuLabUser());
var cadencesRequestParams = [
{
"operationName": "groupIterationCadences",
......@@ -145,17 +151,31 @@ void main() {
when(client.post<List<dynamic>>('/api/graphql', cadencesRequestParams)).thenAnswer((_) => Future(() => Response.of<List<dynamic>>(cadencesRequestResponseData)));
when(client.post('/api/graphql', subgroupCadenceIterationGraphql("ultimate-plan/jihu-gitlab-app", "gid://gitlab/Iterations::Cadence/316", 20, "")))
.thenAnswer((_) => Future(() => Response.of(iterationsRequestResponseData)));
when(client.post('/api/graphql', subgroupIterationIssuesRequestBody('', "ultimate-plan/jihu-gitlab-app", 'gid://gitlab/Iteration/1608', 100)))
.thenAnswer((_) => Future(() => Response.of<dynamic>(groupIssuesGraphQLResponseData)));
when(client.post<dynamic>("/api/graphql", getGroupProjectsGraphQLRequestBody('ultimate-plan/jihu-gitlab-app', ['gid://gitlab/Project/59893', 'gid://gitlab/Project/72936'])))
.thenAnswer((_) => Future(() => Response.of(groupProjectsGraphQLResponseData)));
await tester.pumpWidget(const MaterialApp(
home: Scaffold(
body: SubgroupIterationsPage(fullPath: 'ultimate-plan/jihu-gitlab-app'),
await tester.pumpWidget(MultiProvider(
providers: [ChangeNotifierProvider(create: (context) => ConnectionProvider())],
child: MaterialApp(
onGenerateRoute: onGenerateRoute,
home: const Scaffold(
body: SubgroupIterationsPage(fullPath: 'ultimate-plan/jihu-gitlab-app'),
),
),
));
for (int i = 0; i < 5; i++) {
await tester.pump(const Duration(seconds: 1));
}
expect(find.byType(ListView), findsOneWidget);
expect(find.text("单周迭代"), findsOneWidget);
expect(find.textContaining("Apr 03, 2023"), findsOneWidget);
await tester.tap(find.textContaining("Apr 03, 2023"));
await tester.pumpAndSettle();
expect(find.text("No Epic"), findsOneWidget);
expect(find.text('Feature: 用户可以为评论点赞/反对/取消'), findsOneWidget);
ConnectionProvider().fullReset();
});
testWidgets('Should display page with Authorized Denied View', (WidgetTester tester) async {
......
0% 加载中 .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册