Merge branch 'feature/issue-share' into 'next'
from pull-request 3 * feature/issue-share: my-issues: Show shared issue list issue: Issue sharing feature - support notification issue: Issue sharing feature - view and message issue: Issue sharing feature - Build api issue: Issue sharing feature - Add model Reviewed-by: Mijeong Park
@3cc43f2193f605974bcf6816caa0e41e3509c76f
--- app/assets/stylesheets/less/_common.less
+++ app/assets/stylesheets/less/_common.less
... | ... | @@ -219,6 +219,7 @@ |
219 | 219 |
.mt4 { margin-top:4px; } |
220 | 220 |
.mr3 { margin-right:3px; } |
221 | 221 |
.pb4 { padding-bottom: 4px} |
222 |
+.pl0 { padding-left: 0} |
|
222 | 223 |
|
223 | 224 |
.margin-top-20 { margin-top:20px; } |
224 | 225 |
.margin-left-20 { margin-left: 20px; } |
... | ... | @@ -281,3 +282,21 @@ |
281 | 282 |
.va-text-top { |
282 | 283 |
vertical-align: text-top !important; |
283 | 284 |
} |
285 |
+ |
|
286 |
+.width100p { |
|
287 |
+ width: 100% |
|
288 |
+} |
|
289 |
+ |
|
290 |
+.text-ellipsis { |
|
291 |
+ white-space: nowrap; |
|
292 |
+ overflow: hidden; |
|
293 |
+ text-overflow: ellipsis; |
|
294 |
+} |
|
295 |
+ |
|
296 |
+.z-index-1 { |
|
297 |
+ z-index: 1 !important; |
|
298 |
+} |
|
299 |
+ |
|
300 |
+.hideFromDisplayOnly { |
|
301 |
+ display: none; |
|
302 |
+} |
--- app/assets/stylesheets/less/_override.less
+++ app/assets/stylesheets/less/_override.less
... | ... | @@ -182,6 +182,23 @@ |
182 | 182 |
.box-shadow(none); |
183 | 183 |
} |
184 | 184 |
|
185 |
+.sharer-list { |
|
186 |
+ .select2-container{ |
|
187 |
+ border: none; |
|
188 |
+ box-shadow: none; |
|
189 |
+ border-radius: 0 !important; |
|
190 |
+ border-bottom: 1px solid rgba(0, 0, 0, 0.15); |
|
191 |
+ } |
|
192 |
+ .select2-container-multi { |
|
193 |
+ .select2-choices { |
|
194 |
+ .select2-search-choice { |
|
195 |
+ background-color: #ececec; |
|
196 |
+ border: 1px solid #dfdfdf; |
|
197 |
+ } |
|
198 |
+ } |
|
199 |
+ } |
|
200 |
+} |
|
201 |
+ |
|
185 | 202 |
.select2-dropdown-open { |
186 | 203 |
.select2-choice { |
187 | 204 |
border-bottom-color: transparent; |
--- app/assets/stylesheets/less/_page.less
+++ app/assets/stylesheets/less/_page.less
... | ... | @@ -3066,6 +3066,14 @@ |
3066 | 3066 |
&.rejected {background: #F39C12;} |
3067 | 3067 |
&.conflict {background: #C0392B;} |
3068 | 3068 |
&.resolved {background: #468847;} |
3069 |
+ &.sharer-added { |
|
3070 |
+ background-color: #B2EBF2; |
|
3071 |
+ color: #0097A7; |
|
3072 |
+ } |
|
3073 |
+ &.sharer-deleted { |
|
3074 |
+ background-color: #FFCCBC; |
|
3075 |
+ color: #F4511E; |
|
3076 |
+ } |
|
3069 | 3077 |
} |
3070 | 3078 |
|
3071 | 3079 |
em { font-size:12px; color:#7F8C8D; margin-right:5px;} |
... | ... | @@ -3745,8 +3753,15 @@ |
3745 | 3753 |
.comments-count-color { |
3746 | 3754 |
color: @darkmagenta; |
3747 | 3755 |
} |
3756 |
+ .sharer-color { |
|
3757 |
+ color: @green; |
|
3758 |
+ } |
|
3748 | 3759 |
|
3749 | 3760 |
a:nth-child(2) { |
3761 |
+ margin-left: -5px; |
|
3762 |
+ } |
|
3763 |
+ |
|
3764 |
+ a:nth-child(3) { |
|
3750 | 3765 |
margin-left: -5px; |
3751 | 3766 |
} |
3752 | 3767 |
|
... | ... | @@ -7070,3 +7085,19 @@ |
7070 | 7085 |
border-radius: 4px; |
7071 | 7086 |
} |
7072 | 7087 |
} |
7088 |
+ |
|
7089 |
+.sharer-list { |
|
7090 |
+ margin-top: 40px; |
|
7091 |
+ padding: 10px; |
|
7092 |
+ |
|
7093 |
+ .issue-share-title { |
|
7094 |
+ font-size: 16px; |
|
7095 |
+ } |
|
7096 |
+ .sharer-item{ |
|
7097 |
+ display: inline-block; |
|
7098 |
+ background-color: #ececec; |
|
7099 |
+ border: 1px solid #dfdfdf; |
|
7100 |
+ border-radius: 3px; |
|
7101 |
+ padding: 1px 8px; |
|
7102 |
+ } |
|
7103 |
+} |
--- app/controllers/IssueApp.java
+++ app/controllers/IssueApp.java
... | ... | @@ -108,7 +108,7 @@ |
108 | 108 |
|
109 | 109 |
private static boolean hasNotConditions(models.support.SearchCondition searchCondition) { |
110 | 110 |
return searchCondition.assigneeId == null && searchCondition.authorId == null && searchCondition.mentionId == null |
111 |
- && searchCondition.commenterId == null; |
|
111 |
+ && searchCondition.commenterId == null && searchCondition.sharerId == null; |
|
112 | 112 |
} |
113 | 113 |
|
114 | 114 |
@Transactional |
--- app/controllers/api/IssueApi.java
+++ app/controllers/api/IssueApi.java
... | ... | @@ -39,6 +39,7 @@ |
39 | 39 |
import java.util.*; |
40 | 40 |
|
41 | 41 |
import static controllers.UserApp.MAX_FETCH_USERS; |
42 |
+import static controllers.UserApp.currentUser; |
|
42 | 43 |
import static controllers.api.UserApi.createUserNode; |
43 | 44 |
import static play.libs.Json.toJson; |
44 | 45 |
|
... | ... | @@ -385,7 +386,7 @@ |
385 | 386 |
} |
386 | 387 |
} |
387 | 388 |
|
388 |
- private static void addUserToUsers(User user, List<ObjectNode> users) { |
|
389 |
+ static void addUserToUsers(User user, List<ObjectNode> users) { |
|
389 | 390 |
ObjectNode userNode = Json.newObject(); |
390 | 391 |
userNode.put("loginId", user.loginId); |
391 | 392 |
userNode.put("name", user.getDisplayName()); |
... | ... | @@ -535,4 +536,151 @@ |
535 | 536 |
return ok(node); |
536 | 537 |
}); |
537 | 538 |
} |
539 |
+ |
|
540 |
+ @AnonymousCheck |
|
541 |
+ public static Result findSharerByloginIds(String ownerName, String projectName, Long number, |
|
542 |
+ String commaSeperatedIds) { |
|
543 |
+ if (!request().accepts("application/json")) { |
|
544 |
+ return status(Http.Status.NOT_ACCEPTABLE); |
|
545 |
+ } |
|
546 |
+ Project project = Project.findByOwnerAndProjectName(ownerName, projectName); |
|
547 |
+ Issue issue = Issue.findByNumber(project, number); |
|
548 |
+ |
|
549 |
+ List<IssueSharer> list = getExpressionListByExtractingLoginIds(issue, commaSeperatedIds).findList(); |
|
550 |
+ sortListByAddedDate(list); |
|
551 |
+ |
|
552 |
+ List<ObjectNode> users = new ArrayList<>(); |
|
553 |
+ for (IssueSharer sharer :list) { |
|
554 |
+ addUserToUsers(sharer.user, users); |
|
555 |
+ } |
|
556 |
+ return ok(toJson(users)); |
|
557 |
+ } |
|
558 |
+ |
|
559 |
+ private static void sortListByAddedDate(List<IssueSharer> list) { |
|
560 |
+ list.sort(new Comparator<IssueSharer>() { |
|
561 |
+ @Override |
|
562 |
+ public int compare(IssueSharer o1, IssueSharer o2) { |
|
563 |
+ return o1.created.compareTo(o2.created); |
|
564 |
+ } |
|
565 |
+ }); |
|
566 |
+ } |
|
567 |
+ |
|
568 |
+ private static ExpressionList<IssueSharer> getExpressionListByExtractingLoginIds(Issue issue, String query) { |
|
569 |
+ String[] queryItems = query.split(","); |
|
570 |
+ ExpressionList<IssueSharer> el = IssueSharer.find |
|
571 |
+ .where() |
|
572 |
+ .in("loginId", Arrays.asList(queryItems)) |
|
573 |
+ .eq("issue.id", issue.id); |
|
574 |
+ return el; |
|
575 |
+ } |
|
576 |
+ |
|
577 |
+ @IsAllowed(Operation.READ) |
|
578 |
+ public static Result findSharableUsers(String ownerName, String projectName, Long number, String query) { |
|
579 |
+ if (!request().accepts("application/json")) { |
|
580 |
+ return status(Http.Status.NOT_ACCEPTABLE); |
|
581 |
+ } |
|
582 |
+ |
|
583 |
+ List<ObjectNode> users = new ArrayList<>(); |
|
584 |
+ |
|
585 |
+ ExpressionList<User> el = getUserExpressionList(query, request().getQueryString("type")); |
|
586 |
+ |
|
587 |
+ int total = el.findRowCount(); |
|
588 |
+ if (total > MAX_FETCH_USERS) { |
|
589 |
+ el.setMaxRows(MAX_FETCH_USERS); |
|
590 |
+ response().setHeader("Content-Range", "items " + MAX_FETCH_USERS + "/" + total); |
|
591 |
+ } |
|
592 |
+ |
|
593 |
+ for (User user :el.findList()) { |
|
594 |
+ addUserToUsers(user, users); |
|
595 |
+ } |
|
596 |
+ |
|
597 |
+ return ok(toJson(users)); |
|
598 |
+ } |
|
599 |
+ |
|
600 |
+ public static Result updateSharer(String owner, String projectName, Long number){ |
|
601 |
+ JsonNode json = request().body().asJson(); |
|
602 |
+ if (json == null) { |
|
603 |
+ return badRequest(Json.newObject().put("message", "Expecting Json data")); |
|
604 |
+ } |
|
605 |
+ |
|
606 |
+ Project project = Project.findByOwnerAndProjectName(owner, projectName); |
|
607 |
+ Issue issue = Issue.findByNumber(project, number); |
|
608 |
+ if (!AccessControl.isAllowed(UserApp.currentUser(), issue.asResource(), |
|
609 |
+ Operation.UPDATE)) { |
|
610 |
+ return forbidden(Json.newObject().put("message", "Permission denied")); |
|
611 |
+ } |
|
612 |
+ |
|
613 |
+ JsonNode sharer = json.findValue("sharer"); |
|
614 |
+ if(noSharer(sharer)){ |
|
615 |
+ return badRequest(Json.newObject().put("message", "No sharer")); |
|
616 |
+ } |
|
617 |
+ |
|
618 |
+ final String action = json.findValue("action").asText(); |
|
619 |
+ |
|
620 |
+ ObjectNode result = changeSharer(sharer, issue, action); |
|
621 |
+ sendNotification(sharer, issue, action); |
|
622 |
+ |
|
623 |
+ return ok(result); |
|
624 |
+ } |
|
625 |
+ |
|
626 |
+ private static ObjectNode changeSharer(JsonNode sharer, Issue issue, String action) { |
|
627 |
+ ObjectNode result = Json.newObject(); |
|
628 |
+ for (JsonNode sharerLoginId : sharer) { |
|
629 |
+ if ("add".equalsIgnoreCase(action)) { |
|
630 |
+ addSharer(issue, sharerLoginId.asText()); |
|
631 |
+ result.put("action", "added"); |
|
632 |
+ } else if ("delete".equalsIgnoreCase(action)) { |
|
633 |
+ result.put("action", "deleted"); |
|
634 |
+ removeSharer(issue, sharerLoginId.asText()); |
|
635 |
+ } else { |
|
636 |
+ play.Logger.error("Unknown issue sharing action: " + issue + ":" + action + " by " + currentUser()); |
|
637 |
+ result.put("action", "Do nothing. Unsupported action: " + action); |
|
638 |
+ } |
|
639 |
+ result.put("sharer", User.findByLoginId(sharerLoginId.asText()).getDisplayName()); |
|
640 |
+ } |
|
641 |
+ return result; |
|
642 |
+ } |
|
643 |
+ |
|
644 |
+ private static void sendNotification(JsonNode sharer, Issue issue, String action) { |
|
645 |
+ Runnable preUpdateHook = new Runnable() { |
|
646 |
+ @Override |
|
647 |
+ public void run() { |
|
648 |
+ for(JsonNode sharerLoginId: sharer){ |
|
649 |
+ addSharerChangedNotification(issue, sharerLoginId.asText(), action); |
|
650 |
+ } |
|
651 |
+ } |
|
652 |
+ }; |
|
653 |
+ preUpdateHook.run(); |
|
654 |
+ } |
|
655 |
+ |
|
656 |
+ |
|
657 |
+ private static void addSharerChangedNotification(Issue issue, String sharerLoginId, String action) { |
|
658 |
+ NotificationEvent notiEvent = NotificationEvent.afterIssueSharerChanged(issue, sharerLoginId, action); |
|
659 |
+ IssueEvent.addFromNotificationEventWithoutSkipEvent(notiEvent, issue, UserApp.currentUser().loginId); |
|
660 |
+ } |
|
661 |
+ |
|
662 |
+ private static boolean noSharer(JsonNode sharers) { |
|
663 |
+ return sharers == null || sharers.size() == 0; |
|
664 |
+ } |
|
665 |
+ |
|
666 |
+ private static void addSharer(Issue issue, String loginId) { |
|
667 |
+ IssueSharer issueSharer = IssueSharer.find.where() |
|
668 |
+ .eq("loginId", loginId) |
|
669 |
+ .eq("issue.id", issue.id).findUnique(); |
|
670 |
+ if(issueSharer == null) { |
|
671 |
+ issueSharer = IssueSharer.createSharer(loginId, issue); |
|
672 |
+ issueSharer.save(); |
|
673 |
+ } |
|
674 |
+ issue.sharers.add(issueSharer); |
|
675 |
+ } |
|
676 |
+ |
|
677 |
+ private static void removeSharer(Issue issue, String loginId) { |
|
678 |
+ IssueSharer issueSharer = |
|
679 |
+ IssueSharer.find.where() |
|
680 |
+ .eq("loginId", loginId) |
|
681 |
+ .eq("issue.id", issue.id) |
|
682 |
+ .findUnique(); |
|
683 |
+ issueSharer.delete(); |
|
684 |
+ issue.sharers.remove(issueSharer); |
|
685 |
+ } |
|
538 | 686 |
} |
--- app/models/Issue.java
+++ app/models/Issue.java
... | ... | @@ -75,6 +75,9 @@ |
75 | 75 |
@OneToMany(cascade = CascadeType.ALL, mappedBy="issue") |
76 | 76 |
public List<IssueEvent> events; |
77 | 77 |
|
78 |
+ @OneToMany(cascade = CascadeType.ALL, mappedBy = "issue") |
|
79 |
+ public Set<IssueSharer> sharers = new LinkedHashSet<>(); |
|
80 |
+ |
|
78 | 81 |
@ManyToMany(cascade = CascadeType.ALL) |
79 | 82 |
@JoinTable( |
80 | 83 |
name = "issue_voter", |
... | ... | @@ -675,4 +678,17 @@ |
675 | 678 |
.eq("state", State.OPEN) |
676 | 679 |
.findRowCount(); |
677 | 680 |
} |
681 |
+ |
|
682 |
+ public IssueSharer findSharerByUserId(Long id){ |
|
683 |
+ for (IssueSharer sharer : sharers) { |
|
684 |
+ if (sharer.user.id.equals(id)) { |
|
685 |
+ return sharer; |
|
686 |
+ } |
|
687 |
+ } |
|
688 |
+ return null; |
|
689 |
+ } |
|
690 |
+ |
|
691 |
+ public List<IssueSharer> getSortedSharer() { |
|
692 |
+ return new ArrayList<>(sharers); |
|
693 |
+ } |
|
678 | 694 |
} |
--- app/models/IssueEvent.java
+++ app/models/IssueEvent.java
... | ... | @@ -1,23 +1,10 @@ |
1 | 1 |
/** |
2 |
- * Yobi, Project Hosting SW |
|
3 |
- * |
|
4 |
- * Copyright 2013 NAVER Corp. |
|
5 |
- * http://yobi.io |
|
6 |
- * |
|
7 |
- * @author Yi EungJun |
|
8 |
- * |
|
9 |
- * Licensed under the Apache License, Version 2.0 (the "License"); |
|
10 |
- * you may not use this file except in compliance with the License. |
|
11 |
- * You may obtain a copy of the License at |
|
12 |
- * |
|
13 |
- * http://www.apache.org/licenses/LICENSE-2.0 |
|
14 |
- * |
|
15 |
- * Unless required by applicable law or agreed to in writing, software |
|
16 |
- * distributed under the License is distributed on an "AS IS" BASIS, |
|
17 |
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|
18 |
- * See the License for the specific language governing permissions and |
|
19 |
- * limitations under the License. |
|
20 |
- */ |
|
2 |
+ * Yona, 21st Century Project Hosting SW |
|
3 |
+ * <p> |
|
4 |
+ * Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp. |
|
5 |
+ * https://yona.io |
|
6 |
+ **/ |
|
7 |
+ |
|
21 | 8 |
package models; |
22 | 9 |
|
23 | 10 |
import models.enumeration.EventType; |
... | ... | @@ -91,8 +78,7 @@ |
91 | 78 |
.orderBy("id desc").setMaxRows(1).findUnique(); |
92 | 79 |
|
93 | 80 |
if (lastEvent != null) { |
94 |
- if (lastEvent.eventType == event.eventType && |
|
95 |
- StringUtils.equals(event.senderLoginId, lastEvent.senderLoginId)) { |
|
81 |
+ if (isSameUserEventAsPrevious(event, lastEvent)) { |
|
96 | 82 |
// A -> B, B -> C ==> A -> C |
97 | 83 |
event.oldValue = lastEvent.oldValue; |
98 | 84 |
lastEvent.delete(); |
... | ... | @@ -107,6 +93,45 @@ |
107 | 93 |
} |
108 | 94 |
|
109 | 95 |
event.save(); |
96 |
+ } |
|
97 |
+ |
|
98 |
+ |
|
99 |
+ /** |
|
100 |
+ * It is nearly same as {@link #add(IssueEvent)} except that it doesn't skip waypoint events. |
|
101 |
+ * |
|
102 |
+ * For example, if events of an issue are occurred continuously |
|
103 |
+ * A -> B -> C, then {@link #add(IssueEvent)} method skip B. |
|
104 |
+ * |
|
105 |
+ * This method doesn't skip B and leave it. |
|
106 |
+ * |
|
107 |
+ * @param event |
|
108 |
+ */ |
|
109 |
+ public static void addWithoutSkipEvent(IssueEvent event) { |
|
110 |
+ Date draftDate = DateTime.now().minusMillis(DRAFT_TIME_IN_MILLIS).toDate(); |
|
111 |
+ |
|
112 |
+ IssueEvent lastEvent = IssueEvent.find.where() |
|
113 |
+ .eq("issue.id", event.issue.id) |
|
114 |
+ .gt("created", draftDate) |
|
115 |
+ .orderBy("id desc").setMaxRows(1).findUnique(); |
|
116 |
+ |
|
117 |
+ if (lastEvent != null) { |
|
118 |
+ if (isSameUserEventAsPrevious(event, lastEvent) && |
|
119 |
+ isRevertingTheValue(event, lastEvent)) { |
|
120 |
+ lastEvent.delete(); |
|
121 |
+ return; |
|
122 |
+ } |
|
123 |
+ } |
|
124 |
+ event.save(); |
|
125 |
+ } |
|
126 |
+ |
|
127 |
+ private static boolean isRevertingTheValue(IssueEvent event, IssueEvent lastEvent) { |
|
128 |
+ return StringUtils.equals(event.oldValue, lastEvent.newValue) && |
|
129 |
+ StringUtils.equals(event.newValue, lastEvent.oldValue); |
|
130 |
+ } |
|
131 |
+ |
|
132 |
+ private static boolean isSameUserEventAsPrevious(IssueEvent event, IssueEvent lastEvent) { |
|
133 |
+ return lastEvent.eventType == event.eventType && |
|
134 |
+ StringUtils.equals(event.senderLoginId, lastEvent.senderLoginId); |
|
110 | 135 |
} |
111 | 136 |
|
112 | 137 |
/** |
... | ... | @@ -130,6 +155,18 @@ |
130 | 155 |
add(event); |
131 | 156 |
} |
132 | 157 |
|
158 |
+ public static void addFromNotificationEventWithoutSkipEvent(NotificationEvent notiEvent, Issue updatedIssue, |
|
159 |
+ String senderLoginId) { |
|
160 |
+ IssueEvent event = new IssueEvent(); |
|
161 |
+ event.created = notiEvent.created; |
|
162 |
+ event.senderLoginId = senderLoginId; |
|
163 |
+ event.issue = updatedIssue; |
|
164 |
+ event.eventType = notiEvent.eventType; |
|
165 |
+ event.oldValue = notiEvent.oldValue; |
|
166 |
+ event.newValue = notiEvent.newValue; |
|
167 |
+ addWithoutSkipEvent(event); |
|
168 |
+ } |
|
169 |
+ |
|
133 | 170 |
@Override |
134 | 171 |
public Date getDate() { |
135 | 172 |
return created; |
--- app/models/NotificationEvent.java
+++ app/models/NotificationEvent.java
... | ... | @@ -14,7 +14,6 @@ |
14 | 14 |
import models.resource.GlobalResource; |
15 | 15 |
import models.resource.Resource; |
16 | 16 |
import models.resource.ResourceConvertible; |
17 |
-import models.Webhook; |
|
18 | 17 |
import org.apache.commons.collections.CollectionUtils; |
19 | 18 |
import org.apache.commons.collections.Predicate; |
20 | 19 |
import org.apache.commons.lang3.StringUtils; |
... | ... | @@ -110,6 +109,10 @@ |
110 | 109 |
return oldValue; |
111 | 110 |
} |
112 | 111 |
|
112 |
+ public String getNewValue() { |
|
113 |
+ return newValue; |
|
114 |
+ } |
|
115 |
+ |
|
113 | 116 |
@Transient |
114 | 117 |
public String getMessage() { |
115 | 118 |
return getMessage(Lang.defaultLang()); |
... | ... | @@ -192,7 +195,14 @@ |
192 | 195 |
} |
193 | 196 |
case ISSUE_MOVED: |
194 | 197 |
return Messages.get(lang, "notification.type.issue.moved", oldValue, newValue); |
198 |
+ case ISSUE_SHARER_CHANGED: |
|
199 |
+ if (StringUtils.isNotBlank(newValue)) { |
|
200 |
+ return Messages.get(lang, "notification.issue.sharer.added", User.findByLoginId(newValue).getDisplayName()); |
|
201 |
+ } else if (StringUtils.isNotBlank(oldValue)) { |
|
202 |
+ return Messages.get(lang, "notification.issue.sharer.deleted"); |
|
203 |
+ } |
|
195 | 204 |
default: |
205 |
+ play.Logger.error("Unknown event message: " + this); |
|
196 | 206 |
return null; |
197 | 207 |
} |
198 | 208 |
} |
... | ... | @@ -378,8 +388,7 @@ |
378 | 388 |
.orderBy("id desc").setMaxRows(1).findUnique(); |
379 | 389 |
|
380 | 390 |
if (lastEvent != null) { |
381 |
- if (lastEvent.eventType == event.eventType && |
|
382 |
- event.senderId.equals(lastEvent.senderId)) { |
|
391 |
+ if (isSameUserEventAsPrevious(event, lastEvent)) { |
|
383 | 392 |
// If the last event is A -> B and the current event is B -> C, |
384 | 393 |
// they are merged into the new event A -> C. |
385 | 394 |
event.oldValue = lastEvent.getOldValue(); |
... | ... | @@ -399,6 +408,55 @@ |
399 | 408 |
} |
400 | 409 |
event.save(); |
401 | 410 |
event.saveManyToManyAssociations("receivers"); |
411 |
+ } |
|
412 |
+ |
|
413 |
+ public static void addWithoutSkipEvent(NotificationEvent event) { |
|
414 |
+ if (event.notificationMail == null) { |
|
415 |
+ event.notificationMail = new NotificationMail(); |
|
416 |
+ event.notificationMail.notificationEvent = event; |
|
417 |
+ } |
|
418 |
+ |
|
419 |
+ Date draftDate = DateTime.now().minusMillis(EventConstants.DRAFT_TIME_IN_MILLIS).toDate(); |
|
420 |
+ |
|
421 |
+ NotificationEvent lastEvent = NotificationEvent.find.where() |
|
422 |
+ .eq("resourceId", event.resourceId) |
|
423 |
+ .eq("resourceType", event.resourceType) |
|
424 |
+ .gt("created", draftDate) |
|
425 |
+ .orderBy("id desc").setMaxRows(1).findUnique(); |
|
426 |
+ |
|
427 |
+ if (lastEvent != null) { |
|
428 |
+ if (isSameUserEventAsPrevious(event, lastEvent) && |
|
429 |
+ isRevertingTheValue(event, lastEvent)) { |
|
430 |
+ lastEvent.delete(); |
|
431 |
+ return; |
|
432 |
+ } |
|
433 |
+ } |
|
434 |
+ |
|
435 |
+ if(isAddingSharerEvent(event)){ |
|
436 |
+ filterReceivers(event); |
|
437 |
+ } |
|
438 |
+ |
|
439 |
+ if (event.receivers.isEmpty()) { |
|
440 |
+ return; |
|
441 |
+ } |
|
442 |
+ event.save(); |
|
443 |
+ event.saveManyToManyAssociations("receivers"); |
|
444 |
+ } |
|
445 |
+ |
|
446 |
+ private static boolean isSameUserEventAsPrevious(NotificationEvent event, NotificationEvent lastEvent) { |
|
447 |
+ return lastEvent.eventType == event.eventType && |
|
448 |
+ event.senderId.equals(lastEvent.senderId); |
|
449 |
+ } |
|
450 |
+ |
|
451 |
+ private static boolean isRevertingTheValue(NotificationEvent event, NotificationEvent lastEvent) { |
|
452 |
+ return StringUtils.equals(event.oldValue, lastEvent.newValue) && |
|
453 |
+ StringUtils.equals(event.newValue, lastEvent.oldValue); |
|
454 |
+ } |
|
455 |
+ |
|
456 |
+ private static boolean isAddingSharerEvent(NotificationEvent event) { |
|
457 |
+ return event.eventType.equals(EventType.ISSUE_SHARER_CHANGED) |
|
458 |
+ && StringUtils.isBlank(event.oldValue) |
|
459 |
+ && StringUtils.isNotBlank(event.newValue); |
|
402 | 460 |
} |
403 | 461 |
|
404 | 462 |
private static void filterReceivers(final NotificationEvent event) { |
... | ... | @@ -809,6 +867,30 @@ |
809 | 867 |
NotificationEvent.add(notiEvent); |
810 | 868 |
|
811 | 869 |
return notiEvent; |
870 |
+ } |
|
871 |
+ |
|
872 |
+ public static NotificationEvent afterIssueSharerChanged(Issue issue, String sharerLoginId, String action) { |
|
873 |
+ NotificationEvent notiEvent = createFromCurrentUser(issue); |
|
874 |
+ notiEvent.title = formatReplyTitle(issue); |
|
875 |
+ notiEvent.receivers = findSharer(sharerLoginId); |
|
876 |
+ notiEvent.eventType = ISSUE_SHARER_CHANGED; |
|
877 |
+ if (IssueSharer.ADD.equalsIgnoreCase(action)) { |
|
878 |
+ notiEvent.oldValue = ""; |
|
879 |
+ notiEvent.newValue = sharerLoginId; |
|
880 |
+ } else if (IssueSharer.DELETE.equalsIgnoreCase(action)) { |
|
881 |
+ notiEvent.oldValue = sharerLoginId; |
|
882 |
+ notiEvent.newValue = ""; |
|
883 |
+ } |
|
884 |
+ |
|
885 |
+ NotificationEvent.addWithoutSkipEvent(notiEvent); |
|
886 |
+ |
|
887 |
+ return notiEvent; |
|
888 |
+ } |
|
889 |
+ |
|
890 |
+ private static Set<User> findSharer(String sharerLoginId) { |
|
891 |
+ Set<User> receivers = new HashSet<>(); |
|
892 |
+ receivers.add(User.findByLoginId(sharerLoginId)); |
|
893 |
+ return receivers; |
|
812 | 894 |
} |
813 | 895 |
|
814 | 896 |
private static Set<User> getReceiversForIssueBodyChanged(String oldBody, Issue issue) { |
... | ... | @@ -1284,4 +1366,21 @@ |
1284 | 1366 |
webhookRequest(COMMENT_UPDATED, comment, false); |
1285 | 1367 |
NotificationEvent.add(forUpdatedComment(comment, UserApp.currentUser())); |
1286 | 1368 |
} |
1369 |
+ |
|
1370 |
+ @Override |
|
1371 |
+ public String toString() { |
|
1372 |
+ return "NotificationEvent{" + |
|
1373 |
+ "id=" + id + |
|
1374 |
+ ", title='" + title + '\'' + |
|
1375 |
+ ", senderId=" + senderId + |
|
1376 |
+ ", receivers=" + receivers + |
|
1377 |
+ ", created=" + created + |
|
1378 |
+ ", resourceType=" + resourceType + |
|
1379 |
+ ", resourceId='" + resourceId + '\'' + |
|
1380 |
+ ", eventType=" + eventType + |
|
1381 |
+ ", oldValue='" + oldValue + '\'' + |
|
1382 |
+ ", newValue='" + newValue + '\'' + |
|
1383 |
+ ", notificationMail=" + notificationMail + |
|
1384 |
+ '}'; |
|
1385 |
+ } |
|
1287 | 1386 |
} |
--- app/models/enumeration/EventType.java
+++ app/models/enumeration/EventType.java
... | ... | @@ -1,7 +1,7 @@ |
1 | 1 |
/** |
2 | 2 |
* Yona, 21st Century Project Hosting SW |
3 | 3 |
* <p> |
4 |
- * Copyright Yona & Yobi Authors & NAVER Corp. |
|
4 |
+ * Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp. |
|
5 | 5 |
* https://yona.io |
6 | 6 |
**/ |
7 | 7 |
package models.enumeration; |
... | ... | @@ -34,7 +34,8 @@ |
34 | 34 |
REVIEW_THREAD_STATE_CHANGED("notification.type.review.state.changed", 18), |
35 | 35 |
ORGANIZATION_MEMBER_ENROLL_REQUEST("notification.organization.type.member.enroll",19), |
36 | 36 |
COMMENT_UPDATED("notification.type.comment.updated", 20), |
37 |
- ISSUE_MOVED("notification.type.issue.is.moved", 21); |
|
37 |
+ ISSUE_MOVED("notification.type.issue.is.moved", 21), |
|
38 |
+ ISSUE_SHARER_CHANGED("notification.type.issue.sharer.changed", 22); |
|
38 | 39 |
|
39 | 40 |
private String descr; |
40 | 41 |
|
--- app/models/support/SearchCondition.java
+++ app/models/support/SearchCondition.java
... | ... | @@ -1,23 +1,9 @@ |
1 | 1 |
/** |
2 |
- * Yobi, Project Hosting SW |
|
3 |
- * |
|
4 |
- * Copyright 2012 NAVER Corp. |
|
5 |
- * http://yobi.io |
|
6 |
- * |
|
7 |
- * @author Tae |
|
8 |
- * |
|
9 |
- * Licensed under the Apache License, Version 2.0 (the "License"); |
|
10 |
- * you may not use this file except in compliance with the License. |
|
11 |
- * You may obtain a copy of the License at |
|
12 |
- * |
|
13 |
- * http://www.apache.org/licenses/LICENSE-2.0 |
|
14 |
- * |
|
15 |
- * Unless required by applicable law or agreed to in writing, software |
|
16 |
- * distributed under the License is distributed on an "AS IS" BASIS, |
|
17 |
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|
18 |
- * See the License for the specific language governing permissions and |
|
19 |
- * limitations under the License. |
|
20 |
- */ |
|
2 |
+ * Yona, 21st Century Project Hosting SW |
|
3 |
+ * <p> |
|
4 |
+ * Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp. |
|
5 |
+ * https://yona.io |
|
6 |
+ **/ |
|
21 | 7 |
package models.support; |
22 | 8 |
|
23 | 9 |
import com.avaje.ebean.ExpressionList; |
... | ... | @@ -50,6 +36,7 @@ |
50 | 36 |
public Project project; |
51 | 37 |
|
52 | 38 |
public Long mentionId; |
39 |
+ public Long sharerId; |
|
53 | 40 |
public Organization organization; |
54 | 41 |
public List<String> projectNames; |
55 | 42 |
|
... | ... | @@ -76,6 +63,7 @@ |
76 | 63 |
one.assigneeId = this.assigneeId; |
77 | 64 |
one.commenterId = this.commenterId; |
78 | 65 |
one.mentionId = this.mentionId; |
66 |
+ one.sharerId = this.sharerId; |
|
79 | 67 |
one.dueDate = this.dueDate; |
80 | 68 |
one.projectNames = this.projectNames; |
81 | 69 |
return one; |
... | ... | @@ -151,6 +139,11 @@ |
151 | 139 |
return this; |
152 | 140 |
} |
153 | 141 |
|
142 |
+ public SearchCondition setSharerId(Long sharerId) { |
|
143 |
+ this.sharerId = sharerId; |
|
144 |
+ return this; |
|
145 |
+ } |
|
146 |
+ |
|
154 | 147 |
public ExpressionList<Issue> asExpressionList(@Nonnull Organization organization) { |
155 | 148 |
ExpressionList<Issue> el = Issue.finder.where(); |
156 | 149 |
|
... | ... | @@ -163,6 +156,8 @@ |
163 | 156 |
setAssigneeIfExists(el); |
164 | 157 |
setAuthorIfExist(el); |
165 | 158 |
setMentionedIssuesIfExist(el); |
159 |
+ setSharedIssuesIfExist(el); |
|
160 |
+ |
|
166 | 161 |
setFilteredStringIfExist(el); |
167 | 162 |
|
168 | 163 |
if (commentedCheck) { |
... | ... | @@ -279,6 +274,7 @@ |
279 | 274 |
setAuthorIfExist(el); |
280 | 275 |
setCommenterIfExist(el, null); |
281 | 276 |
setMentionedIssuesIfExist(el); |
277 |
+ setSharedIssuesIfExist(el); |
|
282 | 278 |
setFilteredStringIfExist(el); |
283 | 279 |
|
284 | 280 |
if (commentedCheck) { |
... | ... | @@ -309,6 +305,23 @@ |
309 | 305 |
User mentionUser = User.find.byId(mentionId); |
310 | 306 |
if(!mentionUser.isAnonymous()) { |
311 | 307 |
List<Long> ids = getMentioningIssueIds(mentionUser); |
308 |
+ |
|
309 |
+ if (ids.isEmpty()) { |
|
310 |
+ // No need to progress because the query matches nothing. |
|
311 |
+ el.idEq(-1); |
|
312 |
+ } else { |
|
313 |
+ el.idIn(ids); |
|
314 |
+ } |
|
315 |
+ } |
|
316 |
+ } |
|
317 |
+ } |
|
318 |
+ |
|
319 |
+ private void setSharedIssuesIfExist(ExpressionList<Issue> el) { |
|
320 |
+ |
|
321 |
+ if (sharerId != null) { |
|
322 |
+ User user = User.find.byId(sharerId); |
|
323 |
+ if(!user.isAnonymous()) { |
|
324 |
+ List<Long> ids = getSharedIssueIds(user); |
|
312 | 325 |
|
313 | 326 |
if (ids.isEmpty()) { |
314 | 327 |
// No need to progress because the query matches nothing. |
... | ... | @@ -373,6 +386,18 @@ |
373 | 386 |
return new ArrayList<>(ids); |
374 | 387 |
} |
375 | 388 |
|
389 |
+ private List<Long> getSharedIssueIds(User user) { |
|
390 |
+ Set<Long> ids = new HashSet<>(); |
|
391 |
+ List<IssueSharer> issueSharers = IssueSharer.find.where() |
|
392 |
+ .eq("user.id", user.id) |
|
393 |
+ .findList(); |
|
394 |
+ for (IssueSharer issueSharer : issueSharers) { |
|
395 |
+ ids.add(issueSharer.issue.id); |
|
396 |
+ } |
|
397 |
+ |
|
398 |
+ return new ArrayList<>(ids); |
|
399 |
+ } |
|
400 |
+ |
|
376 | 401 |
public ExpressionList<Issue> asExpressionList(Project project) { |
377 | 402 |
ExpressionList<Issue> el = Issue.finder.where(); |
378 | 403 |
if( project != null ){ |
... | ... | @@ -417,6 +442,7 @@ |
417 | 442 |
} |
418 | 443 |
|
419 | 444 |
setCommenterIfExist(el, project); |
445 |
+ setSharedIssuesIfExist(el); |
|
420 | 446 |
|
421 | 447 |
if (milestoneId != null) { |
422 | 448 |
if (milestoneId.equals(Milestone.NULL_MILESTONE_ID)) { |
... | ... | @@ -457,4 +483,23 @@ |
457 | 483 |
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); |
458 | 484 |
return sdf.format(this.dueDate); |
459 | 485 |
} |
486 |
+ |
|
487 |
+ @Override |
|
488 |
+ public String toString() { |
|
489 |
+ return "SearchCondition{" + |
|
490 |
+ "state='" + state + '\'' + |
|
491 |
+ ", commentedCheck=" + commentedCheck + |
|
492 |
+ ", milestoneId=" + milestoneId + |
|
493 |
+ ", labelIds=" + labelIds + |
|
494 |
+ ", authorId=" + authorId + |
|
495 |
+ ", assigneeId=" + assigneeId + |
|
496 |
+ ", project=" + project + |
|
497 |
+ ", mentionId=" + mentionId + |
|
498 |
+ ", sharerId=" + sharerId + |
|
499 |
+ ", organization=" + organization + |
|
500 |
+ ", projectNames=" + projectNames + |
|
501 |
+ ", commenterId=" + commenterId + |
|
502 |
+ ", dueDate=" + dueDate + |
|
503 |
+ '}'; |
|
504 |
+ } |
|
460 | 505 |
} |
--- app/utils/AccessControl.java
+++ app/utils/AccessControl.java
... | ... | @@ -27,6 +27,8 @@ |
27 | 27 |
import models.resource.Resource; |
28 | 28 |
import org.apache.commons.lang.BooleanUtils; |
29 | 29 |
|
30 |
+import java.util.Optional; |
|
31 |
+ |
|
30 | 32 |
import static models.OrganizationUser.isAdmin; |
31 | 33 |
import static models.OrganizationUser.isMember; |
32 | 34 |
|
... | ... | @@ -114,7 +116,8 @@ |
114 | 116 |
return false; |
115 | 117 |
} |
116 | 118 |
|
117 |
- if (isAllowedIfAuthor(user, container) || isAllowedIfAssignee(user, container)) { |
|
119 |
+ if (isAllowedIfAuthor(user, container) || isAllowedIfAssignee(user, container) |
|
120 |
+ || isAllowedIfSharer(user, container)) { |
|
118 | 121 |
return true; |
119 | 122 |
} |
120 | 123 |
|
... | ... | @@ -285,6 +288,7 @@ |
285 | 288 |
case READ: |
286 | 289 |
return project.isPublic() && !user.isGuest |
287 | 290 |
|| user.isMemberOf(project) |
291 |
+ || isAllowedIfSharer(user, resource) |
|
288 | 292 |
|| isAllowedIfGroupMember(project, user); |
289 | 293 |
case UPDATE: |
290 | 294 |
return user.isMemberOf(project) |
... | ... | @@ -374,6 +378,17 @@ |
374 | 378 |
} |
375 | 379 |
} |
376 | 380 |
|
381 |
+ private static boolean isAllowedIfSharer(User user, Resource resource) { |
|
382 |
+ switch (resource.getType()) { |
|
383 |
+ case ISSUE_POST: |
|
384 |
+ case ISSUE_COMMENT: |
|
385 |
+ Issue issue = Issue.finder.byId(Long.valueOf(resource.getId())); |
|
386 |
+ return issue != null && Optional.ofNullable(issue.findSharerByUserId(user.id)).isPresent(); |
|
387 |
+ default: |
|
388 |
+ return false; |
|
389 |
+ } |
|
390 |
+ } |
|
391 |
+ |
|
377 | 392 |
/** |
378 | 393 |
* Checks if an user has a permission to do something to the given |
379 | 394 |
* resource as an assignee. |
--- app/views/issue/my_partial_list_quicksearch.scala.html
+++ app/views/issue/my_partial_list_quicksearch.scala.html
... | ... | @@ -1,66 +1,72 @@ |
1 | 1 |
@** |
2 |
-* Yobi, Project Hosting SW |
|
2 |
+* Yona, 21st Century Project Hosting SW |
|
3 | 3 |
* |
4 |
-* Copyright 2014 NAVER Corp. |
|
5 |
-* http://yobi.io |
|
6 |
-* |
|
7 |
-* @author Jihan Kim |
|
8 |
-* |
|
9 |
-* Licensed under the Apache License, Version 2.0 (the "License"); |
|
10 |
-* you may not use this file except in compliance with the License. |
|
11 |
-* You may obtain a copy of the License at |
|
12 |
-* |
|
13 |
-* http://www.apache.org/licenses/LICENSE-2.0 |
|
14 |
-* |
|
15 |
-* Unless required by applicable law or agreed to in writing, software |
|
16 |
-* distributed under the License is distributed on an "AS IS" BASIS, |
|
17 |
-* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|
18 |
-* See the License for the specific language governing permissions and |
|
19 |
-* limitations under the License. |
|
4 |
+* Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp. |
|
5 |
+* https://yona.io |
|
20 | 6 |
**@ |
21 |
-@(param:models.support.SearchCondition) |
|
7 |
+@import models.support.SearchCondition |
|
8 |
+@(param:SearchCondition) |
|
9 |
+ |
|
10 |
+@currentUserId = @{ |
|
11 |
+ UserApp.currentUser().id |
|
12 |
+} |
|
22 | 13 |
|
23 | 14 |
<ul class="lst-stacked unstyled"> |
24 | 15 |
@if(!UserApp.currentUser().isAnonymous()){ |
25 |
- <li @if(param.assigneeId == UserApp.currentUser().id){ class="active"}> |
|
16 |
+ <li @if(param.assigneeId == currentUserId){ class="active"}> |
|
26 | 17 |
<a pjax-filter href="#" |
27 | 18 |
data-author-id="" |
28 |
- data-assignee-id="@UserApp.currentUser.id" |
|
19 |
+ data-assignee-id="@currentUserId" |
|
29 | 20 |
data-commenter-id="" |
30 | 21 |
data-milestone-id="@param.milestoneId" |
31 |
- data-mention-id=""> |
|
22 |
+ data-mention-id="" |
|
23 |
+ data-sharer-id=""> |
|
32 | 24 |
@Messages("issue.list.assignedToMe") |
33 | 25 |
</a> |
34 | 26 |
</li> |
35 |
- <li @if(param.authorId == UserApp.currentUser().id){ class="active"}> |
|
27 |
+ <li @if(param.authorId == currentUserId){ class="active"}> |
|
36 | 28 |
<a pjax-filter href="#" |
37 |
- data-author-id="@UserApp.currentUser.id" |
|
29 |
+ data-author-id="@currentUserId" |
|
38 | 30 |
data-assignee-id="" |
39 | 31 |
data-commenter-id="" |
40 | 32 |
data-milestone-id="@param.milestoneId" |
41 |
- data-mention-id=""> |
|
33 |
+ data-mention-id="" |
|
34 |
+ data-sharer-id=""> |
|
42 | 35 |
@Messages("issue.list.authoredByMe") |
43 | 36 |
</a> |
44 | 37 |
</li> |
45 |
- <li @if(param.commenterId == UserApp.currentUser().id){ class="active"}> |
|
38 |
+ <li @if(param.commenterId == currentUserId){ class="active"}> |
|
46 | 39 |
<a pjax-filter href="#" |
47 | 40 |
data-author-id="" |
48 | 41 |
data-assignee-id="" |
49 |
- data-commenter-id="@UserApp.currentUser.id" |
|
42 |
+ data-commenter-id="@currentUserId" |
|
50 | 43 |
data-milestone-id="@param.milestoneId" |
51 |
- data-mention-id=""> |
|
44 |
+ data-mention-id="" |
|
45 |
+ data-sharer-id=""> |
|
52 | 46 |
@Messages("issue.list.commentedByMe") |
53 | 47 |
</a> |
54 | 48 |
</li> |
55 |
- <li @if(param.mentionId == UserApp.currentUser().id){ class="active"}> |
|
49 |
+ <li @if(param.mentionId == currentUserId){ class="active"}> |
|
56 | 50 |
<a pjax-filter href="#" |
57 | 51 |
data-author-id="" |
58 | 52 |
data-assignee-id="" |
59 | 53 |
data-commenter-id="" |
60 | 54 |
data-milestone-id="@param.milestoneId" |
61 |
- data-mention-id="@UserApp.currentUser.id"> |
|
55 |
+ data-mention-id="@currentUserId" |
|
56 |
+ data-sharer-id=""> |
|
62 | 57 |
@Messages("issue.list.mentionedOfMe") |
63 | 58 |
</a> |
64 | 59 |
</li> |
60 |
+ <li @if(param.sharerId == currentUserId){ class="active"}> |
|
61 |
+ <a pjax-filter href="#" |
|
62 |
+ data-author-id="" |
|
63 |
+ data-assignee-id="" |
|
64 |
+ data-commenter-id="" |
|
65 |
+ data-milestone-id="@param.milestoneId" |
|
66 |
+ data-mention-id="" |
|
67 |
+ data-sharer-id="@currentUserId"> |
|
68 |
+ @Messages("issue.list.sharedWithMe") (@IssueSharer.getNumberOfIssuesSharedWithUser(currentUserId)) |
|
69 |
+ </a> |
|
70 |
+ </li> |
|
65 | 71 |
} |
66 | 72 |
</ul> |
--- app/views/issue/my_partial_search.scala.html
+++ app/views/issue/my_partial_search.scala.html
... | ... | @@ -1,11 +1,13 @@ |
1 | 1 |
@** |
2 | 2 |
* Yona, 21st Century Project Hosting SW |
3 | 3 |
* |
4 |
-* Copyright Yona & Yobi Authors & NAVER Corp. |
|
4 |
+* Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp. |
|
5 | 5 |
* https://yona.io |
6 | 6 |
**@ |
7 | 7 |
@import java.util |
8 |
-@(title: String, currentPage: com.avaje.ebean.Page[Issue], param:models.support.SearchCondition, project:Project) |
|
8 |
+@import com.avaje.ebean.Page |
|
9 |
+@import models.support.SearchCondition |
|
10 |
+@(title: String, currentPage: Page[Issue], param:SearchCondition, project:Project) |
|
9 | 11 |
|
10 | 12 |
@import helper._ |
11 | 13 |
@import models.enumeration._ |
... | ... | @@ -33,6 +35,7 @@ |
33 | 35 |
<input type="hidden" name="commenterId" value="@param.commenterId" data-search="commenterId"> |
34 | 36 |
<input type="hidden" name="assigneeId" value="@param.assigneeId" data-search="assigneeId"> |
35 | 37 |
<input type="hidden" name="mentionId" value="@param.mentionId" data-search="mentionId"> |
38 |
+ <input type="hidden" name="sharerId" value="@param.sharerId" data-search="sharerId"> |
|
36 | 39 |
<div class="search myissues-search-input"> |
37 | 40 |
<div class="search-bar"> |
38 | 41 |
<input name="filter" class="textbox full" type="text" value="@param.filter" placeholder="@Messages("issue.search")"> |
--- app/views/issue/partial_comments.scala.html
+++ app/views/issue/partial_comments.scala.html
... | ... | @@ -1,7 +1,7 @@ |
1 | 1 |
@** |
2 | 2 |
* Yona, 21st Century Project Hosting SW |
3 | 3 |
* |
4 |
-* Copyright Yona & Yobi Authors & NAVER Corp. |
|
4 |
+* Copyright Yona & Yobi Authors & NAVER Corp. & NAVER LABS Corp. |
|
5 | 5 |
* https://yona.io |
6 | 6 |
**@ |
7 | 7 |
@(project:Project, issue:Issue) |
... | ... | @@ -178,6 +178,15 @@ |
178 | 178 |
@Html(Messages("issue.event.referred",linkToUser(user.loginId, user.getDisplayName),linkToPullRequest(pull))) |
179 | 179 |
} |
180 | 180 |
} |
181 |
+ case EventType.ISSUE_SHARER_CHANGED => { |
|
182 |
+ @if(StringUtils.isBlank(event.oldValue) && StringUtils.isNotBlank(event.newValue)){ |
|
183 |
+ <span class="state sharer-added">@Messages("issue.sharer")</span> |
|
184 |
+ @Html(Messages("issue.event.sharer.added", linkToUser(user.loginId, user.getPureNameOnly), linkToUser(event.newValue, User.findByLoginId(event.newValue).getPureNameOnly))) |
|
185 |
+ } else { |
|
186 |
+ <span class="state sharer-deleted">@Messages("issue.event.sharer.deleted.title")</span> |
|
187 |
+ @Html(Messages("issue.event.sharer.deleted", linkToUser(user.loginId, user.getPureNameOnly), linkToUser(event.oldValue, User.findByLoginId(event.oldValue).getPureNameOnly))) |
|
188 |
+ } |
|
189 |
+ } |
|
181 | 190 |
case _ => { |
182 | 191 |
@event.newValue by @linkToUser(user.loginId, user.getDisplayName) |
183 | 192 |
} |
--- app/views/issue/partial_list.scala.html
+++ app/views/issue/partial_list.scala.html
... | ... | @@ -62,7 +62,7 @@ |
62 | 62 |
</span> |
63 | 63 |
} |
64 | 64 |
|
65 |
- @if(issue.comments.size>0 || issue.voters.size>0) { |
|
65 |
+ @if(issue.comments.size > 0 || issue.voters.size > 0 || issue.sharers.size > 0) { |
|
66 | 66 |
<span class="infos-item item-count-groups"> |
67 | 67 |
@if(issue.comments.size>0){ |
68 | 68 |
@views.html.common.commentCount(routes.IssueApp.issue(project.owner, project.name, issue.getNumber).toString + "#comments", issue.comments.size, true) |
... | ... | @@ -70,6 +70,9 @@ |
70 | 70 |
@if(issue.voters.size>0){ |
71 | 71 |
@views.html.common.voteCount(routes.IssueApp.issue(project.owner, project.name, issue.getNumber).toString + "#vote", issue.voters.size, true) |
72 | 72 |
} |
73 |
+ @if(issue.sharers.size > 0){ |
|
74 |
+ @views.html.common.sharerCount(issue.sharers.size, true) |
|
75 |
+ } |
|
73 | 76 |
</span> |
74 | 77 |
} |
75 | 78 |
|
--- app/views/issue/view.scala.html
+++ app/views/issue/view.scala.html
... | ... | @@ -5,6 +5,7 @@ |
5 | 5 |
* https://yona.io |
6 | 6 |
**@ |
7 | 7 |
@(title:String, issue:Issue, issueForm: play.data.Form[Issue], commentForm: play.data.Form[Comment],project:Project) |
8 |
+@import scala.collection.mutable.ArrayBuffer |
|
8 | 9 |
@import org.apache.commons.lang.StringUtils |
9 | 10 |
@import models.enumeration.ResourceType |
10 | 11 |
@import models.enumeration.Operation |
... | ... | @@ -56,6 +57,22 @@ |
56 | 57 |
} else { |
57 | 58 |
issue.id |
58 | 59 |
} |
60 |
+} |
|
61 |
+ |
|
62 |
+@hasAssignee = @{ |
|
63 |
+ issue.assigneeName != null |
|
64 |
+} |
|
65 |
+ |
|
66 |
+@hasSharer = @{ |
|
67 |
+ issue.sharers.size > 0 |
|
68 |
+} |
|
69 |
+ |
|
70 |
+@sharers = @{ |
|
71 |
+ var sharerIds = ArrayBuffer[String]() |
|
72 |
+ for( sharedUser <- issue.sharers ) { |
|
73 |
+ sharerIds += sharedUser.loginId |
|
74 |
+ } |
|
75 |
+ sharerIds.mkString(",") |
|
59 | 76 |
} |
60 | 77 |
|
61 | 78 |
@VOTER_AVATAR_SHOW_LIMIT = @{ 5 } |
... | ... | @@ -175,6 +192,9 @@ |
175 | 192 |
} |
176 | 193 |
</button> |
177 | 194 |
} |
195 |
+ @if(isAllowed(UserApp.currentUser(), issue.asResource(), Operation.UPDATE) && !hasSharer) { |
|
196 |
+ <button id="issue-share-button" type="button" class="ybtn">@Messages("button.share.issue")</button> |
|
197 |
+ } |
|
178 | 198 |
|
179 | 199 |
</div> |
180 | 200 |
</div> |
... | ... | @@ -219,6 +239,24 @@ |
219 | 239 |
} |
220 | 240 |
</span> |
221 | 241 |
</div> |
242 |
+ <dl class="sharer-list @if(!hasSharer){hideFromDisplayOnly}"> |
|
243 |
+ <dt class="issue-share-title mb10"> |
|
244 |
+ @Messages("issue.sharer") <span class="num issue-sharer-count">@if(issue.sharers.size > 0) { @issue.sharers.size }</span> |
|
245 |
+ </dt> |
|
246 |
+ <dd id="sharer-list" class="@if(!hasSharer){hideFromDisplayOnly}"> |
|
247 |
+ @if(isAllowed(UserApp.currentUser(), issue.asResource(), Operation.UPDATE)) { |
|
248 |
+ <input type="hidden" class="bigdrop width100p" id="issueSharer" name="issueSharer" placeholder="@Messages("issue.sharer.select")" value="@sharers" title=""> |
|
249 |
+ } else { |
|
250 |
+ @for(sharer <- issue.getSortedSharer){ |
|
251 |
+ <div class="text-ellipsis sharer-item"> |
|
252 |
+ <a href="@userInfo(sharer.loginId)" class="usf-group"> |
|
253 |
+ <strong class="name">@sharer.user.getDisplayName</strong> |
|
254 |
+ </a> |
|
255 |
+ </div> |
|
256 |
+ } |
|
257 |
+ } |
|
258 |
+ </dd> |
|
259 |
+ </dl> |
|
222 | 260 |
<div class="watcher-list"></div> |
223 | 261 |
<div class="subtasks"> |
224 | 262 |
@if(issue.parent == null) { |
... | ... | @@ -254,23 +292,21 @@ |
254 | 292 |
<dt>@Messages("issue.assignee")</dt> |
255 | 293 |
|
256 | 294 |
<dd> |
257 |
- @defining(issue.assigneeName != null) { isAssigned => |
|
258 |
- @if(isAllowed(UserApp.currentUser(), issue.asResource(), Operation.UPDATE)) { |
|
259 |
- @partial_assignee(project, issue) |
|
295 |
+ @if(isAllowed(UserApp.currentUser(), issue.asResource(), Operation.UPDATE)) { |
|
296 |
+ @partial_assignee(project, issue) |
|
297 |
+ } else { |
|
298 |
+ @if(hasAssignee){ |
|
299 |
+ <a href="@userInfo(issue.assignee.user.loginId)" class="usf-group"> |
|
300 |
+ <span class="avatar-wrap smaller"> |
|
301 |
+ <img src="@User.findByLoginId(issue.assignee.user.loginId).avatarUrl" width="20" height="20"> |
|
302 |
+ </span> |
|
303 |
+ <strong class="name">@issue.assignee.user.getDisplayName</strong> |
|
304 |
+ <span class="loginid"> <strong>@{"@"}</strong>@issue.assignee.user.loginId</span> |
|
305 |
+ </a> |
|
260 | 306 |
} else { |
261 |
- @if(isAssigned){ |
|
262 |
- <a href="@userInfo(issue.assignee.user.loginId)" class="usf-group"> |
|
263 |
- <span class="avatar-wrap smaller"> |
|
264 |
- <img src="@User.findByLoginId(issue.assignee.user.loginId).avatarUrl" width="20" height="20"> |
|
265 |
- </span> |
|
266 |
- <strong class="name">@issue.assignee.user.getDisplayName</strong> |
|
267 |
- <span class="loginid"> <strong>@{"@"}</strong>@issue.assignee.user.loginId</span> |
|
268 |
- </a> |
|
269 |
- } else { |
|
270 |
- <div> |
|
271 |
- @Messages("issue.noAssignee") |
|
272 |
- </div> |
|
273 |
- } |
|
307 |
+ <div> |
|
308 |
+ @Messages("issue.noAssignee") |
|
309 |
+ </div> |
|
274 | 310 |
} |
275 | 311 |
} |
276 | 312 |
</dd> |
... | ... | @@ -427,6 +463,7 @@ |
427 | 463 |
<script type="text/javascript" src="@routes.Assets.at("javascripts/lib/atjs/jquery.caret.min.js")"></script> |
428 | 464 |
<script type="text/javascript" src="@routes.Assets.at("javascripts/lib/atjs/jquery.atwho.js")"></script> |
429 | 465 |
<script type="text/javascript" src="@routes.Assets.at("javascripts/service/yona.issue.Assginee.js")"></script> |
466 |
+<script type="text/javascript" src="@routes.Assets.at("javascripts/service/yona.issue.Sharer.js")"></script> |
|
430 | 467 |
<script type="text/javascript"> |
431 | 468 |
$(function(){ |
432 | 469 |
// yobi.issue.View |
... | ... | @@ -471,6 +508,18 @@ |
471 | 508 |
"@Messages("issue.assignee")" |
472 | 509 |
); |
473 | 510 |
|
511 |
+ yonaIssueSharerModule( |
|
512 |
+ "@api.routes.IssueApi.findSharerByloginIds(project.owner, project.name, issue.getNumber)", |
|
513 |
+ "@api.routes.IssueApi.findSharableUsers(project.owner, project.name, issue.getNumber)", |
|
514 |
+ "@api.routes.IssueApi.updateSharer(project.owner, project.name, issue.getNumber)", |
|
515 |
+ "@Messages("issue.sharer")" |
|
516 |
+ ); |
|
517 |
+ |
|
518 |
+ $('#issue-share-button').on('click', function () { |
|
519 |
+ $('#sharer-list').show(); |
|
520 |
+ $('.sharer-list').show(); |
|
521 |
+ }); |
|
522 |
+ |
|
474 | 523 |
$('#translate').one('click', function (e) { |
475 | 524 |
var data = { |
476 | 525 |
owner: "@project.owner", |
+++ conf/evolutions/default/19.sql
... | ... | @@ -0,0 +1,19 @@ |
1 | +# --- !Ups | |
2 | +CREATE TABLE issue_sharer ( | |
3 | + id BIGINT AUTO_INCREMENT NOT NULL, | |
4 | + created DATE, | |
5 | + login_id VARCHAR(255), | |
6 | + user_id BIGINT, | |
7 | + issue_id BIGINT, | |
8 | + CONSTRAINT pk_issue_sharer PRIMARY KEY (id), | |
9 | + CONSTRAINT fk_issue_sharer_user FOREIGN KEY (user_id) REFERENCES n4user (id) on DELETE CASCADE, | |
10 | + CONSTRAINT fk_issue_sharer_issue FOREIGN KEY (issue_id) REFERENCES issue (id) on DELETE CASCADE | |
11 | +) | |
12 | +row_format=compressed, key_block_size=8; | |
13 | + | |
14 | +CREATE index ix_issue_sharer_login_id ON issue_sharer (login_id); | |
15 | +CREATE index ix_issue_sharer_user_id ON issue_sharer (user_id); | |
16 | +CREATE index ix_issue_sharer_issue_id ON issue_sharer (issue_id); | |
17 | + | |
18 | +# --- !Downs | |
19 | +DROP TABLE issue_sharer; |
--- conf/messages
+++ conf/messages
... | ... | @@ -75,6 +75,8 @@ |
75 | 75 |
button.selectFile = Select file |
76 | 76 |
button.setDefaultLoginPage = Set to default page |
77 | 77 |
button.setDefaultLoginPage.desc = Make current page to index page when logged in |
78 |
+button.share.issue = Issue Sharing |
|
79 |
+button.share.issue.desc = Allow others can see this issue and receive notifications |
|
78 | 80 |
button.show.original = See text |
79 | 81 |
button.signup = Sign up for {0} |
80 | 82 |
button.submitForm = Submit form |
... | ... | @@ -282,6 +284,10 @@ |
282 | 284 |
issue.event.open = {0} reopened this issue |
283 | 285 |
issue.event.referred = {0} mentioned this issue in {1} |
284 | 286 |
issue.event.referred.title = mentioned |
287 |
+issue.event.sharer.added = {0} shared current issue to {1} |
|
288 |
+issue.event.sharer.added.title = Issue sharing |
|
289 |
+issue.event.sharer.deleted = {0} cancelled issue sharing with {1} |
|
290 |
+issue.event.sharer.deleted.title = Cancelled |
|
285 | 291 |
issue.event.unassigned = {0} set assignee to unassigned |
286 | 292 |
issue.is.empty = No issue found |
287 | 293 |
issue.label = Issue Label |
... | ... | @@ -292,6 +298,7 @@ |
292 | 298 |
issue.list.authoredByMe = Created |
293 | 299 |
issue.list.commentedByMe = Commented |
294 | 300 |
issue.list.mentionedOfMe = Mentioned |
301 |
+issue.list.sharedWithMe = Shared |
|
295 | 302 |
issue.menu.new = New issue |
296 | 303 |
issue.menu.new.mine = New issue - personal inbox |
297 | 304 |
issue.myIssue = My issues |
... | ... | @@ -302,6 +309,8 @@ |
302 | 309 |
issue.noMilestone = No milestone |
303 | 310 |
issue.option = Option |
304 | 311 |
issue.search = Search Issues |
312 |
+issue.sharer = Issue Sharer |
|
313 |
+issue.sharer.select = Select Issue Sharer |
|
305 | 314 |
issue.state = Status |
306 | 315 |
issue.state.all = All |
307 | 316 |
issue.state.assigned = Assigned |
... | ... | @@ -401,6 +410,8 @@ |
401 | 410 |
notification.issue.closed = Issue has been closed |
402 | 411 |
notification.issue.reopened = Issue has been reopened |
403 | 412 |
notification.issue.unassigned = Issue has been unassigned |
413 |
+notification.issue.sharer.added = Issue is shared with {0} |
|
414 |
+notification.issue.sharer.deleted = Issue sharing state is changed |
|
404 | 415 |
notification.linkToView = View it on {0} |
405 | 416 |
notification.linkToViewHtml = <a href="{1}" target="{2}">View it on {0}</a> |
406 | 417 |
notification.member.enroll.accept = Accepted as a member. |
--- conf/messages.ko-KR
+++ conf/messages.ko-KR
... | ... | @@ -75,6 +75,8 @@ |
75 | 75 |
button.selectFile = 파일 선택 |
76 | 76 |
button.setDefaultLoginPage = 기본 페이지로 지정 |
77 | 77 |
button.setDefaultLoginPage.desc = 현재 페이지를 로그인 후 표시되는 기본 인덱스 페이지로 지정합니다 |
78 |
+button.share.issue = 이슈 공유 |
|
79 |
+button.share.issue.desc = 이 이슈에 대한 접근과 알림을 허용할 사용자를 지정합니다 |
|
78 | 80 |
button.show.original = 원문 보기 |
79 | 81 |
button.signup = {0} 시작 하기 |
80 | 82 |
button.submitForm = 폼 전송 |
... | ... | @@ -282,6 +284,10 @@ |
282 | 284 |
issue.event.open = {0}님이 이 이슈를 다시 열었습니다. |
283 | 285 |
issue.event.referred = {0} 님이 {1} 에서 이 이슈를 언급했습니다. |
284 | 286 |
issue.event.referred.title = 언급됨 |
287 |
+issue.event.sharer.added = {0} 님이 현재 이슈를 {1}님에게 공유했습니다. |
|
288 |
+issue.event.sharer.added.title = 이슈 공유 |
|
289 |
+issue.event.sharer.deleted = {0} 님이 {1}님을 공유대상에서 제외했습니다. |
|
290 |
+issue.event.sharer.deleted.title = 공유 취소 |
|
285 | 291 |
issue.event.unassigned = {0}님이 이 이슈의 담당자를 "없음"으로 설정하였습니다. |
286 | 292 |
issue.is.empty = 등록된 이슈가 없습니다. |
287 | 293 |
issue.label = 이슈 라벨 |
... | ... | @@ -292,6 +298,7 @@ |
292 | 298 |
issue.list.authoredByMe = 작성한 이슈 |
293 | 299 |
issue.list.commentedByMe = 댓글 남긴 이슈 |
294 | 300 |
issue.list.mentionedOfMe = 나를 언급한 이슈 |
301 |
+issue.list.sharedWithMe = 공유된 이슈 |
|
295 | 302 |
issue.menu.new = 새 이슈 |
296 | 303 |
issue.menu.new.mine = 새 이슈 - 개인 inbox |
297 | 304 |
issue.myIssue = 내 이슈 |
... | ... | @@ -302,6 +309,8 @@ |
302 | 309 |
issue.noMilestone = 마일스톤 없음 |
303 | 310 |
issue.option = 이슈 옵션 |
304 | 311 |
issue.search = 이슈 검색 |
312 |
+issue.sharer = 이슈 공유 |
|
313 |
+issue.sharer.select = 이슈 공유 대상 선택 |
|
305 | 314 |
issue.state = 상태 |
306 | 315 |
issue.state.all = 전체 |
307 | 316 |
issue.state.assigned = 할당됨 |
... | ... | @@ -400,6 +409,8 @@ |
400 | 409 |
notification.issue.assigned = {0}에게 이슈 할당됨 |
401 | 410 |
notification.issue.closed = 이슈 닫힘 |
402 | 411 |
notification.issue.reopened = 이슈 다시 열림 |
412 |
+notification.issue.sharer.added = {0} 님에게 이슈가 공유되었습니다. |
|
413 |
+notification.issue.sharer.deleted = 이슈 공유 상태가 변경되었습니다. |
|
403 | 414 |
notification.issue.unassigned = 이슈 담당자 없음 |
404 | 415 |
notification.linkToView = {0}에서 보기 |
405 | 416 |
notification.linkToViewHtml = <a href="{1}" target="{2}">View it on {0}</a> |
... | ... | @@ -442,6 +453,7 @@ |
442 | 453 |
notification.type.issue.moved = 이슈가 {0}에서 {1}로 이동되었습니다 |
443 | 454 |
notification.type.issue.referred.from.commit = 커밋에서의 이슈 언급 |
444 | 455 |
notification.type.issue.referred.from.pullrequest = 코드 주고받기에서의 이슈 언급 |
456 |
+notification.type.issue.sharer.changed = 이슈 공유 변경 |
|
445 | 457 |
notification.type.issue.state.changed = 이슈 상태 변경 |
446 | 458 |
notification.type.member.enroll = 멤버 등록 요청 |
447 | 459 |
notification.type.new.comment = 새 댓글 등록 |
--- conf/routes
+++ conf/routes
... | ... | @@ -56,6 +56,9 @@ |
56 | 56 |
GET /-_-api/v1/owners/:owner/projects/:projectName/issues/:number/assignableUsers controllers.api.IssueApi.findAssignableUsers(owner:String, projectName:String, number:Long, query: String ?= "") |
57 | 57 |
GET /-_-api/v1/owners/:owner/projects/:projectName/assignableUsers controllers.api.IssueApi.findAssignableUsersOfProject(owner:String, projectName:String, query: String ?= "") |
58 | 58 |
POST /-_-api/v1/owners/:owner/projects/:projectName/issues/:number/assignees controllers.api.IssueApi.updateAssginees(owner:String, projectName:String, number:Long) |
59 |
+GET /-_-api/v1/owners/:owner/projects/:projectName/issues/:number/findSharer controllers.api.IssueApi.findSharerByloginIds(owner:String, projectName:String, number:Long, query: String ?= "") |
|
60 |
+GET /-_-api/v1/owners/:owner/projects/:projectName/issues/:number/sharableUsers controllers.api.IssueApi.findSharableUsers(owner:String, projectName:String, number:Long, query: String ?= "") |
|
61 |
+POST /-_-api/v1/owners/:owner/projects/:projectName/issues/:number/share controllers.api.IssueApi.updateSharer(owner:String, projectName:String, number:Long) |
|
59 | 62 |
GET /-_-api/v1/users controllers.UserApp.users(query: String ?= "") |
60 | 63 |
POST /-_-api/v1/users controllers.api.UserApi.newUser() |
61 | 64 |
POST /-_-api/v1/owners/:owner/projects controllers.api.ProjectApi.newProject(owner:String) |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?