Fix Actions (#2132)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2022-06-24 19:36:08 +07:00 committed by GitHub
parent 82c8d70d37
commit d935e9b963
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 1219 additions and 356 deletions

View File

@ -2,15 +2,23 @@
## 0.6.29 (upcoming) ## 0.6.29 (upcoming)
Platform:
- Object selector actions
Tracker: Tracker:
- Remember view options - Remember view options
- My issues - My issues
- Roadmap - Roadmap
- Remember view options
- Context menus (Priority/Status/Assignee)
Chunter: Chunter:
- Reactions on messages - Reactions on messages
- Priority filter
- Context menu selector for state/assignee
HR: HR:
@ -27,7 +35,6 @@ Tracker:
- Issue state history. - Issue state history.
- Subissue issue popup. - Subissue issue popup.
- Label support - Label support
- Priority filter
Lead: Lead:

View File

@ -114,7 +114,8 @@ export function createModel (builder: Builder): void {
category: attachment.category.Attachments, category: attachment.category.Attachments,
target: attachment.class.Attachment, target: attachment.class.Attachment,
context: { context: {
mode: ['context', 'browser'] mode: ['context', 'browser'],
group: 'edit'
} }
}) })
@ -133,7 +134,8 @@ export function createModel (builder: Builder): void {
category: attachment.category.Attachments, category: attachment.category.Attachments,
target: attachment.class.Attachment, target: attachment.class.Attachment,
context: { context: {
mode: ['context', 'browser'] mode: ['context', 'browser'],
group: 'edit'
} }
}) })
@ -152,7 +154,8 @@ export function createModel (builder: Builder): void {
category: attachment.category.Attachments, category: attachment.category.Attachments,
target: attachment.class.Attachment, target: attachment.class.Attachment,
context: { context: {
mode: ['context', 'browser'] mode: ['context', 'browser'],
group: 'edit'
} }
}) })
} }

View File

@ -283,7 +283,7 @@ export function createModel (builder: Builder): void {
input: 'any', input: 'any',
category: board.category.Card, category: board.category.Card,
target: board.class.Card, target: board.class.Card,
context: { mode: 'context', application: board.app.Board, group: 'top' } context: { mode: 'context', application: board.app.Board, group: 'create' }
}, },
board.action.Open board.action.Open
) )
@ -294,7 +294,9 @@ export function createModel (builder: Builder): void {
actionProps: { actionProps: {
component: tags.component.TagsEditorPopup, component: tags.component.TagsEditorPopup,
element: view.popup.PositionElementAlignment, element: view.popup.PositionElementAlignment,
value: 'object' fillProps: {
_object: 'value'
}
}, },
label: board.string.Labels, label: board.string.Labels,
icon: tags.icon.Tags, icon: tags.icon.Tags,
@ -302,7 +304,7 @@ export function createModel (builder: Builder): void {
inline: true, inline: true,
category: board.category.Card, category: board.category.Card,
target: board.class.Card, target: board.class.Card,
context: { mode: 'context', application: board.app.Board, group: 'top' } context: { mode: 'context', application: board.app.Board, group: 'create' }
}, },
board.action.Labels board.action.Labels
) )
@ -320,7 +322,7 @@ export function createModel (builder: Builder): void {
inline: true, inline: true,
category: board.category.Card, category: board.category.Card,
target: board.class.Card, target: board.class.Card,
context: { mode: 'context', application: board.app.Board, group: 'top' } context: { mode: 'context', application: board.app.Board, group: 'create' }
}, },
board.action.Dates board.action.Dates
) )
@ -331,7 +333,9 @@ export function createModel (builder: Builder): void {
actionProps: { actionProps: {
component: board.component.CoverActionPopup, component: board.component.CoverActionPopup,
element: view.popup.PositionElementAlignment, element: view.popup.PositionElementAlignment,
value: 'object' fillProps: {
_object: 'value'
}
}, },
label: board.string.Cover, label: board.string.Cover,
icon: board.icon.Card, icon: board.icon.Card,
@ -339,7 +343,7 @@ export function createModel (builder: Builder): void {
inline: true, inline: true,
category: board.category.Card, category: board.category.Card,
target: board.class.Card, target: board.class.Card,
context: { mode: 'context', application: board.app.Board, group: 'top' } context: { mode: 'context', application: board.app.Board, group: 'create' }
}, },
board.action.Cover board.action.Cover
) )

View File

@ -176,7 +176,8 @@ export function createModel (builder: Builder): void {
category: calendar.category.Calendar, category: calendar.category.Calendar,
target: calendar.class.Event, target: calendar.class.Event,
context: { context: {
mode: 'context' mode: 'context',
group: 'create'
} }
}, },
calendar.action.SaveEventReminder calendar.action.SaveEventReminder

View File

@ -238,7 +238,8 @@ export function createModel (builder: Builder): void {
category: chunter.category.Chunter, category: chunter.category.Chunter,
target: chunter.class.Message, target: chunter.class.Message,
context: { context: {
mode: 'context' mode: 'context',
group: 'edit'
} }
}, },
chunter.action.MarkUnread chunter.action.MarkUnread
@ -253,7 +254,8 @@ export function createModel (builder: Builder): void {
category: chunter.category.Chunter, category: chunter.category.Chunter,
target: chunter.class.ThreadMessage, target: chunter.class.ThreadMessage,
context: { context: {
mode: 'context' mode: 'context',
group: 'edit'
} }
}, },
chunter.action.MarkCommentUnread chunter.action.MarkCommentUnread
@ -272,7 +274,8 @@ export function createModel (builder: Builder): void {
archived: false archived: false
}, },
context: { context: {
mode: 'context' mode: 'context',
group: 'tools'
} }
}, },
chunter.action.ArchiveChannel chunter.action.ArchiveChannel
@ -291,7 +294,8 @@ export function createModel (builder: Builder): void {
archived: true archived: true
}, },
context: { context: {
mode: 'context' mode: 'context',
group: 'tools'
} }
}, },
chunter.action.UnarchiveChannel chunter.action.UnarchiveChannel
@ -307,7 +311,8 @@ export function createModel (builder: Builder): void {
category: chunter.category.Chunter, category: chunter.category.Chunter,
target: chunter.class.DirectMessage, target: chunter.class.DirectMessage,
context: { context: {
mode: 'context' mode: 'context',
group: 'edit'
} }
}, },
chunter.action.ConvertToPrivate chunter.action.ConvertToPrivate

View File

@ -388,7 +388,10 @@ export function createModel (builder: Builder): void {
createAction(builder, { createAction(builder, {
...actionTemplates.open, ...actionTemplates.open,
target: contact.class.Contact, target: contact.class.Contact,
context: { mode: ['browser', 'context'] } context: {
mode: ['browser', 'context'],
group: 'create'
}
}) })
} }

View File

@ -270,7 +270,7 @@ export function createModel (builder: Builder): void {
input: 'any', input: 'any',
category: hr.category.HR, category: hr.category.HR,
target: hr.class.Department, target: hr.class.Department,
context: { mode: 'context', application: hr.app.HR, group: 'top' } context: { mode: 'context', application: hr.app.HR, group: 'create' }
}, },
hr.action.EditDepartment hr.action.EditDepartment
) )
@ -289,7 +289,7 @@ export function createModel (builder: Builder): void {
_id: { $nin: [hr.ids.Head] } _id: { $nin: [hr.ids.Head] }
}, },
target: hr.class.Department, target: hr.class.Department,
context: { mode: 'context', application: hr.app.HR, group: 'top' } context: { mode: 'context', application: hr.app.HR, group: 'create' }
}, },
hr.action.DeleteDepartment hr.action.DeleteDepartment
) )
@ -305,7 +305,7 @@ export function createModel (builder: Builder): void {
input: 'any', input: 'any',
category: hr.category.HR, category: hr.category.HR,
target: hr.class.Request, target: hr.class.Request,
context: { mode: 'context', application: hr.app.HR, group: 'top' } context: { mode: 'context', application: hr.app.HR, group: 'create' }
}, },
hr.action.EditRequest hr.action.EditRequest
) )

View File

@ -160,7 +160,8 @@ export function createModel (builder: Builder): void {
category: inventory.category.Inventory, category: inventory.category.Inventory,
target: inventory.class.Category, target: inventory.class.Category,
context: { context: {
mode: ['context', 'browser'] mode: ['context', 'browser'],
group: 'associate'
} }
}) })
} }

View File

@ -22,8 +22,8 @@ import attachment from '@anticrm/model-attachment'
import chunter from '@anticrm/model-chunter' import chunter from '@anticrm/model-chunter'
import contact, { TContact } from '@anticrm/model-contact' import contact, { TContact } from '@anticrm/model-contact'
import core from '@anticrm/model-core' import core from '@anticrm/model-core'
import task, { TSpaceWithStates, TTask } from '@anticrm/model-task' import task, { actionTemplates, TSpaceWithStates, TTask } from '@anticrm/model-task'
import view, { createAction } from '@anticrm/model-view' import view, { createAction, actionTemplates as viewTemplates } from '@anticrm/model-view'
import workbench, { Application } from '@anticrm/model-workbench' import workbench, { Application } from '@anticrm/model-workbench'
import setting from '@anticrm/setting' import setting from '@anticrm/setting'
import lead from './plugin' import lead from './plugin'
@ -67,6 +67,8 @@ export class TCustomer extends TContact implements Customer {
} }
export function createModel (builder: Builder): void { export function createModel (builder: Builder): void {
const archiveId = 'archive'
builder.createModel(TFunnel, TLead, TCustomer) builder.createModel(TFunnel, TLead, TCustomer)
builder.mixin(lead.class.Funnel, core.class.Class, workbench.mixin.SpaceView, { builder.mixin(lead.class.Funnel, core.class.Class, workbench.mixin.SpaceView, {
@ -113,6 +115,15 @@ export function createModel (builder: Builder): void {
label: lead.string.Customers label: lead.string.Customers
}, },
position: 'top' position: 'top'
},
{
id: archiveId,
component: workbench.component.Archive,
icon: view.icon.Archive,
label: workbench.string.Archive,
position: 'bottom',
visibleIf: workbench.function.HasArchiveSpaces,
spaceClass: lead.class.Funnel
} }
], ],
spaces: [ spaces: [
@ -129,6 +140,22 @@ export function createModel (builder: Builder): void {
lead.app.Lead lead.app.Lead
) )
createAction(builder, { ...actionTemplates.archiveSpace, target: lead.class.Funnel })
createAction(builder, { ...actionTemplates.unarchiveSpace, target: lead.class.Funnel })
createAction(builder, {
...viewTemplates.open,
target: lead.class.Funnel,
context: {
mode: ['browser', 'context'],
group: 'create'
},
action: workbench.actionImpl.Navigate,
actionProps: {
mode: 'space'
}
})
builder.createDoc( builder.createDoc(
view.class.Viewlet, view.class.Viewlet,
core.space.Model, core.space.Model,
@ -248,6 +275,9 @@ export function createModel (builder: Builder): void {
element: 'top', element: 'top',
props: { props: {
preserveCustomer: true preserveCustomer: true
},
fillProps: {
_id: 'customer'
} }
}, },
label: lead.string.CreateLead, label: lead.string.CreateLead,
@ -255,7 +285,10 @@ export function createModel (builder: Builder): void {
input: 'focus', input: 'focus',
category: lead.category.Lead, category: lead.category.Lead,
target: contact.class.Contact, target: contact.class.Contact,
context: { mode: ['context', 'browser'] }, context: {
mode: ['context', 'browser'],
group: 'associate'
},
override: [lead.action.CreateGlobalLead] override: [lead.action.CreateGlobalLead]
}) })
@ -275,7 +308,8 @@ export function createModel (builder: Builder): void {
target: core.class.Doc, target: core.class.Doc,
context: { context: {
mode: ['workbench', 'browser'], mode: ['workbench', 'browser'],
application: lead.app.Lead application: lead.app.Lead,
group: 'create'
} }
}, },
lead.action.CreateGlobalLead lead.action.CreateGlobalLead

View File

@ -394,10 +394,12 @@ export function createModel (builder: Builder): void {
action: view.actionImpl.ShowPopup, action: view.actionImpl.ShowPopup,
actionProps: { actionProps: {
component: recruit.component.CreateApplication, component: recruit.component.CreateApplication,
_id: 'candidate',
element: 'top', element: 'top',
props: { props: {
preserveCandidate: true preserveCandidate: true
},
fillProps: {
_id: 'candidate'
} }
}, },
label: recruit.string.CreateAnApplication, label: recruit.string.CreateAnApplication,
@ -405,7 +407,10 @@ export function createModel (builder: Builder): void {
input: 'focus', input: 'focus',
category: recruit.category.Recruit, category: recruit.category.Recruit,
target: contact.class.Person, target: contact.class.Person,
context: { mode: ['context', 'browser'] }, context: {
mode: ['context', 'browser'],
group: 'associate'
},
override: [recruit.action.CreateGlobalApplication] override: [recruit.action.CreateGlobalApplication]
}) })
createAction(builder, { createAction(builder, {
@ -422,7 +427,8 @@ export function createModel (builder: Builder): void {
target: core.class.Doc, target: core.class.Doc,
context: { context: {
mode: ['workbench', 'browser'], mode: ['workbench', 'browser'],
application: recruit.app.Recruit application: recruit.app.Recruit,
group: 'create'
} }
}) })
@ -440,7 +446,8 @@ export function createModel (builder: Builder): void {
target: core.class.Doc, target: core.class.Doc,
context: { context: {
mode: ['workbench', 'browser'], mode: ['workbench', 'browser'],
application: recruit.app.Recruit application: recruit.app.Recruit,
group: 'create'
} }
}) })
@ -460,7 +467,8 @@ export function createModel (builder: Builder): void {
target: core.class.Doc, target: core.class.Doc,
context: { context: {
mode: ['workbench', 'browser'], mode: ['workbench', 'browser'],
application: recruit.app.Recruit application: recruit.app.Recruit,
group: 'create'
} }
}, },
recruit.action.CreateGlobalApplication recruit.action.CreateGlobalApplication
@ -504,7 +512,8 @@ export function createModel (builder: Builder): void {
keyBinding: ['e'], keyBinding: ['e'],
target: recruit.class.Vacancy, target: recruit.class.Vacancy,
context: { context: {
mode: ['context', 'browser'] mode: ['context', 'browser'],
group: 'create'
} }
}) })
@ -526,12 +535,13 @@ export function createModel (builder: Builder): void {
createReviewModel(builder) createReviewModel(builder)
// createAction(builder, { ...viewTemplates.open, target: recruit.class.Vacancy, context: { mode: ['browser', 'context'] } })
createAction(builder, { createAction(builder, {
...viewTemplates.open, ...viewTemplates.open,
target: recruit.class.Vacancy, target: recruit.class.Vacancy,
context: { mode: ['browser', 'context'] }, context: {
mode: ['browser', 'context'],
group: 'create'
},
action: workbench.actionImpl.Navigate, action: workbench.actionImpl.Navigate,
actionProps: { actionProps: {
mode: 'space' mode: 'space'
@ -541,7 +551,10 @@ export function createModel (builder: Builder): void {
createAction(builder, { createAction(builder, {
...viewTemplates.open, ...viewTemplates.open,
target: recruit.class.Applicant, target: recruit.class.Applicant,
context: { mode: ['browser', 'context'] } context: {
mode: ['browser', 'context'],
group: 'create'
}
}) })
function createGotoSpecialAction (builder: Builder, id: string, key: KeyBinding, label: IntlString): void { function createGotoSpecialAction (builder: Builder, id: string, key: KeyBinding, label: IntlString): void {
@ -574,6 +587,55 @@ export function createModel (builder: Builder): void {
mode: ['workbench', 'browser', 'editor', 'panel', 'popup'] mode: ['workbench', 'browser', 'editor', 'panel', 'popup']
} }
}) })
createAction(builder, {
action: view.actionImpl.ValueSelector,
actionPopup: view.component.ValueSelector,
actionProps: {
attribute: 'assignee',
_class: contact.class.Employee,
query: {},
placeholder: recruit.string.AssignRecruiter
},
label: recruit.string.AssignRecruiter,
icon: contact.icon.Person,
keyBinding: [],
input: 'none',
category: recruit.category.Recruit,
target: recruit.class.Applicant,
context: {
mode: ['context'],
application: recruit.app.Recruit,
group: 'edit'
}
})
createAction(builder, {
action: view.actionImpl.ValueSelector,
actionPopup: view.component.ValueSelector,
actionProps: {
attribute: 'state',
_class: task.class.State,
query: {},
searchField: 'title',
// should match space
fillQuery: { space: 'space' },
// Only apply for same vacancy
docMatches: ['space'],
placeholder: task.string.TaskState
},
label: task.string.TaskState,
icon: task.icon.TaskState,
keyBinding: [],
input: 'none',
category: recruit.category.Recruit,
target: recruit.class.Applicant,
context: {
mode: ['context'],
application: recruit.app.Recruit,
group: 'edit'
}
})
} }
export { recruitOperation } from './migration' export { recruitOperation } from './migration'

View File

@ -49,7 +49,8 @@ export function createReviewModel (builder: Builder): void {
category: recruit.category.Recruit, category: recruit.category.Recruit,
target: recruit.class.Review, target: recruit.class.Review,
context: { context: {
mode: ['context', 'browser'] mode: ['context', 'browser'],
group: 'create'
} }
}, },
recruit.action.CreateOpinion recruit.action.CreateOpinion
@ -75,11 +76,13 @@ export function createReviewModel (builder: Builder): void {
action: view.actionImpl.ShowPopup, action: view.actionImpl.ShowPopup,
actionProps: { actionProps: {
component: recruit.component.CreateReview, component: recruit.component.CreateReview,
_id: 'candidate',
_space: 'space',
element: 'top', element: 'top',
props: { props: {
preserveCandidate: true preserveCandidate: true
},
fillProps: {
space: '_space',
_id: 'candidate'
} }
}, },
label: recruit.string.CreateReview, label: recruit.string.CreateReview,
@ -88,7 +91,8 @@ export function createReviewModel (builder: Builder): void {
category: recruit.category.Recruit, category: recruit.category.Recruit,
target: recruit.mixin.Candidate, target: recruit.mixin.Candidate,
context: { context: {
mode: ['context', 'browser'] mode: ['context', 'browser'],
group: 'associate'
} }
}) })

View File

@ -281,7 +281,8 @@ export const actionTemplates = template({
archived: false archived: false
}, },
context: { context: {
mode: ['context', 'browser'] mode: ['context', 'browser'],
group: 'tools'
} }
}, },
unarchiveSpace: { unarchiveSpace: {
@ -301,7 +302,8 @@ export const actionTemplates = template({
archived: true archived: true
}, },
context: { context: {
mode: ['context', 'browser'] mode: ['context', 'browser'],
group: 'tools'
} }
} }
}) })
@ -427,7 +429,8 @@ export function createModel (builder: Builder): void {
archived: false archived: false
}, },
context: { context: {
mode: ['context', 'browser'] mode: ['context', 'browser'],
group: 'edit'
} }
}, },
task.action.EditStatuses task.action.EditStatuses
@ -487,7 +490,8 @@ export function createModel (builder: Builder): void {
}, },
target: task.class.TodoItem, target: task.class.TodoItem,
context: { context: {
mode: ['context', 'browser'] mode: ['context', 'browser'],
group: 'edit'
} }
}) })
@ -505,7 +509,8 @@ export function createModel (builder: Builder): void {
done: true done: true
}, },
context: { context: {
mode: ['context', 'browser'] mode: ['context', 'browser'],
group: 'edit'
}, },
target: task.class.TodoItem target: task.class.TodoItem
}) })
@ -516,7 +521,8 @@ export function createModel (builder: Builder): void {
...viewTemplates.move, ...viewTemplates.move,
target: task.class.Task, target: task.class.Task,
context: { context: {
mode: ['context', 'browser'] mode: ['context', 'browser'],
group: 'tools'
} }
}, },
task.action.Move task.action.Move
@ -542,7 +548,8 @@ export function createModel (builder: Builder): void {
category: task.category.Task, category: task.category.Task,
target: task.class.State, target: task.class.State,
context: { context: {
mode: ['context', 'browser'] mode: ['context', 'browser'],
group: 'tools'
} }
}, },
task.action.ArchiveState task.action.ArchiveState

View File

@ -15,7 +15,7 @@
import type { Employee } from '@anticrm/contact' import type { Employee } from '@anticrm/contact'
import contact from '@anticrm/contact' import contact from '@anticrm/contact'
import { Domain, DOMAIN_MODEL, IndexKind, Markup, Ref, Timestamp, Type } from '@anticrm/core' import { Domain, DOMAIN_MODEL, FindOptions, IndexKind, Markup, Ref, SortingOrder, Timestamp, Type } from '@anticrm/core'
import { import {
ArrOf, ArrOf,
Builder, Builder,
@ -55,6 +55,7 @@ import tags from '@anticrm/tags'
import tracker from './plugin' import tracker from './plugin'
import presentation from '@anticrm/model-presentation' import presentation from '@anticrm/model-presentation'
import { defaultPriorities, issuePriorities } from '@anticrm/tracker-resources/src/types'
export { trackerOperation } from './migration' export { trackerOperation } from './migration'
export { default } from './plugin' export { default } from './plugin'
@ -565,22 +566,25 @@ export function createModel (builder: Builder): void {
{ {
action: view.actionImpl.ShowPopup, action: view.actionImpl.ShowPopup,
actionProps: { actionProps: {
component: tracker.component.SetDueDateActionPopup, component: tracker.component.CreateIssue,
props: { mondayStart: true, withTime: false }, element: 'top',
element: 'top' fillProps: {
_object: 'parentIssue'
}
}, },
label: tracker.string.SetDueDate, label: tracker.string.NewSubIssue,
icon: tracker.icon.DueDate, icon: tracker.icon.Issue,
keyBinding: [], keyBinding: [],
input: 'none', input: 'focus',
category: tracker.category.Tracker, category: tracker.category.Tracker,
target: tracker.class.Issue, target: tracker.class.Issue,
context: { context: {
mode: ['context', 'browser'], mode: ['context', 'browser'],
application: tracker.app.Tracker application: tracker.app.Tracker,
group: 'associate'
} }
}, },
tracker.action.SetDueDate tracker.action.SetParent
) )
createAction( createAction(
@ -598,13 +602,35 @@ export function createModel (builder: Builder): void {
category: tracker.category.Tracker, category: tracker.category.Tracker,
target: tracker.class.Issue, target: tracker.class.Issue,
context: { context: {
mode: ['context', 'browser'], mode: ['context'],
application: tracker.app.Tracker application: tracker.app.Tracker,
group: 'associate'
} }
}, },
tracker.action.SetParent tracker.action.SetParent
) )
createAction(builder, {
action: view.actionImpl.ShowPopup,
actionPopup: tracker.component.SetParentIssueActionPopup,
actionProps: {
component: tracker.component.SetParentIssueActionPopup,
element: 'top'
},
label: tracker.string.SetParent,
icon: tracker.icon.Parent,
keyBinding: [],
input: 'none',
category: tracker.category.Tracker,
target: tracker.class.Issue,
override: [tracker.action.SetParent],
context: {
mode: ['browser'],
application: tracker.app.Tracker,
group: 'associate'
}
})
builder.mixin(tracker.class.Issue, core.class.Class, view.mixin.ClassFilters, { builder.mixin(tracker.class.Issue, core.class.Class, view.mixin.ClassFilters, {
filters: ['status', 'priority', 'assignee', 'project', 'dueDate', 'modifiedOn'] filters: ['status', 'priority', 'assignee', 'project', 'dueDate', 'modifiedOn']
}) })
@ -619,4 +645,139 @@ export function createModel (builder: Builder): void {
}, },
tracker.completion.IssueCategory tracker.completion.IssueCategory
) )
const statusOptions: FindOptions<IssueStatus> = {
lookup: {
category: tracker.class.IssueStatusCategory
},
sort: { rank: SortingOrder.Ascending }
}
createAction(
builder,
{
action: view.actionImpl.ValueSelector,
actionPopup: view.component.ValueSelector,
actionProps: {
attribute: 'status',
_class: tracker.class.IssueStatus,
placeholder: tracker.string.SetStatus,
fillQuery: {
space: 'space'
},
queryOptions: statusOptions
},
label: tracker.string.Status,
icon: tracker.icon.CategoryBacklog,
keyBinding: [],
input: 'none',
category: tracker.category.Tracker,
target: tracker.class.Issue,
context: {
mode: ['context'],
application: tracker.app.Tracker,
group: 'edit'
}
},
tracker.action.SetStatus
)
createAction(
builder,
{
action: view.actionImpl.ValueSelector,
actionPopup: view.component.ValueSelector,
actionProps: {
attribute: 'priority',
values: defaultPriorities.map((p) => ({ id: p, ...issuePriorities[p] })),
placeholder: tracker.string.SetPriority
},
label: tracker.string.Priority,
icon: tracker.icon.PriorityHigh,
keyBinding: [],
input: 'none',
category: tracker.category.Tracker,
target: tracker.class.Issue,
context: {
mode: ['context'],
application: tracker.app.Tracker,
group: 'edit'
}
},
tracker.action.SetPriority
)
createAction(
builder,
{
action: view.actionImpl.ValueSelector,
actionPopup: view.component.ValueSelector,
actionProps: {
attribute: 'assignee',
_class: contact.class.Employee,
query: {},
placeholder: tracker.string.AssignTo
},
label: tracker.string.Assignee,
icon: contact.icon.Person,
keyBinding: [],
input: 'none',
category: tracker.category.Tracker,
target: tracker.class.Issue,
context: {
mode: ['context'],
application: tracker.app.Tracker,
group: 'edit'
}
},
tracker.action.SetAssignee
)
createAction(
builder,
{
action: view.actionImpl.ValueSelector,
actionPopup: view.component.ValueSelector,
actionProps: {
attribute: 'project',
_class: tracker.class.Project,
query: {},
searchField: 'label',
placeholder: tracker.string.Project
},
label: tracker.string.Project,
icon: tracker.icon.Project,
keyBinding: [],
input: 'none',
category: tracker.category.Tracker,
target: tracker.class.Issue,
context: {
mode: ['context'],
application: tracker.app.Tracker,
group: 'edit'
}
},
tracker.action.SetProject
)
createAction(
builder,
{
action: view.actionImpl.ShowPopup,
actionProps: {
component: tracker.component.SetDueDateActionPopup,
props: { mondayStart: true, withTime: false },
element: 'top'
},
label: tracker.string.SetDueDate,
icon: tracker.icon.DueDate,
keyBinding: [],
input: 'none',
category: tracker.category.Tracker,
target: tracker.class.Issue,
context: {
mode: ['context', 'browser'],
application: tracker.app.Tracker,
group: 'edit'
}
},
tracker.action.SetDueDate
)
} }

View File

@ -19,7 +19,7 @@ import { ObjectSearchCategory, ObjectSearchFactory } from '@anticrm/model-presen
import { IntlString, mergeIds, Resource } from '@anticrm/platform' import { IntlString, mergeIds, Resource } from '@anticrm/platform'
import { trackerId } from '@anticrm/tracker' import { trackerId } from '@anticrm/tracker'
import tracker from '@anticrm/tracker-resources/src/plugin' import tracker from '@anticrm/tracker-resources/src/plugin'
import type { AnyComponent } from '@anticrm/ui' import type { AnyComponent } from '@anticrm/ui/src/types'
import { ViewletDescriptor } from '@anticrm/view' import { ViewletDescriptor } from '@anticrm/view'
import { Application } from '@anticrm/workbench' import { Application } from '@anticrm/workbench'

View File

@ -44,14 +44,13 @@
}, },
(result) => { (result) => {
objects = result objects = result
dispatch('content', objects)
}, },
{ {
...options ...options
} }
) )
$: dispatch('content', objects)
function getStateObjects ( function getStateObjects (
objects: Item[], objects: Item[],
state: TypeState, state: TypeState,

View File

@ -22,7 +22,9 @@
createFocusManager, createFocusManager,
EditBox, EditBox,
FocusHandler, FocusHandler,
Icon,
IconAdd, IconAdd,
IconCheck,
ListView, ListView,
showPopup, showPopup,
Tooltip Tooltip
@ -44,7 +46,8 @@
export let selectedObjects: Ref<Doc>[] = [] export let selectedObjects: Ref<Doc>[] = []
export let ignoreObjects: Ref<Doc>[] = [] export let ignoreObjects: Ref<Doc>[] = []
export let shadows: boolean = true export let shadows: boolean = true
export let width: 'medium' | 'large' = 'medium' export let width: 'medium' | 'large' | 'full' = 'medium'
export let size: 'small' | 'medium' | 'large' = 'small'
export let searchField: string = 'name' export let searchField: string = 'name'
@ -56,7 +59,7 @@
label: IntlString label: IntlString
update: (doc: Doc) => string update: (doc: Doc) => string
} }
| undefined | undefined = undefined
let search: string = '' let search: string = ''
let objects: Doc[] = [] let objects: Doc[] = []
@ -162,7 +165,13 @@
<FocusHandler {manager} /> <FocusHandler {manager} />
<div class="selectPopup" class:plainContainer={!shadows} class:width-40={width === 'large'} on:keydown={onKeydown}> <div
class="selectPopup"
class:full-width={width === 'full'}
class:plainContainer={!shadows}
class:width-40={width === 'large'}
on:keydown={onKeydown}
>
<div class="header flex-between"> <div class="header flex-between">
<EditBox kind={'search-style'} focusIndex={1} focus bind:value={search} {placeholder} /> <EditBox kind={'search-style'} focusIndex={1} focus bind:value={search} {placeholder} />
{#if create !== undefined} {#if create !== undefined}
@ -170,7 +179,7 @@
<Button <Button
focusIndex={2} focusIndex={2}
kind={'transparent'} kind={'transparent'}
size={'small'} {size}
icon={IconAdd} icon={IconAdd}
showTooltip={{ label: create.label }} showTooltip={{ label: create.label }}
on:click={onCreate} on:click={onCreate}
@ -200,23 +209,27 @@
handleSelection(undefined, objects, item) handleSelection(undefined, objects, item)
}} }}
> >
{#if multiSelect} {#if allowDeselect && selected}
<div class="check pointer-events-none"> <div class="icon">
<CheckBox checked={selectedElements.has(obj._id)} primary /> {#if obj._id === selected}
{#if titleDeselect}
<Tooltip label={titleDeselect ?? presentation.string.Deselect}>
<Icon icon={IconCheck} {size} />
</Tooltip>
{:else}
<Icon icon={IconCheck} {size} />
<!-- <CheckBox checked circle primary /> -->
{/if}
{/if}
</div> </div>
{/if} {/if}
<slot name="item" item={obj} /> <span class="label">
<slot name="item" item={obj} />
{#if allowDeselect && obj._id === selected} </span>
<div class="check-right pointer-events-none"> {#if multiSelect}
{#if titleDeselect} <div class="check pointer-events-none">
<Tooltip label={titleDeselect ?? presentation.string.Deselect}> <CheckBox checked={selectedElements.has(obj._id)} primary />
<CheckBox checked circle primary />
</Tooltip>
{:else}
<CheckBox checked circle primary />
{/if}
</div> </div>
{/if} {/if}
</button> </button>

View File

@ -25,6 +25,15 @@
background: var(--popup-bg-color); background: var(--popup-bg-color);
border-radius: .5rem; border-radius: .5rem;
box-shadow: var(--popup-shadow); box-shadow: var(--popup-shadow);
&.full-width {
flex-grow: 1;
background: none;
border-radius: 0px;
box-shadow: none;
width: 100%;
max-width: 100%;
}
&.maxHeight { height: 22rem; } &.maxHeight { height: 22rem; }
&.autoHeight { &.autoHeight {
@ -242,11 +251,11 @@
margin: 0; margin: 0;
.icon { color: var(--content-color); } .icon { color: var(--content-color); }
&:focus .icon { color: var(--accent-color); } &.hover .icon { color: var(--accent-color); }
} }
// &:hover { background-color: var(--popup-bg-hover); } // &:hover { background-color: var(--popup-bg-hover); }
&:focus { &.hover {
background-color: var(--popup-bg-hover); background-color: var(--popup-bg-hover);
outline: none; outline: none;
// box-shadow: inset 0 0 1px 1px var(--primary-edit-border-color); // box-shadow: inset 0 0 1px 1px var(--primary-edit-border-color);
@ -258,6 +267,12 @@
&:active { color: var(--highlight-red-press); } &:active { color: var(--highlight-red-press); }
&:focus { color: var(--highlight-red-press); } &:focus { color: var(--highlight-red-press); }
} }
&.separator {
margin: 0.25rem 0;
height: 1px;
background-color: var(--theme-card-divider);
}
} }
.ap-check { .ap-check {
margin-left: 1rem; margin-left: 1rem;
@ -360,7 +375,9 @@
.selectPopup .menu-item.arrow, .selectPopup .menu-item.arrow,
.antiPopup-submenu { .antiPopup-submenu {
position: relative; position: relative;
span {
padding-right: 1rem;
}
&::after { &::after {
content: ''; content: '';
position: absolute; position: absolute;

View File

@ -38,6 +38,7 @@
"DaysAfter": "{days, plural, =1 {in a day} other {in # days}}", "DaysAfter": "{days, plural, =1 {in a day} other {in # days}}",
"NoActionsDefined": "No actions applicable", "NoActionsDefined": "No actions applicable",
"Incoming": "Incoming", "Incoming": "Incoming",
"HoursLabel": "Hours" "HoursLabel": "Hours",
"Back": "Back"
} }
} }

View File

@ -38,6 +38,7 @@
"DaysAfter": "{days, plural, =1 {через день} other {через # дней}}", "DaysAfter": "{days, plural, =1 {через день} other {через # дней}}",
"NoActionsDefined": "Нет доступных действий", "NoActionsDefined": "Нет доступных действий",
"Incoming": "Входящие", "Incoming": "Входящие",
"HoursLabel": "Часы" "HoursLabel": "Часы",
"Back": "Назад"
} }
} }

View File

@ -13,76 +13,148 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import { afterUpdate, createEventDispatcher, onMount } from 'svelte' import { afterUpdate, createEventDispatcher, onDestroy, onMount } from 'svelte'
import ui from '../plugin' import ui from '../plugin'
import { closePopup, showPopup } from '../popups'
import { Action } from '../types' import { Action } from '../types'
import Icon from './Icon.svelte' import Icon from './Icon.svelte'
import Label from './Label.svelte' import Label from './Label.svelte'
import MouseSpeedTracker from './MouseSpeedTracker.svelte'
export let actions: Action[] = [] export let actions: Action[] = []
export let ctx: any = undefined export let ctx: any = undefined
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const btns: HTMLButtonElement[] = [] const btns: HTMLElement[] = []
let activeElement: HTMLElement
const keyDown = (ev: KeyboardEvent, n: number): void => { const keyDown = (ev: KeyboardEvent): void => {
if (ev.key === 'Tab') { if (ev.key === 'Tab') {
dispatch('close') dispatch('close')
ev.preventDefault()
ev.stopPropagation()
} }
const n = btns.indexOf(activeElement) ?? 0
if (ev.key === 'ArrowDown') { if (ev.key === 'ArrowDown') {
if (n === btns.length - 1) btns[0].focus() if (n < btns.length - 1) {
else btns[n + 1].focus() activeElement = btns[n + 1]
}
ev.preventDefault()
ev.stopPropagation()
} }
if (ev.key === 'ArrowUp') { if (ev.key === 'ArrowUp') {
if (n === 0) btns[btns.length - 1].focus() if (n > 0) {
else btns[n - 1].focus() activeElement = btns[n - 1]
}
ev.preventDefault()
ev.stopPropagation()
}
if (ev.key === 'ArrowLeft') {
dispatch('update', 'left')
closePopup('submenu')
ev.preventDefault()
ev.stopPropagation()
}
if (ev.key === 'ArrowRight') {
dispatch('update', 'right')
showActionPopup(actions[n], activeElement)
ev.preventDefault()
ev.stopPropagation()
} }
if (ev.key === 'ArrowLeft' && ev.altKey) dispatch('update', 'left')
if (ev.key === 'ArrowRight' && ev.altKey) dispatch('update', 'right')
} }
afterUpdate(() => { afterUpdate(() => {
dispatch('update', Date.now()) dispatch('update', Date.now())
}) })
onMount(() => { onMount(() => {
if (btns[0]) btns[0].focus() if (btns[0]) {
btns[0].focus()
}
}) })
onDestroy(() => {
closePopup('submenu')
})
function showActionPopup (action: Action, target: HTMLElement): void {
closePopup('submenu')
if (action.component !== undefined) {
console.log(action.props)
showPopup(
action.component,
action.props,
{ getBoundingClientRect: () => target.getBoundingClientRect(), position: { v: 'top', h: 'right' } },
(evt) => {
dispatch('close')
},
undefined,
{ category: 'submenu', overlay: false }
)
}
}
function focusTarget (action: Action, target: HTMLElement): void {
if (focusSpeed && target !== activeElement) {
activeElement = target
showActionPopup(action, target)
}
}
let focusSpeed: boolean = false
let popup: HTMLElement
$: popup?.focus()
</script> </script>
<div class="antiPopup"> <div class="antiPopup" on:keydown={keyDown}>
<MouseSpeedTracker bind:focusSpeed />
<div class="ap-space" /> <div class="ap-space" />
<div class="ap-scroll"> <div class="ap-scroll">
<div class="ap-box"> <div class="ap-box" bind:this={popup}>
{#if actions.length === 0} {#if actions.length === 0}
<div class="p-6 error-color"> <div class="p-6 error-color">
<Label label={ui.string.NoActionsDefined} /> <Label label={ui.string.NoActionsDefined} />
</div> </div>
{/if} {/if}
{#each actions as action, i} {#each actions as action, i}
{#if i > 0 && actions[i - 1].group !== action.group}
<span class="ap-menuItem separator" />
{/if}
{#if action.link} {#if action.link}
<a class="stealth" href={action.link}> <a class="stealth" href={action.link}>
<!-- svelte-ignore a11y-mouse-events-have-key-events --> <!-- svelte-ignore a11y-mouse-events-have-key-events -->
<button <button
bind:this={btns[i]} bind:this={btns[i]}
class="ap-menuItem flex-row-center withIcon w-full" class="ap-menuItem flex-row-center withIcon w-full"
on:keydown={(evt) => keyDown(evt, i)} class:hover={btns[i] === activeElement}
on:mouseover={(evt) => evt.currentTarget.focus()} on:mouseover={(evt) => focusTarget(action, btns[i])}
on:click|preventDefault|stopPropagation={(evt) => { on:click|preventDefault|stopPropagation={(evt) => {
if (!action.inline) dispatch('close') if (!action.inline) dispatch('close')
action.action(ctx, evt) action.action(ctx, evt)
}} }}
> >
{#if action.icon}<div class="icon mr-3"><Icon icon={action.icon} size={'small'} /></div>{/if} {#if action.icon}<div class="icon mr-3"><Icon icon={action.icon} size={'small'} /></div>{/if}
<span class="overflow-label pr-1"><Label label={action.label} /></span> <span class="overflow-label pr-1 flex-grow"><Label label={action.label} /></span>
</button> </button>
</a> </a>
{:else if action.component !== undefined}
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<button
bind:this={btns[i]}
class="ap-menuItem antiPopup-submenu"
class:hover={btns[i] === activeElement}
on:mouseover={() => focusTarget(action, btns[i])}
>
{#if action.icon}
<div class="icon mr-3"><Icon icon={action.icon} size={'small'} /></div>
{/if}
<span class="overflow-label pr-1 flex-grow"><Label label={action.label} /></span>
</button>
{:else} {:else}
<!-- svelte-ignore a11y-mouse-events-have-key-events --> <!-- svelte-ignore a11y-mouse-events-have-key-events -->
<button <button
bind:this={btns[i]} bind:this={btns[i]}
class="ap-menuItem flex-row-center withIcon" class="ap-menuItem flex-row-center withIcon"
on:keydown={(evt) => keyDown(evt, i)} class:hover={btns[i] === activeElement}
on:mouseover={(evt) => evt.currentTarget.focus()} on:mouseover={() => focusTarget(action, btns[i])}
on:click={(evt) => { on:click={(evt) => {
if (!action.inline) dispatch('close') if (!action.inline) dispatch('close')
action.action(ctx, evt) action.action(ctx, evt)
@ -91,7 +163,7 @@
{#if action.icon} {#if action.icon}
<div class="icon mr-3"><Icon icon={action.icon} size={'small'} /></div> <div class="icon mr-3"><Icon icon={action.icon} size={'small'} /></div>
{/if} {/if}
<span class="overflow-label pr-1"><Label label={action.label} /></span> <span class="overflow-label pr-1 flex-grow"><Label label={action.label} /></span>
</button> </button>
{/if} {/if}
{/each} {/each}

View File

@ -0,0 +1,68 @@
<script lang="ts" context="module">
import { readable } from 'svelte/store'
const ticker = readable(Date.now(), (set) => {
setInterval(() => {
set(Date.now())
}, 100)
})
</script>
<script lang="ts">
export let focusSpeed: boolean
let timestamp: number = 0
let lastMouseX: number = 0
let lastMouseY: number = 0
let speedX: number
let speedY: number
let speedD: number
let maxSpeedX: number
let maxSpeedY: number
let maxSpeedD: number
$: focusSpeed = speedD < 50
function update (now: number) {
maxSpeedY = 0
maxSpeedX = 0
maxSpeedD = 0
}
$: update($ticker)
function trackMouse (evt: MouseEvent): void {
if (timestamp === 0) {
timestamp = Date.now()
lastMouseX = evt.screenX
lastMouseY = evt.screenY
return
}
const now = Date.now()
const dt = now - timestamp
const dx = evt.screenX - lastMouseX
const dy = evt.screenY - lastMouseY
speedX = Math.round((dx / dt) * 100)
speedY = Math.round((dy / dt) * 100)
speedD = Math.round(Math.sqrt(speedX * speedX + speedY * speedY))
if (speedX > maxSpeedX) {
maxSpeedX = speedX
}
if (speedY > maxSpeedY) {
maxSpeedY = speedY
}
if (speedD > maxSpeedD) {
maxSpeedD = speedD
}
timestamp = now
lastMouseX = evt.screenX
lastMouseY = evt.screenY
}
</script>
<svelte:window on:mousemove={trackMouse} />

View File

@ -30,5 +30,6 @@
zIndex={(i + 1) * 500} zIndex={(i + 1) * 500}
top={$modal.length - 1 === i} top={$modal.length - 1 === i}
close={popup.close} close={popup.close}
overlay={popup.options.overlay}
/> />
{/each} {/each}

View File

@ -23,6 +23,7 @@
export let element: PopupAlignment | undefined export let element: PopupAlignment | undefined
export let onClose: ((result: any) => void) | undefined export let onClose: ((result: any) => void) | undefined
export let onUpdate: ((result: any) => void) | undefined export let onUpdate: ((result: any) => void) | undefined
export let overlay: boolean
export let zIndex: number export let zIndex: number
export let top: boolean export let top: boolean
export let close: () => void export let close: () => void
@ -126,12 +127,16 @@
on:changeContent={fitPopup} on:changeContent={fitPopup}
/> />
</div> </div>
<div
class="modal-overlay" {#if overlay}
class:antiOverlay={options.showOverlay} <div
style={`z-index: ${zIndex};`} class="modal-overlay"
on:click={() => escapeClose()} class:antiOverlay={options.showOverlay}
/> style={`z-index: ${zIndex};`}
on:click={() => escapeClose()}
on:keydown|stopPropagation|preventDefault={() => {}}
/>
{/if}
<style lang="scss"> <style lang="scss">
.popup { .popup {

View File

@ -14,64 +14,124 @@
--> -->
<script lang="ts"> <script lang="ts">
import type { Asset, IntlString } from '@anticrm/platform' import type { Asset, IntlString } from '@anticrm/platform'
import { translate } from '@anticrm/platform'
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import IconCheck from './icons/Check.svelte' import { createFocusManager } from '../focus'
import EditBox from './EditBox.svelte'
import FocusHandler from './FocusHandler.svelte'
import Icon from './Icon.svelte' import Icon from './Icon.svelte'
import IconCheck from './icons/Check.svelte'
import Label from './Label.svelte' import Label from './Label.svelte'
import ListView from './ListView.svelte'
interface ValueType {
id: number | string
icon?: Asset
label?: IntlString
text?: string
isSelected?: boolean
}
export let placeholder: IntlString | undefined = undefined export let placeholder: IntlString | undefined = undefined
export let placeholderParam: any | undefined = undefined export let placeholderParam: any | undefined = undefined
export let searchable: boolean = false export let searchable: boolean = false
export let value: Array<{ id: number | string; icon: Asset; label?: IntlString; text?: string; isSelected?: boolean }> export let value: Array<ValueType>
export let width: 'medium' | 'large' = 'medium' export let width: 'medium' | 'large' | 'full' = 'medium'
export let size: 'small' | 'medium' | 'large' = 'small'
let search: string = '' let search: string = ''
let phTraslate: string = ''
$: if (placeholder) {
translate(placeholder, placeholderParam ?? {}).then((res) => {
phTraslate = res
})
}
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
$: hasSelected = value.some((v) => v.isSelected) $: hasSelected = value.some((v) => v.isSelected)
let selection = 0
let list: ListView
function onKeydown (key: KeyboardEvent): void {
if (key.code === 'ArrowUp') {
key.stopPropagation()
key.preventDefault()
list.select(selection - 1)
}
if (key.code === 'ArrowDown') {
key.stopPropagation()
key.preventDefault()
list.select(selection + 1)
}
if (key.code === 'Enter') {
key.preventDefault()
key.stopPropagation()
dispatch('close', value[selection].id)
}
if (key.code === 'Escape') {
key.preventDefault()
key.stopPropagation()
dispatch('close')
}
}
const manager = createFocusManager()
$: filteredObjects = value.filter((el) => (el.label ?? el.text ?? '').toLowerCase().includes(search.toLowerCase()))
$: huge = size === 'medium' || size === 'large'
</script> </script>
<div class="selectPopup" class:max-width-40={width === 'large'}> <FocusHandler {manager} />
<div
class="selectPopup"
class:full-width={width === 'full'}
class:max-width-40={width === 'large'}
on:keydown={onKeydown}
>
{#if searchable} {#if searchable}
<div class="header"> <div class="header">
<input type="text" bind:value={search} placeholder={phTraslate} on:input={(ev) => {}} on:change /> <EditBox
kind={'search-style'}
focusIndex={1}
focus
bind:value={search}
{placeholder}
{placeholderParam}
on:change
/>
</div> </div>
{/if} {/if}
<div class="scroll"> <div class="scroll">
<div class="box"> <div class="box">
{#each value.filter((el) => (el.label ?? el.text ?? '').toLowerCase().includes(search.toLowerCase())) as item} <ListView bind:this={list} count={filteredObjects.length} bind:selection>
<button <svelte:fragment slot="item" let:item={itemId}>
class="menu-item" {@const item = filteredObjects[itemId]}
on:click={() => dispatch('close', item.id)} <button
on:focus={() => dispatch('update', item)} class="menu-item w-full"
on:mouseover={() => dispatch('update', item)} on:click={() => dispatch('close', item.id)}
> on:focus={() => dispatch('update', item)}
{#if hasSelected} on:mouseover={() => dispatch('update', item)}
<div class="icon"> >
{#if item.isSelected} <div class="flex-row-center" class:mt-2={huge} class:mb-2={huge}>
<Icon icon={IconCheck} size={'small'} /> {#if hasSelected}
<div class="icon">
{#if item.isSelected}
<Icon icon={IconCheck} {size} />
{/if}
</div>
{/if} {/if}
{#if item.icon}
<div class="mr-2">
<Icon icon={item.icon} {size} />
</div>
{/if}
<span class="label" class:text-base={huge}>
{#if item.label}
<Label label={item.label} />
{:else if item.text}
<span>{item.text}</span>
{/if}
</span>
</div> </div>
{/if} </button>
<div class="icon"><Icon icon={item.icon} size={'small'} /></div> </svelte:fragment>
<span class="label"> </ListView>
{#if item.label}
<Label label={item.label} />
{:else if item.text}
<span>{item.text}</span>
{/if}
</span>
</button>
{/each}
</div> </div>
</div> </div>
</div> </div>

View File

@ -63,7 +63,8 @@ export const uis = plugin(uiId, {
DaysAfter: '' as IntlString, DaysAfter: '' as IntlString,
NoActionsDefined: '' as IntlString, NoActionsDefined: '' as IntlString,
Incoming: '' as IntlString, Incoming: '' as IntlString,
HoursLabel: '' as IntlString HoursLabel: '' as IntlString,
Back: '' as IntlString
}, },
metadata: { metadata: {
DefaultApplication: '' as Metadata<AnyComponent> DefaultApplication: '' as Metadata<AnyComponent>

View File

@ -1,14 +1,14 @@
import type {
AnySvelteComponent,
AnyComponent,
HorizontalAlignment,
PopupAlignment,
PopupPositionElement,
PopupOptions,
VerticalAlignment
} from './types'
import { getResource } from '@anticrm/platform' import { getResource } from '@anticrm/platform'
import { writable } from 'svelte/store' import { writable } from 'svelte/store'
import type {
AnyComponent,
AnySvelteComponent,
HorizontalAlignment,
PopupAlignment,
PopupOptions,
PopupPositionElement,
VerticalAlignment
} from './types'
interface CompAndProps { interface CompAndProps {
id: string id: string
@ -18,9 +18,14 @@ interface CompAndProps {
onClose?: (result: any) => void onClose?: (result: any) => void
onUpdate?: (result: any) => void onUpdate?: (result: any) => void
close: () => void close: () => void
options: {
category: string
overlay: boolean
}
} }
export const popupstore = writable<CompAndProps[]>([]) export const popupstore = writable<CompAndProps[]>([])
function addPopup (props: CompAndProps): void { function addPopup (props: CompAndProps): void {
popupstore.update((popups) => { popupstore.update((popups) => {
popups.push(props) popups.push(props)
@ -33,7 +38,11 @@ export function showPopup (
props: any, props: any,
element?: PopupAlignment, element?: PopupAlignment,
onClose?: (result: any) => void, onClose?: (result: any) => void,
onUpdate?: (result: any) => void onUpdate?: (result: any) => void,
options: {
category: string
overlay: boolean
} = { category: 'popup', overlay: true }
): () => void { ): () => void {
const id = `${popupId++}` const id = `${popupId++}`
const closePopupOp = (): void => { const closePopupOp = (): void => {
@ -47,17 +56,23 @@ export function showPopup (
} }
if (typeof component === 'string') { if (typeof component === 'string') {
getResource(component) getResource(component)
.then((resolved) => addPopup({ id, is: resolved, props, element, onClose, onUpdate, close: closePopupOp })) .then((resolved) =>
addPopup({ id, is: resolved, props, element, onClose, onUpdate, close: closePopupOp, options })
)
.catch((err) => console.log(err)) .catch((err) => console.log(err))
} else { } else {
addPopup({ id, is: component, props, element, onClose, onUpdate, close: closePopupOp }) addPopup({ id, is: component, props, element, onClose, onUpdate, close: closePopupOp, options })
} }
return closePopupOp return closePopupOp
} }
export function closePopup (): void { export function closePopup (category?: string): void {
popupstore.update((popups) => { popupstore.update((popups) => {
popups.pop() if (category !== undefined) {
popups = popups.filter((p) => p.options.category !== category)
} else {
popups.pop()
}
return popups return popups
}) })
} }
@ -179,15 +194,6 @@ export function fitPopupPositionedElement (
return { props: newProps, showOverlay: false, direction } return { props: newProps, showOverlay: false, direction }
} }
// function applyStyle (values: Record<string, string | number>, modalHTML: HTMLElement): void {
// for (const [k, v] of Object.entries(values)) {
// const old = (modalHTML.style as any)[k]
// if (old !== v) {
// ;(modalHTML.style as any)[k] = v
// }
// }
// }
/** /**
* @public * @public
* *

View File

@ -45,6 +45,12 @@ export interface Action {
action: (props: any, ev: Event) => Promise<void> action: (props: any, ev: Event) => Promise<void>
inline?: boolean inline?: boolean
link?: string link?: string
// Submenu component
component?: AnyComponent
props?: Record<string, any>
group?: string
} }
export interface IPopupItem { export interface IPopupItem {

View File

@ -14,6 +14,8 @@
export let shouldShowPlaceholder = false export let shouldShowPlaceholder = false
export let onEmployeeEdit: ((event: MouseEvent) => void) | undefined = undefined export let onEmployeeEdit: ((event: MouseEvent) => void) | undefined = undefined
export let avatarSize: 'inline' | 'tiny' | 'x-small' | 'small' | 'medium' | 'large' | 'x-large' = 'x-small' export let avatarSize: 'inline' | 'tiny' | 'x-small' | 'small' | 'medium' | 'large' | 'x-large' = 'x-small'
export let isInteractive = true
export let inline = false
let container: HTMLElement let container: HTMLElement
@ -35,15 +37,17 @@
</script> </script>
<div bind:this={container} class="inline-flex clear-mins"> <div bind:this={container} class="inline-flex clear-mins">
<div class="over-underline"> <div class:over-underline={!inline}>
<PersonPresenter <PersonPresenter
{value} {value}
{tooltipLabels} {tooltipLabels}
onEdit={handlePersonEdit} onEdit={isInteractive ? handlePersonEdit : () => {}}
{shouldShowAvatar} {shouldShowAvatar}
{shouldShowName} {shouldShowName}
{avatarSize} {avatarSize}
{shouldShowPlaceholder} {shouldShowPlaceholder}
{isInteractive}
{inline}
/> />
</div> </div>
{#if value?.$lookup?.statuses?.length} {#if value?.$lookup?.statuses?.length}

View File

@ -153,7 +153,8 @@
"DurDays": "{days, plural, =0 {today} =1 {1 day} other {# days }}", "DurDays": "{days, plural, =0 {today} =1 {1 day} other {# days }}",
"DurMonths": "{months, plural, =0 {this month} =1 {1 month} other {# months}}", "DurMonths": "{months, plural, =0 {this month} =1 {1 month} other {# months}}",
"DurYears": "{years, plural, =0 {this year} =1 {a year} other {# years}}", "DurYears": "{years, plural, =0 {this year} =1 {a year} other {# years}}",
"StatusHistory": "State History" "StatusHistory": "State History",
"NewSubIssue": "Add sub-issue..."
}, },
"status": {} "status": {}
} }

View File

@ -153,7 +153,8 @@
"DurDays": "{days, plural, =0 {сегодня} =1 {1 день} =2 {2 дня} =3 {3 дня} =4 {4 дня} other {# дней }}", "DurDays": "{days, plural, =0 {сегодня} =1 {1 день} =2 {2 дня} =3 {3 дня} =4 {4 дня} other {# дней }}",
"DurMonths": "{months, plural, =0 {меньше месяця} =1 {месяц} =2 {2 месяца} =3 {3 месяца} =4 {4 месяца} other {# месяцев}}", "DurMonths": "{months, plural, =0 {меньше месяця} =1 {месяц} =2 {2 месяца} =3 {3 месяца} =4 {4 месяца} other {# месяцев}}",
"DurYears": "{years, plural, =0 {меньше года} =1 {год} =2 {2 года} =3 {3 года} =4 {4 года} other {# лет}}", "DurYears": "{years, plural, =0 {меньше года} =1 {год} =2 {2 года} =3 {3 года} =4 {4 года} other {# лет}}",
"StatusHistory": "История состояний" "StatusHistory": "История состояний",
"NewSubIssue": "Добавить под-задачу..."
}, },
"status": {} "status": {}
} }

View File

@ -49,7 +49,7 @@
let currentAssignee: Ref<Employee> | null = assignee let currentAssignee: Ref<Employee> | null = assignee
let issueStatuses: WithLookup<IssueStatus>[] | undefined let issueStatuses: WithLookup<IssueStatus>[] | undefined
let parentIssue: Issue | undefined export let parentIssue: Issue | undefined
let labels: TagReference[] = [] let labels: TagReference[] = []
let objectId: Ref<Issue> = generateId() let objectId: Ref<Issue> = generateId()

View File

@ -14,24 +14,24 @@
--> -->
<script lang="ts"> <script lang="ts">
import { AttachedData, FindOptions, SortingOrder } from '@anticrm/core' import { AttachedData, FindOptions, SortingOrder } from '@anticrm/core'
import { Issue, IssueStatusCategory, Team, calcRank } from '@anticrm/tracker' import { getClient, ObjectPopup } from '@anticrm/presentation'
import { createQuery, getClient, ObjectPopup } from '@anticrm/presentation' import { calcRank, Issue, IssueStatusCategory } from '@anticrm/tracker'
import { Icon } from '@anticrm/ui' import { Icon } from '@anticrm/ui'
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import tracker from '../plugin' import tracker from '../plugin'
import { getIssueId } from '../utils' import { getIssueId } from '../utils'
export let value: Issue | AttachedData<Issue> export let value: Issue | AttachedData<Issue> | Issue[]
export let width: 'medium' | 'large' | 'full' = 'large'
const client = getClient() const client = getClient()
const spaceQuery = createQuery()
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const options: FindOptions<Issue> = { const options: FindOptions<Issue> = {
lookup: { status: tracker.class.IssueStatus }, lookup: { status: tracker.class.IssueStatus, space: tracker.class.Team },
sort: { modifiedOn: SortingOrder.Descending } sort: { modifiedOn: SortingOrder.Descending }
} }
let team: Team | undefined
let statusCategoryById: Map<string, IssueStatusCategory> | undefined let statusCategoryById: Map<string, IssueStatusCategory> | undefined
async function updateIssueStatusCategories () { async function updateIssueStatusCategories () {
@ -41,32 +41,34 @@
} }
async function onClose ({ detail: parentIssue }: CustomEvent<Issue | undefined | null>) { async function onClose ({ detail: parentIssue }: CustomEvent<Issue | undefined | null>) {
if ('_id' in value && parentIssue !== undefined && parentIssue?._id !== value.attachedTo) { const vv = Array.isArray(value) ? value : [value]
let rank: string | null = null for (const docValue of vv) {
if ('_id' in docValue && parentIssue !== undefined && parentIssue?._id !== docValue.attachedTo) {
let rank: string | null = null
if (parentIssue) { if (parentIssue) {
const lastAttachedIssue = await client.findOne<Issue>( const lastAttachedIssue = await client.findOne<Issue>(
tracker.class.Issue, tracker.class.Issue,
{ attachedTo: parentIssue._id }, { attachedTo: parentIssue._id },
{ sort: { rank: SortingOrder.Descending } } { sort: { rank: SortingOrder.Descending } }
) )
rank = calcRank(lastAttachedIssue, undefined) rank = calcRank(lastAttachedIssue, undefined)
}
await client.update(docValue, {
attachedTo: parentIssue === null ? tracker.ids.NoParent : parentIssue._id,
...(rank ? { rank } : {})
})
} }
await client.update(value, {
attachedTo: parentIssue === null ? tracker.ids.NoParent : parentIssue._id,
...(rank ? { rank } : {})
})
} }
dispatch('close', parentIssue) dispatch('close', parentIssue)
} }
$: selected = 'attachedTo' in value ? value.attachedTo : undefined $: selected = !Array.isArray(value) ? ('attachedTo' in value ? value.attachedTo : undefined) : undefined
$: ignoreObjects = '_id' in value ? [value._id] : [] $: ignoreObjects = !Array.isArray(value) ? ('_id' in value ? [value._id] : []) : undefined
$: updateIssueStatusCategories() $: updateIssueStatusCategories()
$: 'space' in value && spaceQuery.query(tracker.class.Team, { _id: value.space }, (res) => ([team] = res))
</script> </script>
<ObjectPopup <ObjectPopup
@ -79,14 +81,14 @@
create={undefined} create={undefined}
{ignoreObjects} {ignoreObjects}
shadows={true} shadows={true}
width="large" {width}
searchField="title" searchField="title"
on:update on:update
on:close={onClose} on:close={onClose}
> >
<svelte:fragment slot="item" let:item={issue}> <svelte:fragment slot="item" let:item={issue}>
{@const { icon } = statusCategoryById?.get(issue.$lookup?.status.category) ?? {}} {@const { icon } = statusCategoryById?.get(issue.$lookup?.status.category) ?? {}}
{@const issueId = team && getIssueId(team, issue)} {@const issueId = getIssueId(issue.$lookup.space, issue)}
{#if issueId && icon} {#if issueId && icon}
<div class="flex-center clear-mins w-full h-9"> <div class="flex-center clear-mins w-full h-9">
<div class="icon mr-4 h-8"> <div class="icon mr-4 h-8">

View File

@ -195,7 +195,7 @@
{#each groupedIssues[category] as docObject (docObject._id)} {#each groupedIssues[category] as docObject (docObject._id)}
<div <div
bind:this={objectRefs[combinedGroupedIssues.findIndex((x) => x === docObject)]} bind:this={objectRefs[combinedGroupedIssues.findIndex((x) => x === docObject)]}
class="listGrid antiList__row row gap-2" class="listGrid antiList__row row gap-2 flex-grow"
class:checking={selectedObjectIdsSet.has(docObject._id)} class:checking={selectedObjectIdsSet.has(docObject._id)}
class:mListGridFixed={selectedRowIndex === combinedGroupedIssues.findIndex((x) => x === docObject)} class:mListGridFixed={selectedRowIndex === combinedGroupedIssues.findIndex((x) => x === docObject)}
class:mListGridSelected={selectedRowIndex === combinedGroupedIssues.findIndex((x) => x === docObject)} class:mListGridSelected={selectedRowIndex === combinedGroupedIssues.findIndex((x) => x === docObject)}
@ -254,7 +254,6 @@
value={getObjectValue(attributeModel.key, docObject) ?? ''} value={getObjectValue(attributeModel.key, docObject) ?? ''}
{...attributeModel.props} {...attributeModel.props}
/> />
<div class="grow-cell" />
{:else if attributeModel.props?.fixed} {:else if attributeModel.props?.fixed}
<FixedColumn <FixedColumn
width={propsWidth[attributeModel.key]} width={propsWidth[attributeModel.key]}

View File

@ -67,19 +67,19 @@
{#if kind === 'list'} {#if kind === 'list'}
<div class="priority-container" on:click={handlePriorityEditorOpened}> <div class="priority-container" on:click={handlePriorityEditorOpened}>
<div class="icon"> <div class="icon">
{#if issuePriorities[value.priority].icon}<Icon icon={issuePriorities[value.priority].icon} {size} />{/if} {#if issuePriorities[value.priority]?.icon}<Icon icon={issuePriorities[value.priority]?.icon} {size} />{/if}
</div> </div>
{#if shouldShowLabel} {#if shouldShowLabel}
<span class="overflow-label label"> <span class="overflow-label label">
<Label label={issuePriorities[value.priority].label} /> <Label label={issuePriorities[value.priority]?.label} />
</span> </span>
{/if} {/if}
</div> </div>
{:else} {:else}
<Button <Button
showTooltip={isEditable ? { label: tracker.string.SetPriority } : undefined} showTooltip={isEditable ? { label: tracker.string.SetPriority } : undefined}
label={shouldShowLabel ? issuePriorities[value.priority].label : undefined} label={shouldShowLabel ? issuePriorities[value.priority]?.label : undefined}
icon={issuePriorities[value.priority].icon} icon={issuePriorities[value.priority]?.icon}
{justify} {justify}
{width} {width}
{size} {size}

View File

@ -13,13 +13,20 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import { WithLookup } from '@anticrm/core'
import { IssueStatus } from '@anticrm/tracker' import { IssueStatus } from '@anticrm/tracker'
import { Icon } from '@anticrm/ui'
export let value: IssueStatus | undefined export let value: WithLookup<IssueStatus> | undefined
</script> </script>
{#if value} {#if value}
<span class="overflow-label"> <div class="flex-presenter">
{value.name} {#if value.$lookup?.category?.icon}
</span> <Icon icon={value.$lookup?.category?.icon} size={'medium'} />
{/if}
<span class="overflow-label" class:ml-2={value.$lookup?.category?.icon !== undefined}>
{value.name}
</span>
</div>
{/if} {/if}

View File

@ -20,7 +20,9 @@
</script> </script>
{#if value} {#if value}
<span class="titleLabel" class:mTitleLabelWithMargin={shouldUseMargin} title={value.title}>{value.title}</span> <span class="titleLabel flex-grow" class:mTitleLabelWithMargin={shouldUseMargin} title={value.title}
>{value.title}</span
>
{/if} {/if}
<style lang="scss"> <style lang="scss">

View File

@ -57,6 +57,7 @@ import Views from './components/views/Views.svelte'
import KanbanView from './components/issues/KanbanView.svelte' import KanbanView from './components/issues/KanbanView.svelte'
import tracker from './plugin' import tracker from './plugin'
import { getIssueId, getIssueTitle } from './utils' import { getIssueId, getIssueTitle } from './utils'
import CreateIssue from './components/CreateIssue.svelte'
export async function queryIssue<D extends Issue> ( export async function queryIssue<D extends Issue> (
_class: Ref<Class<D>>, _class: Ref<Class<D>>,
@ -144,7 +145,8 @@ export default async (): Promise<Resources> => ({
KanbanView, KanbanView,
TeamProjects, TeamProjects,
Roadmap, Roadmap,
IssuePreview IssuePreview,
CreateIssue
}, },
completion: { completion: {
IssueQuery: async (client: Client, query: string) => await queryIssue(tracker.class.Issue, client, query) IssueQuery: async (client: Client, query: string) => await queryIssue(tracker.class.Issue, client, query)

View File

@ -57,6 +57,7 @@ export default mergeIds(trackerId, tracker, {
CreateTeam: '' as IntlString, CreateTeam: '' as IntlString,
AddIssue: '' as IntlString, AddIssue: '' as IntlString,
NewIssue: '' as IntlString, NewIssue: '' as IntlString,
NewSubIssue: '' as IntlString,
Team: '' as IntlString, Team: '' as IntlString,
SelectIssue: '' as IntlString, SelectIssue: '' as IntlString,
SelectTeam: '' as IntlString, SelectTeam: '' as IntlString,
@ -209,7 +210,8 @@ export default mergeIds(trackerId, tracker, {
KanbanView: '' as AnyComponent, KanbanView: '' as AnyComponent,
Roadmap: '' as AnyComponent, Roadmap: '' as AnyComponent,
TeamProjects: '' as AnyComponent, TeamProjects: '' as AnyComponent,
IssuePreview: '' as AnyComponent IssuePreview: '' as AnyComponent,
CreateIssue: '' as AnyComponent
}, },
function: { function: {
getIssueTitle: '' as Resource<(client: Client, ref: Ref<Doc>) => Promise<string>> getIssueTitle: '' as Resource<(client: Client, ref: Ref<Doc>) => Promise<string>>

View File

@ -0,0 +1,77 @@
//
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { Asset, IntlString } from '@anticrm/platform'
import {
IssuePriority,
IssuesDateModificationPeriod,
IssuesGrouping,
IssuesOrdering,
ProjectStatus
} from '@anticrm/tracker'
import tracker from './plugin'
export const issuePriorities: Record<IssuePriority, { icon: Asset, label: IntlString }> = {
[IssuePriority.NoPriority]: { icon: tracker.icon.PriorityNoPriority, label: tracker.string.NoPriority },
[IssuePriority.Urgent]: { icon: tracker.icon.PriorityUrgent, label: tracker.string.Urgent },
[IssuePriority.High]: { icon: tracker.icon.PriorityHigh, label: tracker.string.High },
[IssuePriority.Medium]: { icon: tracker.icon.PriorityMedium, label: tracker.string.Medium },
[IssuePriority.Low]: { icon: tracker.icon.PriorityLow, label: tracker.string.Low }
}
export const issuesGroupByOptions: Record<IssuesGrouping, IntlString> = {
[IssuesGrouping.Status]: tracker.string.Status,
[IssuesGrouping.Assignee]: tracker.string.Assignee,
[IssuesGrouping.Priority]: tracker.string.Priority,
[IssuesGrouping.Project]: tracker.string.Project,
[IssuesGrouping.NoGrouping]: tracker.string.NoGrouping
}
export const issuesOrderByOptions: Record<IssuesOrdering, IntlString> = {
[IssuesOrdering.Status]: tracker.string.Status,
[IssuesOrdering.Priority]: tracker.string.Priority,
[IssuesOrdering.LastUpdated]: tracker.string.LastUpdated,
[IssuesOrdering.DueDate]: tracker.string.DueDate
}
export const issuesDateModificationPeriodOptions: Record<IssuesDateModificationPeriod, IntlString> = {
[IssuesDateModificationPeriod.All]: tracker.string.All,
[IssuesDateModificationPeriod.PastWeek]: tracker.string.PastWeek,
[IssuesDateModificationPeriod.PastMonth]: tracker.string.PastMonth
}
export const defaultProjectStatuses = [
ProjectStatus.Backlog,
ProjectStatus.Planned,
ProjectStatus.InProgress,
ProjectStatus.Paused,
ProjectStatus.Completed,
ProjectStatus.Canceled
]
export const projectStatusAssets: Record<ProjectStatus, { icon: Asset, label: IntlString }> = {
[ProjectStatus.Backlog]: { icon: tracker.icon.ProjectStatusBacklog, label: tracker.string.Backlog },
[ProjectStatus.Planned]: { icon: tracker.icon.ProjectStatusPlanned, label: tracker.string.Planned },
[ProjectStatus.InProgress]: { icon: tracker.icon.ProjectStatusInProgress, label: tracker.string.InProgress },
[ProjectStatus.Paused]: { icon: tracker.icon.ProjectStatusPaused, label: tracker.string.Paused },
[ProjectStatus.Completed]: { icon: tracker.icon.ProjectStatusCompleted, label: tracker.string.Completed },
[ProjectStatus.Canceled]: { icon: tracker.icon.ProjectStatusCanceled, label: tracker.string.Canceled }
}
export const defaultPriorities = [
IssuePriority.NoPriority,
IssuePriority.Urgent,
IssuePriority.High,
IssuePriority.Medium,
IssuePriority.Low
]

View File

@ -15,20 +15,22 @@
import contact, { Employee, formatName } from '@anticrm/contact' import contact, { Employee, formatName } from '@anticrm/contact'
import { Doc, DocumentQuery, Ref, SortingOrder, TxOperations } from '@anticrm/core' import { Doc, DocumentQuery, Ref, SortingOrder, TxOperations } from '@anticrm/core'
import { TypeState } from '@anticrm/kanban'
import { Asset, IntlString, translate } from '@anticrm/platform' import { Asset, IntlString, translate } from '@anticrm/platform'
import { import {
IssuePriority,
Team,
IssuesGrouping,
IssuesOrdering,
Issue, Issue,
IssuesDateModificationPeriod, IssuesDateModificationPeriod,
IssuesGrouping,
IssuesOrdering,
IssueStatus,
ProjectStatus, ProjectStatus,
IssueStatus Team
} from '@anticrm/tracker' } from '@anticrm/tracker'
import { AnyComponent, AnySvelteComponent, getMillisecondsInMonth, MILLISECONDS_IN_WEEK } from '@anticrm/ui' import { AnyComponent, AnySvelteComponent, getMillisecondsInMonth, MILLISECONDS_IN_WEEK } from '@anticrm/ui'
import { TypeState } from '@anticrm/kanban'
import tracker from './plugin' import tracker from './plugin'
import { defaultPriorities, defaultProjectStatuses, issuePriorities } from './types'
export * from './types'
export interface NavigationItem { export interface NavigationItem {
id: string id: string
@ -44,35 +46,6 @@ export interface Selection {
currentSpecial?: string currentSpecial?: string
} }
export const issuePriorities: Record<IssuePriority, { icon: Asset, label: IntlString }> = {
[IssuePriority.NoPriority]: { icon: tracker.icon.PriorityNoPriority, label: tracker.string.NoPriority },
[IssuePriority.Urgent]: { icon: tracker.icon.PriorityUrgent, label: tracker.string.Urgent },
[IssuePriority.High]: { icon: tracker.icon.PriorityHigh, label: tracker.string.High },
[IssuePriority.Medium]: { icon: tracker.icon.PriorityMedium, label: tracker.string.Medium },
[IssuePriority.Low]: { icon: tracker.icon.PriorityLow, label: tracker.string.Low }
}
export const issuesGroupByOptions: Record<IssuesGrouping, IntlString> = {
[IssuesGrouping.Status]: tracker.string.Status,
[IssuesGrouping.Assignee]: tracker.string.Assignee,
[IssuesGrouping.Priority]: tracker.string.Priority,
[IssuesGrouping.Project]: tracker.string.Project,
[IssuesGrouping.NoGrouping]: tracker.string.NoGrouping
}
export const issuesOrderByOptions: Record<IssuesOrdering, IntlString> = {
[IssuesOrdering.Status]: tracker.string.Status,
[IssuesOrdering.Priority]: tracker.string.Priority,
[IssuesOrdering.LastUpdated]: tracker.string.LastUpdated,
[IssuesOrdering.DueDate]: tracker.string.DueDate
}
export const issuesDateModificationPeriodOptions: Record<IssuesDateModificationPeriod, IntlString> = {
[IssuesDateModificationPeriod.All]: tracker.string.All,
[IssuesDateModificationPeriod.PastWeek]: tracker.string.PastWeek,
[IssuesDateModificationPeriod.PastMonth]: tracker.string.PastMonth
}
export type IssuesGroupByKeys = keyof Pick<Issue, 'status' | 'priority' | 'assignee' | 'project'> export type IssuesGroupByKeys = keyof Pick<Issue, 'status' | 'priority' | 'assignee' | 'project'>
export type IssuesOrderByKeys = keyof Pick<Issue, 'status' | 'priority' | 'modifiedOn' | 'dueDate'> export type IssuesOrderByKeys = keyof Pick<Issue, 'status' | 'priority' | 'modifiedOn' | 'dueDate'>
@ -120,24 +93,6 @@ export const getIssuesModificationDatePeriodTime = (period: IssuesDateModificati
} }
} }
export const defaultProjectStatuses = [
ProjectStatus.Backlog,
ProjectStatus.Planned,
ProjectStatus.InProgress,
ProjectStatus.Paused,
ProjectStatus.Completed,
ProjectStatus.Canceled
]
export const projectStatusAssets: Record<ProjectStatus, { icon: Asset, label: IntlString }> = {
[ProjectStatus.Backlog]: { icon: tracker.icon.ProjectStatusBacklog, label: tracker.string.Backlog },
[ProjectStatus.Planned]: { icon: tracker.icon.ProjectStatusPlanned, label: tracker.string.Planned },
[ProjectStatus.InProgress]: { icon: tracker.icon.ProjectStatusInProgress, label: tracker.string.InProgress },
[ProjectStatus.Paused]: { icon: tracker.icon.ProjectStatusPaused, label: tracker.string.Paused },
[ProjectStatus.Completed]: { icon: tracker.icon.ProjectStatusCompleted, label: tracker.string.Completed },
[ProjectStatus.Canceled]: { icon: tracker.icon.ProjectStatusCanceled, label: tracker.string.Canceled }
}
export const groupBy = (data: any, key: any): { [key: string]: any[] } => { export const groupBy = (data: any, key: any): { [key: string]: any[] } => {
return data.reduce((storage: { [key: string]: any[] }, item: any) => { return data.reduce((storage: { [key: string]: any[] }, item: any) => {
const group = item[key] const group = item[key]
@ -224,14 +179,6 @@ export const getIssueFilterAssetsByType = (type: string): { icon: Asset, label:
} }
} }
export const defaultPriorities = [
IssuePriority.NoPriority,
IssuePriority.Urgent,
IssuePriority.High,
IssuePriority.Medium,
IssuePriority.Low
]
export const getArraysIntersection = (a: any[], b: any[]): any[] => { export const getArraysIntersection = (a: any[], b: any[]): any[] => {
const setB = new Set(b) const setB = new Set(b)
const intersection = new Set(a.filter((x) => setB.has(x))) const intersection = new Set(a.filter((x) => setB.has(x)))

View File

@ -267,7 +267,11 @@ export default plugin(trackerId, {
}, },
action: { action: {
SetDueDate: '' as Ref<Action>, SetDueDate: '' as Ref<Action>,
SetParent: '' as Ref<Action> SetParent: '' as Ref<Action>,
SetStatus: '' as Ref<Action>,
SetPriority: '' as Ref<Action>,
SetAssignee: '' as Ref<Action>,
SetProject: '' as Ref<Action>
}, },
team: { team: {
DefaultTeam: '' as Ref<Team> DefaultTeam: '' as Ref<Team>

View File

@ -1,5 +1,5 @@
import { Doc, Hierarchy } from '@anticrm/core' import { Class, Doc, DocumentQuery, Hierarchy, Ref } from '@anticrm/core'
import { getResource, Resource } from '@anticrm/platform' import { Asset, getResource, IntlString, Resource } from '@anticrm/platform'
import { getClient, MessageBox, updateAttribute } from '@anticrm/presentation' import { getClient, MessageBox, updateAttribute } from '@anticrm/presentation'
import { import {
AnyComponent, AnyComponent,
@ -11,7 +11,7 @@ import {
showPanel, showPanel,
showPopup showPopup
} from '@anticrm/ui' } from '@anticrm/ui'
import { ViewContext } from '@anticrm/view' import { Action, ViewContext } from '@anticrm/view'
import MoveView from './components/Move.svelte' import MoveView from './components/Move.svelte'
import { contextStore } from './context' import { contextStore } from './context'
import view from './plugin' import view from './plugin'
@ -168,6 +168,7 @@ async function ShowPopup (
value?: string value?: string
values?: string values?: string
props?: Record<string, any> props?: Record<string, any>
fillProps?: Record<string, string>
} }
): Promise<void> { ): Promise<void> {
const docs = Array.isArray(doc) ? doc : doc !== undefined ? [doc] : [] const docs = Array.isArray(doc) ? doc : doc !== undefined ? [doc] : []
@ -176,6 +177,19 @@ async function ShowPopup (
let cprops = { let cprops = {
...(props?.props ?? {}) ...(props?.props ?? {})
} }
for (const [docKey, propKey] of Object.entries(props.fillProps ?? {})) {
for (const dv of docs) {
const dvv = (dv as any)[docKey]
if (dvv !== undefined) {
;(cprops as any)[propKey] = { dvv }
}
}
if (docKey === '_object') {
;(cprops as any)[propKey] = docs[0]
}
}
if (docs.length > 0) { if (docs.length > 0) {
cprops = { cprops = {
...cprops, ...cprops,
@ -282,6 +296,36 @@ function UpdateDocument (doc: Doc | Doc[], evt: Event, props: Record<string, any
} }
} }
function ValueSelector (
doc: Doc | Doc[],
evt: Event,
props: {
action: Action
attribute: string
// Class object finder
_class?: Ref<Class<Doc>>
query?: DocumentQuery<Doc>
// Will copy values from selection document to query
// If set of docs passed, will do $in for values.
fillQuery?: Record<string, string>
// A list of fields with matched values to perform action.
docMatches?: string[]
searchField?: string
// Or list of values to select from
values?: Array<{ icon?: Asset, label: IntlString, id: number | string }>
placeholder?: IntlString
}
): void {
if (props.action.actionPopup !== undefined) {
showPopup(props.action.actionPopup, { ...props, ...props.action.actionProps, value: doc, width: 'large' }, 'top')
}
}
async function getPopupAlignment ( async function getPopupAlignment (
element?: PopupPosAlignment | Resource<(e?: Event) => PopupAlignment | undefined>, element?: PopupPosAlignment | Resource<(e?: Event) => PopupAlignment | undefined>,
evt?: Event evt?: Event
@ -319,5 +363,6 @@ export const actionImpl = {
UpdateDocument, UpdateDocument,
ShowPanel, ShowPanel,
ShowPopup, ShowPopup,
ShowEditor ShowEditor,
ValueSelector
} }

View File

@ -16,7 +16,7 @@
import { WithLookup } from '@anticrm/core' import { WithLookup } from '@anticrm/core'
import { getResource, translate } from '@anticrm/platform' import { getResource, translate } from '@anticrm/platform'
import { createQuery, getClient } from '@anticrm/presentation' import { createQuery, getClient } from '@anticrm/presentation'
import { closePopup, Icon, IconArrowLeft, Label } from '@anticrm/ui' import ui, { Button, closePopup, Component, Icon, IconArrowLeft, Label } from '@anticrm/ui'
import { Action, ViewContext } from '@anticrm/view' import { Action, ViewContext } from '@anticrm/view'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { filterActions, getSelection } from '../actions' import { filterActions, getSelection } from '../actions'
@ -25,18 +25,22 @@
import ActionContext from './ActionContext.svelte' import ActionContext from './ActionContext.svelte'
import { ListView } from '@anticrm/ui' import { ListView } from '@anticrm/ui'
import ObjectPresenter from './ObjectPresenter.svelte' import ObjectPresenter from './ObjectPresenter.svelte'
import { tick } from 'svelte'
export let viewContext: ViewContext export let viewContext: ViewContext
let search: string = '' let search: string = ''
let actions: WithLookup<Action>[] = [] let actions: WithLookup<Action>[] = []
let input: HTMLInputElement let input: HTMLInputElement | undefined
const query = createQuery() const query = createQuery()
query.query( query.query(
view.class.Action, view.class.Action,
{}, {
// Disable popup actions for now
// actionPopup: { $exists: false }
},
(res) => { (res) => {
actions = res actions = res
}, },
@ -47,12 +51,6 @@
} }
) )
const targetQuery = createQuery()
targetQuery.query(view.class.Action, {}, (res) => {
actions = res
})
let supportedActions: WithLookup<Action>[] = [] let supportedActions: WithLookup<Action>[] = []
let filteredActions: WithLookup<Action>[] = [] let filteredActions: WithLookup<Action>[] = []
@ -107,21 +105,27 @@
let list: ListView let list: ListView
/* eslint-disable no-undef */ /* eslint-disable no-undef */
let activeAction: Action | undefined
async function handleSelection (evt: Event, selection: number): Promise<void> { async function handleSelection (evt: Event, selection: number): Promise<void> {
const action = filteredActions[selection] const action = filteredActions[selection]
if (action.actionPopup !== undefined) {
activeAction = action
return
}
const docs = getSelection($focusStore, $selectionStore) const docs = getSelection($focusStore, $selectionStore)
if (action.input === 'focus') { if (action.input === 'focus') {
const impl = await getResource(action.action) const impl = await getResource(action.action)
if (impl !== undefined) { if (impl !== undefined) {
closePopup() closePopup()
impl(docs[0], evt, action.actionProps) impl(docs[0], evt, { ...action.actionProps, action })
} }
} }
if (action.input === 'selection' || action.input === 'any' || action.input === 'none') { if (action.input === 'selection' || action.input === 'any' || action.input === 'none') {
const impl = await getResource(action.action) const impl = await getResource(action.action)
if (impl !== undefined) { if (impl !== undefined) {
closePopup() closePopup()
impl(docs, evt, action.actionProps) impl(docs, evt, { ...action.actionProps, action })
} }
} }
} }
@ -176,7 +180,7 @@
/> />
<div class="selectPopup width-40" style:width="15rem" on:keydown={onKeydown}> <div class="selectPopup width-40" style:width="15rem" on:keydown={onKeydown}>
<div class="mt-2 ml-2"> <div class="mt-2 ml-2 flex-between">
{#if $selectionStore.length > 0} {#if $selectionStore.length > 0}
<div class="item-box"> <div class="item-box">
{$selectionStore.length} items {$selectionStore.length} items
@ -191,64 +195,93 @@
/> />
</div> </div>
{/if} {/if}
{#if activeAction && activeAction?.actionPopup !== undefined}
<div class="mt-2 mb-2 mr-2">
<Button
icon={IconArrowLeft}
label={ui.string.Back}
on:click={() => {
activeAction = undefined
}}
width={'fit-content'}
/>
</div>
{/if}
</div> </div>
<div class="header"> {#if activeAction && activeAction?.actionPopup !== undefined}
<input bind:this={input} type="text" bind:value={search} placeholder={phTraslate} /> <Component
</div> is={activeAction?.actionPopup}
<div class="scroll"> props={{
<div class="box"> ...activeAction.actionProps,
<ListView value: getSelection($focusStore, $selectionStore),
bind:this={list} width: 'full',
count={filteredActions.length} size: 'medium'
bind:selection }}
on:click={(evt) => handleSelection(evt, evt.detail)} on:close={async () => {
> activeAction = undefined
<svelte:fragment slot="category" let:item> await tick()
{@const action = filteredActions[item]} input?.focus()
{#if item === 0 || (item > 0 && filteredActions[item - 1].$lookup?.category?.label !== action.$lookup?.category?.label)} }}
<!--Category for first item--> />
{#if action.$lookup?.category} {:else}
<div class="category-box"> <div class="header">
<Label label={action.$lookup.category.label} /> <input bind:this={input} type="text" bind:value={search} placeholder={phTraslate} />
</div>
{/if}
{/if}
</svelte:fragment>
<svelte:fragment slot="item" let:item>
{@const action = filteredActions[item]}
<div class="flex-row-center flex-between flex-grow ml-2 p-3 text-base">
<div class="mr-4">
<Icon icon={action.icon ?? IconArrowLeft} size={'small'} />
</div>
<div class="flex-grow">
<Label label={action.label} />
</div>
<div class="mr-2 text-md flex-row-center">
{#if action.keyBinding}
{#each action.keyBinding as key, i}
{#if i !== 0}
<div class="ml-2 mr-2">or</div>
{/if}
<div class="flex-row-center">
{#each formatKey(key) as k, jj}
{#if jj !== 0}
<div class="ml-1 mr-1">then</div>
{/if}
{#each k as kk, j}
<div class="flex-center text-sm key-box">
{kk}
</div>
{/each}
{/each}
</div>
{/each}
{/if}
</div>
</div>
</svelte:fragment>
</ListView>
</div> </div>
</div> <div class="scroll">
<div class="box">
<ListView
bind:this={list}
count={filteredActions.length}
bind:selection
on:click={(evt) => handleSelection(evt, evt.detail)}
>
<svelte:fragment slot="category" let:item>
{@const action = filteredActions[item]}
{#if item === 0 || (item > 0 && filteredActions[item - 1].$lookup?.category?.label !== action.$lookup?.category?.label)}
<!--Category for first item-->
{#if action.$lookup?.category}
<div class="category-box">
<Label label={action.$lookup.category.label} />
</div>
{/if}
{/if}
</svelte:fragment>
<svelte:fragment slot="item" let:item>
{@const action = filteredActions[item]}
<div class="flex-row-center flex-between flex-grow ml-2 p-3 text-base">
<div class="mr-4">
<Icon icon={action.icon ?? IconArrowLeft} size={'small'} />
</div>
<div class="flex-grow">
<Label label={action.label} />
</div>
<div class="mr-2 text-md flex-row-center">
{#if action.keyBinding}
{#each action.keyBinding as key, i}
{#if i !== 0}
<div class="ml-2 mr-2">or</div>
{/if}
<div class="flex-row-center">
{#each formatKey(key) as k, jj}
{#if jj !== 0}
<div class="ml-1 mr-1">then</div>
{/if}
{#each k as kk, j}
<div class="flex-center text-sm key-box">
{kk}
</div>
{/each}
{/each}
</div>
{/each}
{/if}
</div>
</div>
</svelte:fragment>
</ListView>
</div>
</div>
{/if}
</div> </div>
<style lang="scss"> <style lang="scss">

View File

@ -18,7 +18,7 @@
import type { Asset } from '@anticrm/platform' import type { Asset } from '@anticrm/platform'
import { getClient } from '@anticrm/presentation' import { getClient } from '@anticrm/presentation'
import { Action, Menu } from '@anticrm/ui' import { Action, Menu } from '@anticrm/ui'
import type { ViewContextType } from '@anticrm/view' import type { ActionGroup, ViewContextType } from '@anticrm/view'
import { getActions, invokeAction } from '../actions' import { getActions, invokeAction } from '../actions'
export let object: Doc | Doc[] export let object: Doc | Doc[]
@ -31,15 +31,29 @@
let loaded = 0 let loaded = 0
const order: Record<ActionGroup, number> = {
create: 1,
edit: 2,
copy: 3,
associate: 4,
tools: 5,
other: 6
}
getActions(client, object, baseMenuClass, mode).then((result) => { getActions(client, object, baseMenuClass, mode).then((result) => {
actions = result.map((a) => ({ actions = result
label: a.label, .sort((a, b) => order[a.context.group ?? 'other'] - order[b.context.group ?? 'other'])
icon: a.icon as Asset, .map((a) => ({
inline: a.inline, label: a.label,
action: async (_: any, evt: Event) => { icon: a.icon as Asset,
invokeAction(object, evt, a.action, a.actionProps) inline: a.inline,
} group: a.context.group ?? 'other',
})) action: async (_: any, evt: Event) => {
invokeAction(object, evt, a.action, a.actionProps)
},
component: a.actionPopup,
props: { ...a.actionProps, value: object }
}))
loaded = 1 loaded = 1
}) })

View File

@ -0,0 +1,142 @@
<script lang="ts">
import { Class, Doc, DocumentQuery, FindOptions, Ref } from '@anticrm/core'
import { Asset, IntlString } from '@anticrm/platform'
import { getClient, ObjectPopup } from '@anticrm/presentation'
import { Label, SelectPopup } from '@anticrm/ui'
import { createEventDispatcher } from 'svelte'
import view from '../plugin'
import ObjectPresenter from './ObjectPresenter.svelte'
export let value: Doc | Doc[]
export let isEditable: boolean = true
export let _class: Ref<Class<Doc>> | undefined
export let query: DocumentQuery<Doc> | undefined
export let queryOptions: FindOptions<Doc> | undefined
export let attribute: string
export let searchField: string
export let values:
| {
icon?: Asset
label: IntlString
id: string | number
}[]
| undefined = undefined
export let fillQuery: Record<string, string> | undefined
export let docMatches: string[] | undefined
export let placeholder: IntlString | undefined
export let width: 'medium' | 'large' | 'full' = 'medium'
export let size: 'small' | 'medium' | 'large' = 'small'
const dispatch = createEventDispatcher()
const changeStatus = async (newStatus: any) => {
if (!isEditable || newStatus == null) {
dispatch('close', null)
return
}
const docs = Array.isArray(value) ? value : [value]
const c = getClient()
const changed = (d: Doc) => (d as any)[attribute] !== newStatus
await Promise.all(docs.filter(changed).map((it) => c.update(it, { [attribute]: newStatus })))
dispatch('close', newStatus)
}
$: current = (value as any)[attribute]
let finalQuery: DocumentQuery<Doc> = {}
let docMatch = true
function updateQuery (
query: DocumentQuery<Doc> | undefined,
value: Doc | Doc[],
fillQuery: Record<string, string> | undefined
): void {
// Check if docMatches is applied.
if (docMatches !== undefined && Array.isArray(value)) {
for (const k of docMatches) {
const v = (value[0] as any)[k]
for (const d of value) {
if (v !== (d as any)[k]) {
docMatch = false
return
}
}
}
}
const q = { ...query }
const docs = Array.isArray(value) ? value : [value]
for (const [docKey, queryKey] of Object.entries(fillQuery ?? {})) {
const vs: any[] = []
for (const dv of docs) {
const dvv = (dv as any)[docKey]
if (dvv !== undefined) {
if (!vs.includes(dvv)) {
vs.push(dvv)
}
}
}
;(q as any)[queryKey] = docs.length === 1 ? vs[0] : { $in: vs }
if (docKey === '_object') {
;(q as any)[queryKey] = docs[0]
}
}
finalQuery = q
docMatch = true
}
$: updateQuery(query, value, fillQuery)
$: huge = size === 'medium' || size === 'large'
</script>
{#if docMatch}
{#if values}
<SelectPopup
value={values.map((it) => ({ ...it, isSelected: it.id === current }))}
on:close={(evt) => changeStatus(evt.detail)}
placeholder={placeholder ?? view.string.Filter}
searchable
{width}
{size}
/>
{:else if _class !== undefined}
<ObjectPopup
{_class}
docQuery={finalQuery}
options={queryOptions ?? {}}
{searchField}
allowDeselect={true}
selected={current}
on:close={(evt) => changeStatus(evt.detail?._id)}
placeholder={placeholder ?? view.string.Filter}
{width}
{size}
>
<svelte:fragment slot="item" let:item>
<div class="flex flex-grow overflow-label" class:mt-2={huge} class:mb-2={huge}>
<ObjectPresenter
objectId={item._id}
_class={item._class}
value={item}
props={{ isInteractive: false, inline: true, size }}
/>
</div>
</svelte:fragment>
</ObjectPopup>
{/if}
{:else}
<div class="selectPopup">
<div class="flex-center w-60 h-18">
<Label label={view.string.DontMatchCriteria} />
</div>
</div>
{/if}

View File

@ -51,6 +51,7 @@ import TableBrowser from './components/TableBrowser.svelte'
import TimestampPresenter from './components/TimestampPresenter.svelte' import TimestampPresenter from './components/TimestampPresenter.svelte'
import UpDownNavigator from './components/UpDownNavigator.svelte' import UpDownNavigator from './components/UpDownNavigator.svelte'
import ViewletSettingButton from './components/ViewletSettingButton.svelte' import ViewletSettingButton from './components/ViewletSettingButton.svelte'
import ValueSelector from './components/ValueSelector.svelte'
import { import {
afterResult, afterResult,
beforeResult, beforeResult,
@ -136,7 +137,8 @@ export default async (): Promise<Resources> => ({
StringEditorPopup: EditBoxPopup, StringEditorPopup: EditBoxPopup,
BooleanTruePresenter, BooleanTruePresenter,
EnumEditor, EnumEditor,
FilterTypePopup FilterTypePopup,
ValueSelector
}, },
popup: { popup: {
PositionElementAlignment PositionElementAlignment

View File

@ -110,6 +110,11 @@ export class ListSelectionProvider implements SelectionFocusProvider {
update (docs: Doc[]): void { update (docs: Doc[]): void {
this._docs = docs this._docs = docs
selectionStore.update((docs) => {
const ids = new Set(docs.map((it) => it._id))
return this._docs.filter((it) => ids.has(it._id))
})
if (this._docs.length > 0) { if (this._docs.length > 0) {
if (this._current?.focus === undefined) { if (this._current?.focus === undefined) {
this.delegate(0, undefined, 'vertical') this.delegate(0, undefined, 'vertical')

View File

@ -215,6 +215,11 @@ export interface ActionCategory extends Doc, UXObject {
visible: boolean visible: boolean
} }
/**
* @public
*/
export type ActionGroup = 'create' | 'edit' | 'associate' | 'copy' | 'tools' | 'other'
/** /**
* @public * @public
*/ */
@ -224,6 +229,9 @@ export interface Action<T extends Doc = Doc, P = Record<string, any>> extends Do
// Action implementation parameters // Action implementation parameters
actionProps?: P actionProps?: P
// If specified, will show sub menu based on actionPopup/actionProps
actionPopup?: AnyComponent
// If specified, action could be used only with one item selected. // If specified, action could be used only with one item selected.
// single - one object is required // single - one object is required
// any - one or multiple objects are required // any - one or multiple objects are required
@ -275,8 +283,9 @@ export interface ViewContext {
mode: ViewContextType | ViewContextType[] mode: ViewContextType | ViewContextType[]
// Active application // Active application
application?: Ref<Doc> application?: Ref<Doc>
// Optional groupping // Optional groupping
group?: string group?: ActionGroup
} }
/** /**
@ -435,7 +444,8 @@ const view = plugin(viewId, {
ObjectPresenter: '' as AnyComponent, ObjectPresenter: '' as AnyComponent,
EditDoc: '' as AnyComponent, EditDoc: '' as AnyComponent,
SpacePresenter: '' as AnyComponent, SpacePresenter: '' as AnyComponent,
BooleanTruePresenter: '' as AnyComponent BooleanTruePresenter: '' as AnyComponent,
ValueSelector: '' as AnyComponent
}, },
string: { string: {
CustomizeView: '' as IntlString, CustomizeView: '' as IntlString,
@ -499,11 +509,33 @@ const view = plugin(viewId, {
value?: string value?: string
values?: string values?: string
props?: Record<string, any> props?: Record<string, any>
// Will copy values from selection document to props
fillProps?: Record<string, string>
}>, }>,
ShowEditor: '' as ViewAction<{ ShowEditor: '' as ViewAction<{
element?: PopupPosAlignment | Resource<(e?: Event) => PopupAlignment | undefined> element?: PopupPosAlignment | Resource<(e?: Event) => PopupAlignment | undefined>
attribute: string attribute: string
props?: Record<string, any> props?: Record<string, any>
}>,
ValueSelector: '' as ViewAction<{
attribute: string
// Class object finder
_class?: Ref<Class<Doc>>
query?: DocumentQuery<Doc>
queryOptions?: FindOptions<Doc>
// Will copy values from selection document to query
// If set of docs passed, will do $in for values.
fillQuery?: Record<string, string>
// A list of fields with matched values to perform action.
docMatches?: string[]
searchField?: string
// Or list of values to select from
values?: { icon?: Asset, label: IntlString, id: number | string }[]
placeholder?: IntlString
}> }>
} }
}) })