@@ -17,6 +17,10 @@ class KanbanBoard(Document): | |||||
def on_update(self): | def on_update(self): | ||||
frappe.clear_cache(doctype=self.reference_doctype) | frappe.clear_cache(doctype=self.reference_doctype) | ||||
def before_insert(self): | |||||
for column in self.columns: | |||||
column.order = get_order_for_column(self, column.column_name) | |||||
def validate_column_name(self): | def validate_column_name(self): | ||||
for column in self.columns: | for column in self.columns: | ||||
if not column.column_name: | if not column.column_name: | ||||
@@ -125,6 +129,53 @@ def update_order(board_name, order): | |||||
board.save() | board.save() | ||||
return board, updated_cards | return board, updated_cards | ||||
@frappe.whitelist() | |||||
def update_order_for_single_card(board_name, docname, from_colname, to_colname, old_index, new_index): | |||||
'''Save the order of cards in columns''' | |||||
board = frappe.get_doc('Kanban Board', board_name) | |||||
doctype = board.reference_doctype | |||||
fieldname = board.field_name | |||||
old_index = frappe.parse_json(old_index) | |||||
new_index = frappe.parse_json(new_index) | |||||
# save current order and index of columns to be updated | |||||
from_col_order, from_col_idx = get_kanban_column_order_and_index(board, from_colname) | |||||
to_col_order, to_col_idx = get_kanban_column_order_and_index(board, to_colname) | |||||
if from_colname == to_colname: | |||||
from_col_order = to_col_order | |||||
to_col_order.insert(new_index, from_col_order.pop((old_index))) | |||||
# save updated order | |||||
board.columns[from_col_idx].order = frappe.as_json(from_col_order) | |||||
board.columns[to_col_idx].order = frappe.as_json(to_col_order) | |||||
board.save() | |||||
# update changed value in doc | |||||
frappe.set_value(doctype, docname, fieldname, to_colname) | |||||
return board | |||||
def get_kanban_column_order_and_index(board, colname): | |||||
for i, col in enumerate(board.columns): | |||||
if col.column_name == colname: | |||||
col_order = frappe.parse_json(col.order) | |||||
col_idx = i | |||||
return col_order, col_idx | |||||
@frappe.whitelist() | |||||
def add_card(board_name, docname, colname): | |||||
board = frappe.get_doc('Kanban Board', board_name) | |||||
col_order, col_idx = get_kanban_column_order_and_index(board, colname) | |||||
col_order.insert(0, docname) | |||||
board.columns[col_idx].order = frappe.as_json(col_order) | |||||
board.save() | |||||
return board | |||||
@frappe.whitelist() | @frappe.whitelist() | ||||
def quick_kanban_board(doctype, board_name, field_name, project=None): | def quick_kanban_board(doctype, board_name, field_name, project=None): | ||||
@@ -133,6 +184,13 @@ def quick_kanban_board(doctype, board_name, field_name, project=None): | |||||
doc = frappe.new_doc('Kanban Board') | doc = frappe.new_doc('Kanban Board') | ||||
meta = frappe.get_meta(doctype) | meta = frappe.get_meta(doctype) | ||||
doc.kanban_board_name = board_name | |||||
doc.reference_doctype = doctype | |||||
doc.field_name = field_name | |||||
if project: | |||||
doc.filters = '[["Task","project","=","{0}"]]'.format(project) | |||||
options = '' | options = '' | ||||
for field in meta.fields: | for field in meta.fields: | ||||
if field.fieldname == field_name: | if field.fieldname == field_name: | ||||
@@ -149,12 +207,6 @@ def quick_kanban_board(doctype, board_name, field_name, project=None): | |||||
column_name=column | column_name=column | ||||
)) | )) | ||||
doc.kanban_board_name = board_name | |||||
doc.reference_doctype = doctype | |||||
doc.field_name = field_name | |||||
if project: | |||||
doc.filters = '[["Task","project","=","{0}"]]'.format(project) | |||||
if doctype in ['Note', 'ToDo']: | if doctype in ['Note', 'ToDo']: | ||||
doc.private = 1 | doc.private = 1 | ||||
@@ -162,6 +214,12 @@ def quick_kanban_board(doctype, board_name, field_name, project=None): | |||||
doc.save() | doc.save() | ||||
return doc | return doc | ||||
def get_order_for_column(board, colname): | |||||
filters = [[board.reference_doctype, board.field_name, '=', colname]] | |||||
if board.filters: | |||||
filters.append(frappe.parse_json(board.filters)[0]) | |||||
return frappe.as_json(frappe.get_list(board.reference_doctype, filters=filters, pluck='name')) | |||||
@frappe.whitelist() | @frappe.whitelist() | ||||
def update_column_order(board_name, order): | def update_column_order(board_name, order): | ||||
@@ -164,7 +164,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { | |||||
const match_rules_list = frappe.perm.get_match_rules(this.doctype); | const match_rules_list = frappe.perm.get_match_rules(this.doctype); | ||||
if (match_rules_list.length) { | if (match_rules_list.length) { | ||||
this.restricted_list = $( | this.restricted_list = $( | ||||
`<button class="btn btn-default btn-xs restricted-button flex align-center"> | |||||
`<button class="btn btn-xs restricted-button flex align-center"> | |||||
${frappe.utils.icon('restriction', 'xs')} | ${frappe.utils.icon('restriction', 'xs')} | ||||
</button>` | </button>` | ||||
).click(() => this.show_restrictions(match_rules_list)).appendTo(this.page.page_form); | ).click(() => this.show_restrictions(match_rules_list)).appendTo(this.page.page_form); | ||||
@@ -676,7 +676,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { | |||||
if (col.type === "Tag") { | if (col.type === "Tag") { | ||||
const tags_display_class = !this.tags_shown ? 'hide' : ''; | const tags_display_class = !this.tags_shown ? 'hide' : ''; | ||||
let tags_html = doc._user_tags ? this.get_tags_html(doc._user_tags) : '<div class="tags-empty">-</div>'; | |||||
let tags_html = doc._user_tags ? this.get_tags_html(doc._user_tags, 2) : '<div class="tags-empty">-</div>'; | |||||
return ` | return ` | ||||
<div class="list-row-col tag-col ${tags_display_class} hidden-xs ellipsis"> | <div class="list-row-col tag-col ${tags_display_class} hidden-xs ellipsis"> | ||||
${tags_html} | ${tags_html} | ||||
@@ -790,13 +790,19 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { | |||||
`; | `; | ||||
} | } | ||||
get_tags_html(user_tags) { | |||||
get_tags_html(user_tags, limit, colored=false) { | |||||
let get_tag_html = tag => { | let get_tag_html = tag => { | ||||
let color = '', style = ''; | |||||
if (tag) { | if (tag) { | ||||
return `<div class="tag-pill ellipsis" title="${tag}">${tag}</div>`; | |||||
if (colored) { | |||||
color = frappe.get_palette(tag); | |||||
style = `background-color: var(${color[0]}); color: var(${color[1]})`; | |||||
} | |||||
return `<div class="tag-pill ellipsis" title="${tag}" style="${style}">${tag}</div>`; | |||||
} | } | ||||
}; | }; | ||||
return user_tags.split(',').slice(1, 3).map(get_tag_html).join(''); | |||||
return user_tags.split(',').slice(1, limit + 1).map(get_tag_html).join(''); | |||||
} | } | ||||
get_meta_html(doc) { | get_meta_html(doc) { | ||||
@@ -114,6 +114,7 @@ frappe.ui.Page = Class.extend({ | |||||
this.get_main_icon(this.icon); | this.get_main_icon(this.icon); | ||||
this.body = this.main = this.wrapper.find(".layout-main-section"); | this.body = this.main = this.wrapper.find(".layout-main-section"); | ||||
this.container = this.wrapper.find(".page-body"); | |||||
this.sidebar = this.wrapper.find(".layout-side-section"); | this.sidebar = this.wrapper.find(".layout-side-section"); | ||||
this.footer = this.wrapper.find(".layout-footer"); | this.footer = this.wrapper.find(".layout-footer"); | ||||
this.indicator = this.wrapper.find(".indicator-pill"); | this.indicator = this.wrapper.find(".indicator-pill"); | ||||
@@ -124,7 +124,12 @@ frappe.provide("frappe.views"); | |||||
const new_cards = state.cards.slice(); | const new_cards = state.cards.slice(); | ||||
new_cards[index] = card; | new_cards[index] = card; | ||||
updater.set({ cards: new_cards }); | updater.set({ cards: new_cards }); | ||||
fluxify.doAction('update_order'); | |||||
const args = { | |||||
new: 1, | |||||
name: card.name, | |||||
colname: updated_doc[state.board.field_name], | |||||
}; | |||||
fluxify.doAction('update_order_for_single_card', args); | |||||
}); | }); | ||||
} else { | } else { | ||||
frappe.new_doc(this.doctype, doc); | frappe.new_doc(this.doctype, doc); | ||||
@@ -155,6 +160,53 @@ frappe.provide("frappe.views"); | |||||
fluxify.doAction('update_card', updated_card); | fluxify.doAction('update_card', updated_card); | ||||
}); | }); | ||||
}, | }, | ||||
update_order_for_single_card: function(updater, card) { | |||||
// cache original order | |||||
const _cards = this.cards.slice(); | |||||
const _columns = this.columns.slice(); | |||||
let args = {}; | |||||
let method_name = ""; | |||||
if (card.new) { | |||||
method_name = "add_card"; | |||||
args = { | |||||
board_name: this.board.name, | |||||
docname: card.name, | |||||
colname: card.colname, | |||||
}; | |||||
} else { | |||||
method_name = "update_order_for_single_card"; | |||||
args = { | |||||
board_name: this.board.name, | |||||
docname: card.name, | |||||
from_colname: card.from_colname, | |||||
to_colname: card.to_colname, | |||||
old_index: card.old_index, | |||||
new_index: card.new_index, | |||||
}; | |||||
} | |||||
frappe.call({ | |||||
method: method_prefix + method_name, | |||||
args: args, | |||||
callback: (r) => { | |||||
let board = r.message; | |||||
let updated_cards = [{'name': card.name, 'column': card.to_colname || card.colname}]; | |||||
let cards = update_cards_column(updated_cards); | |||||
let columns = prepare_columns(board.columns); | |||||
updater.set({ | |||||
cards: cards, | |||||
columns: columns | |||||
}); | |||||
} | |||||
}).fail(function() { | |||||
// revert original order | |||||
updater.set({ | |||||
cards: _cards, | |||||
columns: _columns | |||||
}); | |||||
}); | |||||
}, | |||||
update_order: function(updater) { | update_order: function(updater) { | ||||
// cache original order | // cache original order | ||||
const _cards = this.cards.slice(); | const _cards = this.cards.slice(); | ||||
@@ -446,16 +498,24 @@ frappe.provide("frappe.views"); | |||||
group: "cards", | group: "cards", | ||||
animation: 150, | animation: 150, | ||||
dataIdAttr: 'data-name', | dataIdAttr: 'data-name', | ||||
forceFallback: true, | |||||
onStart: function() { | onStart: function() { | ||||
wrapper.find('.kanban-card.add-card').fadeOut(200, function() { | wrapper.find('.kanban-card.add-card').fadeOut(200, function() { | ||||
wrapper.find('.kanban-cards').height('100vh'); | wrapper.find('.kanban-cards').height('100vh'); | ||||
}); | }); | ||||
}, | }, | ||||
onEnd: function() { | |||||
onEnd: function(e) { | |||||
wrapper.find('.kanban-card.add-card').fadeIn(100); | wrapper.find('.kanban-card.add-card').fadeIn(100); | ||||
wrapper.find('.kanban-cards').height('auto'); | wrapper.find('.kanban-cards').height('auto'); | ||||
// update order | // update order | ||||
fluxify.doAction('update_order'); | |||||
const args = { | |||||
name: $(e.item).attr('data-name'), | |||||
from_colname: $(e.from).parents('.kanban-column').attr('data-column-value'), | |||||
to_colname: $(e.to).parents('.kanban-column').attr('data-column-value'), | |||||
old_index: e.oldIndex, | |||||
new_index: e.newIndex, | |||||
}; | |||||
fluxify.doAction('update_order_for_single_card', args); | |||||
}, | }, | ||||
onAdd: function() { | onAdd: function() { | ||||
}, | }, | ||||
@@ -546,14 +606,24 @@ frappe.provide("frappe.views"); | |||||
var opts = { | var opts = { | ||||
name: card.name, | name: card.name, | ||||
title: remove_img_tags(card.title), | title: remove_img_tags(card.title), | ||||
disable_click: card._disable_click ? 'disable-click' : '' | |||||
disable_click: card._disable_click ? 'disable-click' : '', | |||||
creation: card.creation, | |||||
}; | }; | ||||
self.$card = $(frappe.render_template('kanban_card', opts)) | self.$card = $(frappe.render_template('kanban_card', opts)) | ||||
.appendTo(wrapper); | .appendTo(wrapper); | ||||
} | } | ||||
function get_tags_html(card) { | |||||
return card.tags | |||||
? `<div class="kanban-tags"> | |||||
${cur_list.get_tags_html(card.tags, 3, true)} | |||||
</div>` | |||||
: ''; | |||||
} | |||||
function render_card_meta() { | function render_card_meta() { | ||||
var html = ""; | |||||
let html = get_tags_html(card); | |||||
if (card.comment_count > 0) | if (card.comment_count > 0) | ||||
html += | html += | ||||
`<span class="list-comment-count small text-muted "> | `<span class="list-comment-count small text-muted "> | ||||
@@ -563,7 +633,10 @@ frappe.provide("frappe.views"); | |||||
const $assignees_group = get_assignees_group(); | const $assignees_group = get_assignees_group(); | ||||
html += `<span class="kanban-assignments"></span>`; | |||||
html += ` | |||||
<span class="kanban-assignments"></span> | |||||
${cur_list.get_like_html(card)} | |||||
`; | |||||
if (card.color && frappe.ui.color.validate_hex(card.color)) { | if (card.color && frappe.ui.color.validate_hex(card.color)) { | ||||
const $div = $('<div>'); | const $div = $('<div>'); | ||||
@@ -630,6 +703,8 @@ frappe.provide("frappe.views"); | |||||
doctype: state.doctype, | doctype: state.doctype, | ||||
name: card.name, | name: card.name, | ||||
title: card[state.card_meta.title_field.fieldname], | title: card[state.card_meta.title_field.fieldname], | ||||
creation: moment(card.creation).format('MMM DD, YYYY'), | |||||
tags: card._user_tags, | |||||
column: card[state.board.field_name], | column: card[state.board.field_name], | ||||
assigned_list: card.assigned_list || assigned_list, | assigned_list: card.assigned_list || assigned_list, | ||||
comment_count: card.comment_count || comment_count, | comment_count: card.comment_count || comment_count, | ||||
@@ -2,9 +2,12 @@ | |||||
<a class="kanban-card-redirect" href="#"> | <a class="kanban-card-redirect" href="#"> | ||||
<div class="kanban-card content"> | <div class="kanban-card content"> | ||||
<div class="kanban-title-area"> | <div class="kanban-title-area"> | ||||
<div class="kanban-card-title ellipsis" title="{{title}}"> | |||||
<div class="kanban-card-title" title="{{title}}"> | |||||
{{ title }} | {{ title }} | ||||
</div> | </div> | ||||
<div class="kanban-card-creation"> | |||||
{{ creation }} | |||||
</div> | |||||
</div> | </div> | ||||
<div class="kanban-card-meta"> | <div class="kanban-card-meta"> | ||||
</div> | </div> | ||||
@@ -73,7 +73,11 @@ frappe.views.KanbanView = class KanbanView extends frappe.views.ListView { | |||||
} | } | ||||
setup_view() { | setup_view() { | ||||
if (this.board.columns.length > 5) { | |||||
this.page.container.addClass('full-width'); | |||||
} | |||||
this.setup_realtime_updates(); | this.setup_realtime_updates(); | ||||
this.setup_like(); | |||||
} | } | ||||
set_fields() { | set_fields() { | ||||
@@ -15,7 +15,7 @@ | |||||
.kanban { | .kanban { | ||||
display: flex; | display: flex; | ||||
overflow: auto; | |||||
overflow-y: hidden; | |||||
-ms-overflow-style: none; /* IE and Edge */ | -ms-overflow-style: none; /* IE and Edge */ | ||||
scrollbar-width: none; /* Firefox */ | scrollbar-width: none; /* Firefox */ | ||||
@@ -32,19 +32,7 @@ | |||||
border-radius: var(--border-radius); | border-radius: var(--border-radius); | ||||
padding: var(--padding-md); | padding: var(--padding-md); | ||||
min-height: calc(100vh - 250px); | min-height: calc(100vh - 250px); | ||||
&.add-new-column { | |||||
order: 1; | |||||
} | |||||
&:hover { | |||||
.add-card { | |||||
background-color: var(--kanban-new-card-hover-bg); | |||||
box-shadow: var(--shadow-xs); | |||||
} | |||||
background-color: var(--kanban-new-card-bg); | |||||
} | |||||
max-height: calc(75vh - 10px); | |||||
.add-card { | .add-card { | ||||
@include flex(flex, center, center, null); | @include flex(flex, center, center, null); | ||||
@@ -70,6 +58,17 @@ | |||||
} | } | ||||
} | } | ||||
.kanban-column:not(.add-new-column) { | |||||
&:hover { | |||||
.add-card { | |||||
background-color: var(--kanban-new-card-hover-bg); | |||||
box-shadow: var(--shadow-xs); | |||||
} | |||||
background-color: var(--kanban-new-card-bg); | |||||
} | |||||
} | |||||
.kanban-column-header { | .kanban-column-header { | ||||
@include flex(flex, space-between, null, null); | @include flex(flex, space-between, null, null); | ||||
margin-top: 0; | margin-top: 0; | ||||
@@ -138,7 +137,6 @@ | |||||
} | } | ||||
.kanban-cards { | .kanban-cards { | ||||
min-height: 100px; | |||||
max-height: calc(100vh - 250px); | max-height: calc(100vh - 250px); | ||||
margin: -5px; | margin: -5px; | ||||
padding: 5px; | padding: 5px; | ||||
@@ -191,10 +189,20 @@ | |||||
} | } | ||||
} | } | ||||
.kanban-card-title { | |||||
max-width: 90%; | |||||
font-size: $font-size-base; | |||||
font-weight: 500; | |||||
.kanban-title-area { | |||||
margin-bottom: 12px; | |||||
.kanban-card-title { | |||||
max-width: 90%; | |||||
font-size: var(--text-md); | |||||
font-weight: 500; | |||||
} | |||||
.kanban-card-creation { | |||||
font-size: var(--text-md); | |||||
color: var(--text-muted); | |||||
margin-top: var(--margin-xs); | |||||
} | |||||
} | } | ||||
.kanban-card-edit { | .kanban-card-edit { | ||||
@@ -232,28 +240,71 @@ | |||||
} | } | ||||
} | } | ||||
.add-new-column { | |||||
display: flex; | |||||
justify-content: center; | |||||
align-items: center; | |||||
min-height: 65px; | |||||
.kanban-column.add-new-column { | |||||
color: var(--text-muted); | color: var(--text-muted); | ||||
border: 1px dashed var(--gray-400); | |||||
max-height: 80px; | |||||
background-color: transparent; | background-color: transparent; | ||||
} | |||||
order: 1; | |||||
.add-new-column:hover { | |||||
background-color: var(--kanban-column-bg); | |||||
&:hover { | |||||
background-color: none; | |||||
} | |||||
.kanban-column-title.compose-column { | |||||
@include flex(flex, center, center, null); | |||||
min-height: 65px; | |||||
border-radius: var(--border-radius); | |||||
border: 1px dashed var(--gray-400); | |||||
font-size: var(--text-base); | |||||
&:hover { | |||||
background-color: var(--kanban-column-bg); | |||||
} | |||||
} | |||||
} | } | ||||
.kanban-card-meta { | .kanban-card-meta { | ||||
.list-comment-count { | |||||
width: 30px; | |||||
} | |||||
.like-action:not(.liked) { | |||||
.icon use { | |||||
stroke: var(--text-muted); | |||||
} | |||||
} | |||||
.kanban-tags { | |||||
font-size: var(--text-sm); | |||||
margin-bottom: 8px; | |||||
.tag-pill { | |||||
border-radius: 100px; | |||||
height: 22px; | |||||
width: auto; | |||||
padding: 2px 8px; | |||||
margin-right: var(--margin-xs); | |||||
} | |||||
} | |||||
.kanban-assignments { | .kanban-assignments { | ||||
display: flex; | display: flex; | ||||
float: right; | float: right; | ||||
.avatar { | .avatar { | ||||
cursor: default; | cursor: default; | ||||
width: 22px; | |||||
height: 22px; | |||||
} | |||||
.avatar-action { | |||||
width: 22px; | |||||
height: 22px; | |||||
.icon { | |||||
width: 12px; | |||||
height: 12px; | |||||
} | |||||
} | } | ||||
} | } | ||||
} | } | ||||
@@ -44,6 +44,11 @@ | |||||
.page-container { | .page-container { | ||||
background-color: var(--bg-color); | background-color: var(--bg-color); | ||||
.page-body.full-width { | |||||
width: 100%; | |||||
max-width: 100%; | |||||
} | |||||
} | } | ||||
.custom-actions { | .custom-actions { | ||||