Skip to content

Commit

Permalink
work_items: add support for adding/removing tags
Browse files Browse the repository at this point in the history
Closes #41
  • Loading branch information
sstasi95 committed Oct 17, 2024
1 parent 6fe9dec commit 83fa569
Show file tree
Hide file tree
Showing 7 changed files with 292 additions and 0 deletions.
30 changes: 30 additions & 0 deletions lib/src/models/work_item_tags.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import 'dart:convert';

import 'package:http/http.dart';

class WorkItemTagsResponse {
WorkItemTagsResponse({
required this.tags,
});

factory WorkItemTagsResponse.fromResponse(Response res) =>
WorkItemTagsResponse.fromJson(json.decode(res.body) as Map<String, dynamic>);

factory WorkItemTagsResponse.fromJson(Map<String, dynamic> json) => WorkItemTagsResponse(
tags: List<WorkItemTag>.from(
(json['value'] as List<dynamic>).map((x) => WorkItemTag.fromJson(x as Map<String, dynamic>)),
),
);

final List<WorkItemTag> tags;
}

class WorkItemTag {
WorkItemTag({required this.name});

factory WorkItemTag.fromResponse(Response res) => WorkItemTag.fromJson(json.decode(res.body) as Map<String, dynamic>);

factory WorkItemTag.fromJson(Map<String, dynamic> json) => WorkItemTag(name: json['name'] as String);

final String name;
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import 'package:azure_devops/src/services/azure_api_service.dart';
import 'package:azure_devops/src/services/overlay_service.dart';
import 'package:azure_devops/src/services/rules_checker.dart';
import 'package:azure_devops/src/services/storage_service.dart';
import 'package:azure_devops/src/theme/dev_ops_icons_icons.dart';
import 'package:azure_devops/src/widgets/app_base_page.dart';
import 'package:azure_devops/src/widgets/app_page.dart';
import 'package:azure_devops/src/widgets/filter_menu.dart';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,3 +173,84 @@ class _DefaultFormField extends StatelessWidget {
);
}
}

class _AddTagBottomsheet extends StatelessWidget {
_AddTagBottomsheet({
required this.projectTags,
required this.workItemTags,
required this.addExistingTag,
required this.addNewTag,
});

final ValueNotifier<Set<String>?> projectTags;
final Set<String> workItemTags;
final void Function(String) addExistingTag;
final bool Function(String) addNewTag;

final newTagController = TextEditingController();

void _onSubmit() {
final tagToAdd = newTagController.text.trim();
final res = addNewTag(tagToAdd);

if (res) newTagController.clear();
}

@override
Widget build(BuildContext context) {
return Column(
children: [
DevOpsFormField(
label: 'New tag',
maxLines: 1,
onChanged: (s) => true,
controller: newTagController,
validator: (_) => null,
suffix: ValueListenableBuilder(
valueListenable: newTagController,
builder: (_, tagCtrl, ___) => IconButton(
onPressed: tagCtrl.text.isEmpty ? null : _onSubmit,
color: Colors.blue,
disabledColor: Colors.transparent,
icon: const Icon(Icons.done),
),
),
onFieldSubmitted: _onSubmit,
),
const SizedBox(
height: 20,
),
ValueListenableBuilder(
valueListenable: projectTags,
builder: (_, tags, __) => switch (tags) {
null => const CircularProgressIndicator(),
[] => const Text('No tags available'),
_ => Expanded(
child: ListView.separated(
itemCount: tags.length,
separatorBuilder: (_, __) => const Divider(),
itemBuilder: (context, index) {
final t = tags.toList().sortedBy((t) => t.toLowerCase())[index];
return GestureDetector(
onTap: () => addExistingTag(t),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(t),
if (workItemTags.contains(t)) const Icon(DevOpsIcons.success),
],
),
),
);
},
),
),
},
),
],
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ class _CreateOrEditWorkItemController with FilterMixin, AppLogger {
AreaOrIteration? newWorkItemArea;
AreaOrIteration? newWorkItemIteration;

/// Tags available for the project
final _projectTags = ValueNotifier<Set<String>?>(null);

/// Tags added to this work item
Set<String> _newWorkItemTags = {};

bool get isEditing => args.id != null;
WorkItem? editingWorkItem;

Expand Down Expand Up @@ -119,6 +125,10 @@ class _CreateOrEditWorkItemController with FilterMixin, AppLogger {

newWorkItemArea = AreaOrIteration.onlyPath(path: fields.systemAreaPath);
newWorkItemIteration = AreaOrIteration.onlyPath(path: fields.systemIterationPath);

if (fields.systemTags != null) {
_newWorkItemTags = fields.systemTags!.split(';').map((t) => t.trim()).toSet();
}
}

Future<void> setType(WorkItemType type) async {
Expand Down Expand Up @@ -345,6 +355,7 @@ class _CreateOrEditWorkItemController with FilterMixin, AppLogger {
state: newWorkItemState?.name,
area: newWorkItemArea,
iteration: newWorkItemIteration,
tags: _newWorkItemTags.toList(),
formFields: {for (final field in formFields.entries) field.key: field.value.text},
);
return res;
Expand All @@ -359,6 +370,7 @@ class _CreateOrEditWorkItemController with FilterMixin, AppLogger {
description: newWorkItemDescription,
area: newWorkItemArea,
iteration: newWorkItemIteration,
tags: _newWorkItemTags.toList(),
formFields: {for (final field in formFields.entries) field.key: field.value.text},
);
return res;
Expand Down Expand Up @@ -608,6 +620,67 @@ class _CreateOrEditWorkItemController with FilterMixin, AppLogger {
final users = getAssignees();
return users.where((u) => u.displayName != null && u.displayName!.toLowerCase().contains(loweredQuery)).toList();
}

void addTag() {
// ignore: unawaited_futures, reason: to show a loader inside the bottomsheet while getting tags
_getProjectTags();

OverlayService.bottomsheet(
title: 'Add tags',
heightPercentage: .7,
isScrollControlled: true,
builder: (context) => _AddTagBottomsheet(
projectTags: _projectTags,
addExistingTag: _addExistingTag,
addNewTag: _addNewTag,
workItemTags: _newWorkItemTags,
),
);
}

Future<void> _getProjectTags() {
return apiService.getProjectTags(projectName: newWorkItemProject.name!).then((res) {
if (res.isError) {
return OverlayService.snackbar('Could not get tags for project ${newWorkItemProject.name}', isError: true);
}

_projectTags.value = {
if (_projectTags.value != null) ..._projectTags.value!,
...res.data!.map((t) => t.name).toSet(),
};
});
}

void _addExistingTag(String t) {
if (_newWorkItemTags.contains(t)) {
_newWorkItemTags.remove(t);
} else {
_newWorkItemTags.add(t);
}

_projectTags.value = {..._projectTags.value!};
_setHasChanged();
}

bool _addNewTag(String tagToAdd) {
if (tagToAdd.isEmpty) return false;

if (_projectTags.value!.contains(tagToAdd)) {
_addExistingTag(tagToAdd);
} else {
_newWorkItemTags.add(tagToAdd);
_projectTags.value = {..._projectTags.value!, tagToAdd};
}

_setHasChanged();

return true;
}

void removeTag(String tag) {
_newWorkItemTags.remove(tag);
_setHasChanged();
}
}

extension on WorkItemField {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,79 @@ class _CreateOrEditWorkItemScreen extends StatelessWidget {
const SizedBox(
height: 20,
),
if (ctrl.newWorkItemProject != ctrl.projectAll || ctrl.isEditing)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
'Tags:',
style: style,
),
),
const SizedBox(
width: 10,
),
Flexible(
child: Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (final tag in ctrl._newWorkItemTags.sortedBy((t) => t.toLowerCase()))
Container(
height: 24,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: context.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(100),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
tag,
style: context.textTheme.labelSmall!.copyWith(height: 1),
),
const SizedBox(
width: 8,
),
GestureDetector(
onTap: () => ctrl.removeTag(tag),
child: Icon(
DevOpsIcons.failedsolid,
size: 14,
color: context.colorScheme.onSecondary,
),
),
],
),
),
const SizedBox(
width: 4,
),
CircleAvatar(
backgroundColor: context.colorScheme.tertiaryContainer,
radius: 12,
child: IconButton(
onPressed: ctrl.addTag,
padding: EdgeInsets.zero,
icon: Icon(
DevOpsIcons.plus,
size: 18,
color: context.themeExtension.onBackground,
),
),
),
],
),
),
],
),
const SizedBox(
height: 20,
),
DevOpsFormField(
initialValue: ctrl.newWorkItemTitle,
onChanged: ctrl.onTitleChanged,
Expand Down
Loading

0 comments on commit 83fa569

Please sign in to comment.