- EGroupware
- Overview
- Overview
- Examples
- Basic
- React to what the user does
- Right-click menu
- Add, change and remove a comment
- Filter
- Follow the video
- Another row template
- Show something else with the same comments
- Content
- Row template
- What the user can do
- Events
- Methods
- The store
- Filters
- Following the video
- Live updates
- Attributes
- Things to know
- Properties
- LitElement
- Events
- Methods
Small Part Comment List
<smallpart-comment-list> | SmallPartCommentList
Provided by the SmallParT app. It is only available in that app’s templates, where the app’s JavaScript registers it, and cannot be used from other apps.
The comments of a video as tiles
Overview
The comments of one video as a single column of tiles.
<smallpart-comment-list id="comments"/>
with the comments (as the server’s Student\Ui::_fixComments() makes them) in
$content['comments'].
The widget keeps the comments in a SmallPartCommentStore (list.store): sorted, filtered, and
followed along the video. Rows are drawn by et2-datagrid, so only the tiles in view exist; a
comment pushed from the server goes through upsertComment() / removeComment() and
redraws what is on screen, not every comment.
It does not talk to the video or the edit form. What the user does is announced by a bubbling
smallpart-comment-action event whose detail is
{action: "open" | "edit" | "retweet", id, comment}, and the owner decides what to do. There is
ONE click listener for the whole list: the tiles contain no click handlers, the controls in them are found
by class (commentEditBtn, commentRetweetBtn). A click on a tile anywhere else -
not on a link, button or dropdown - opens the comment.
smallpart-comment-list shows the comments of one SmallParT video as a column of tiles. It sits
on et2-datagrid, so only the tiles in view exist, and it keeps the comments in a
SmallPartCommentStore that sorts, filters and follows the video.
<smallpart-comment-list id="comments" template="smallpart.comment.rows"/>
// Student\Ui::index() - the list takes its comments from $content['comments']
$content['comments'] = self::_fixComments($bo->listComments($video_id), $bo->isTeacher($course_id));
// ... and the actions of the old grid, which become its context menu
$tpl->setElementAttribute('comments', 'actions', self::get_actions());
Overview
Examples
Content
Row template
What the user can do
Events
Methods
The store
Filters
Following the video
Live updates
Attributes
Things to know
Overview
The list replaced a legacy <grid id="comments">. That grid made about 23 widgets per
comment and rebuilt all of them whenever any comment was pushed: with 889 comments a single push blocked the
page for 4.8 s and took 10.6 s to paint. The list draws roughly the 25 tiles in view, and a pushed comment
redraws only what changed.
Three rules keep it cheap, and any change to the list or its row template should keep them:
-
No handler in a row. The list has one
click, onecontextmenuand four pointer listeners for the whole list. Controls in a tile are found by class (commentEditBtn,commentRetweetBtn) and the comment by the row the event came through. Never addonclickto the row template. - Filtering reads data, not the DOM. A filter is a value in the store; the visible comments are computed from the comments themselves.
- Changes are applied once per frame. Pushes arriving together (a livestream) are drawn together.
The widget does not talk to the video or the edit area. It announces what the user did (events) and the owner, smallpart/js/app.ts, decides what happens.
Examples
The list needs the SmallParT app (its JavaScript registers it and its server code supplies the content), so these are code to copy, not live previews.
Basic
Put the list in a template and give it the comments. Nothing else is needed to show them: the list loads its row template, draws the tiles in view and keeps itself in step with its own store.
<smallpart-comment-list id="comments"/>
// the id of the widget is the key of the content
$content['comments'] = self::_fixComments($bo->listComments($video_id), $bo->isTeacher($course_id));
Give it a height: it fills the space its parent offers and scrolls inside it. In a flex column that is
flex: 1 1 auto; min-height: 0 on the parent’s child, which the list already sets on itself.
React to what the user does
The list never opens anything itself. It announces the click and the owner decides.
const list = this.et2.getWidgetById("comments");
list.addEventListener("smallpart-comment-action", (e : CustomEvent) =>
{
// action is "open", "edit" or "retweet"
const {action, comment} = e.detail;
app.smallpart.student_openComment({id: action}, [{data: comment}]);
});
Right-click menu
The menu is the action set the server attaches to the widget. Edit and Delete are only enabled for comments
that have the commentOwner class (the author and staff).
$tpl->setElementAttribute('comments', 'actions', [
'open' => [
'caption' => 'Open', 'icon' => 'view', 'default' => true, 'group' => 1,
'onExecute' => 'javaScript:app.smallpart.student_openComment',
],
'edit' => [
'caption' => 'Edit', 'icon' => 'edit', 'group' => 2, 'enableClass' => 'commentOwner',
'onExecute' => 'javaScript:app.smallpart.student_openComment',
],
'delete' => [
'caption' => 'Delete', 'icon' => 'delete', 'group' => 3, 'enableClass' => 'commentOwner',
'onExecute' => 'javaScript:app.smallpart.student_deleteComment',
],
]);
The handler gets (action, [{id, data: comment}]).
Add, change and remove a comment
A comment pushed from the server (or returned by an ajax call) goes in by calling the list, which redraws what is on screen and nothing else. Several calls in the same moment are drawn together.
push(pushData)
{
const list = this.et2.getWidgetById("comments");
const [course_id, video_id, comment_id] = pushData.id.split(":");
if (pushData.type === "delete")
{
// a delete carries no comment: the id names it
list.removeComment(comment_id);
}
else
{
list.upsertComment(pushData.acl);
}
}
Filter
Filters are values in the store, so the list shows the result without looking at its own DOM.
const store = this.et2.getWidgetById("comments").store;
store.setFilter("search", {query: "Klarheit", all: true}); // text, not a pattern; `all` adds the author's name
store.setFilter("participants", {accounts: [11, 12]}); // written or replied to by these
store.setFilter("color", {mode: "lf"}); // only livefeedback comments
store.clearFilters();
Filters combine: a comment has to pass all of them. See Filters for the others.
Follow the video
Scroll to the comment the video has reached while it plays, and stop when it is paused.
const videobar = this.et2.getWidgetById("video");
videobar.video[0].addEventListener("et2_video.onTimeUpdate." + videobar.id, () =>
{
if (!videobar.paused())
{
list.followTime(videobar.currentTime());
}
});
// scroll to a comment on request, eg. a click on the video's slider
list.scrollToComment(comment_id);
Another row template
The structure of a tile is a template. To change how a tile looks, make one with the same bindings and name it:
<smallpart-comment-list id="comments" template="myapp.compact.rows"/>
Start from comment.rows.xet, which shows every field a row can bind to.
Show something else with the same comments
Anything that shows comments can read the store and follow its changes, so it does not need a copy of the data.
const store = this.et2.getWidgetById("comments").store;
const draw = () => myTimeline.draw(store.visible); // myTimeline: any view of your own
store.addEventListener("change", draw);
draw();
Content
$content['comments'] is the list Student\Ui::_fixComments() makes: an empty first
entry (the header row of the old grid, ignored) followed by the comments. Per comment the list uses:
| field | used for |
|---|---|
comment_id, comment_starttime, comment_stoptime |
identity and order (start, stop, id) |
account_id |
author; the name comes from sel_options['account_id'] |
comment_added |
[text, user1, reply1, user2, reply2, ...] |
comment_cat, comment_cat_type |
"main" or "main:sub", the label comes from
sel_options['catsOptions']; type sc is a special category
|
comment_color |
colour bar on the tile |
comment_marked, class |
marked-area icon; commentOwner, commentMarked,
commentAttachments, cat-<id>
|
comment_is_staff |
staff tag |
comment_updated |
the “new”, date and search filters |
"/apps/smallpart/<c>/<v>/<lid>/comments/<id>/" |
the attachments the server found |
The store adds the classes the server adds (commentOwner for staff and the author,
commentMarked, one cat-<id> per category), so a comment pushed without a
class looks like one that came with the page. is_staff is read from the content,
the user from egw.user('account_id').
Row template
The structure of a tile is an ordinary .xet file,
smallpart/templates/default/comment.rows.xet, template id smallpart.comment.rows;
the template attribute names it and the datagrid reads it the way it reads a nextmatch row
template. The template says what a tile shows and which row fields it binds to - see it there.
getRowData() is what builds those fields from a comment.
Notes for editing the template:
- Use
$fieldbindings, not$row_cont[...]. -
A widget that is only needed for some rows gets
disabled="!$flag", as the attachments dropdown does: the other rows do not instantiate it. -
smallpart-cats-selectdoes not work inside a row (it loads its options when it is created from the template); the category is a plain chip for that reason. -
Row styles live in
SmallPartCommentList.styles.tsand are adopted into the datagrid’s shadow root. A stylesheet of the page cannot reach rows. -
After editing the
.xeton a dev instance the browser keeps the old copy (the template URL carries a fixed?download=number): refetch it withfetch(url, {cache: 'reload'})and reload.
What the user can do
| gesture | result |
|---|---|
| click the bubble (author and staff) | smallpart-comment-action edit |
| click the reply button | retweet |
| click anywhere else on a tile | open, but not on a link, button, dropdown or the attachments |
| right click | the server’s actions as a menu |
| long press (touch, 500 ms, no movement) | the same menu |
The menu is built when it is opened from the action definitions the server put on the
widget (Student\Ui::get_actions()): Open and Add, Edit and Retweet, Delete, in those groups,
with the same menu class the old popup used. An action with enableClass (Edit and Delete:
commentOwner) is enabled for comments with that class. Choosing an item runs the action’s
onExecute with [{id, data: comment}], which is what the old rows handed over.
There is no action object per tile.
The widget does not link the server’s actions to itself (_link_actions() is
empty). Linking registers one action object for the whole list whose click runs “open” with no comment;
student_openComment() then sought the video to undefined and collapsed it. The
list handles clicks itself instead.
Browsers that send a contextmenu event for a long press (Android) use it; for the others (iOS)
the list times the press itself. The tap that ends a long press does not also open the comment.
Events
All bubble and are composed.
| event | detail | when |
|---|---|---|
smallpart-comment-action |
{action: "open" | "edit" | "retweet", id, comment} |
a click on a tile, see above |
smallpart-comment-list-ready |
none | the list was connected; its store holds the comments already |
The context menu does not fire an event: its items run the server’s actions directly.
Methods
| method | purpose |
|---|---|
setComments(list) |
replace everything, eg. with the list an ajax call returned |
upsertComment(comment) |
add a comment or replace the one with the same id |
removeComment(id) |
remove one; an unknown id changes nothing |
getComment(id) |
the comment as the store holds it |
scrollToComment(id, {highlight}) |
scroll to it (it may be far outside what is drawn) and highlight it; false if it is not
in the list
|
highlight(id | null) |
highlight one tile or none |
followTime(seconds) |
scroll to the comment starting in this second, once; returns the comments reached |
getRowData(id) |
what a row binds to |
upsertComment and removeComment are not called upsert and
remove on purpose: an element already has a remove() that the framework calls when
it tears a widget down.
scrollToComment asks the virtualizer for the tile twice, a frame apart: the first call makes it
draw a tile far away, the second puts it at the top. (Et2Datagrid.focusRowById() only scrolls
tiles that are already drawn.)
The store
list.store is a SmallPartCommentStore, plain data with no DOM that anything
showing comments can subscribe to:
list.store.addEventListener("change", e => e.detail); // {kind: "reset"|"add"|"update"|"remove"|"filter", ids}
list.store.all; // every comment, in order
list.store.visible; // the ones passing the filters; the same array until something changes
list.store.visibleIds; // Set of ids as strings
list.store.numberOf(id); // 1-based position among all comments
list.store.batch(() => { ... }); // one "change" event for what happens inside
- Order is start time, then stop time, then comment id, kept on every insert and update.
-
userIdandisStaffdecide who getscommentOwner. The list sets them from the page. -
extraSearchTextis a hook returning more text (the author’s name) searched when “search all” is on. -
app.tslistens tochangeto rebuild the slider tags, the staff slider and the participants filter once per frame.
Filters
store.setFilter(name, spec) sets one filter, null (or an empty spec) clears it,
clearFilters() clears all. All active filters have to match (AND).
| name | spec | shows |
|---|---|---|
color |
{mode: "ac" | "lf" | "all"} |
no live comments / only live comments (categories lf, lfc) |
{mode: "new", since} |
comments updated after since |
|
participants |
{accounts: [...]} |
comments written or replied to by these accounts |
group |
{accounts: [...]} |
comments written by these accounts; an empty list shows nothing |
cats |
{cats: ["12", "free", ...]} |
comments with one of these main or sub categories |
date |
{from, to} |
last updated on a day in the range, both days included |
search |
{query, all} |
text in the comment and its replies, case-insensitive, not a pattern;
all adds extraSearchText
|
marked, attachments |
{enabled: true} |
comments with marked areas / attachments |
store.specialCategories = true shows the special-category comments
instead of the regular ones (they are hidden otherwise).
store.matchesFilters(id) says whether the filters (not the special view) hide a comment, which
is what the slider uses to hide tags. Dates are compared as written, as calendar days, so the time zone of
the machine does not matter.
Following the video
store.advanceTo(seconds) returns the visible comments starting in that second that it has not
returned yet, in order. A seek backwards makes the comments from the new position on available again;
resetCursor() starts over (the app calls it when playback starts).
list.followTime() is advanceTo() plus scrollToComment() of the last
one. The app calls it from the video’s time-update event while the video is not paused.
Live updates
app.ts pushComment() calls upsertComment() for add,
update, edit and retweet, and removeComment() for
delete. A delete push carries no comment, so the id in the push id
(course:video:comment) names it. The list redraws once per frame: when only the content of
comments changed it refreshes those rows, otherwise it gives the datagrid the new list.
student_updateComments() (the ajax replies) calls setComments().
Attributes
| attribute / property | type | default | purpose |
|---|---|---|---|
template |
string | smallpart.comment.rows |
the row template |
extendedInfo |
boolean | false |
wide info column with number, author and Start/End labels; the extended attribute of
the datagrid, so it is CSS only and redraws nothing
|
emptyStateText |
string | “No comments” | shown when the list is empty |
value |
array | from the content | the comments; setting it replaces them |
The widget is display-only: getValue() is null and it never makes a form dirty.
Things to know
- The tests run with
npm run jstest -- --group smallpart. - Live checks need a tab that is actually rendering: a background tab runs no animation frames, so scrolling and drawing do not happen there and its timers are throttled.
- The datagrid re-renders all of its drawn rows when one is refreshed (about 40 rows, about 16 ms); that does not grow with the number of comments.
-
The staff slider (
et2_widget_videooverlay_slider_controller.ts) and the slider tags (et2_widget_videobar.ts), whichapp.tsrebuilds when the store changes, have their own tests insmallpart/js/test/. Not covered by automated tests: touch with a real finger, and one user’s push reaching another user’s session. -
The timeline strip (one canvas for all comments instead of a tag each) is not built; see
doc/ai/projects/smallpart-comment-list.md.
Properties
| Name | Description | Reflects | Type | Default |
|---|---|---|---|---|
emptyStateText
empty-state-text
|
Shown when there is no comment to show |
string
|
""
|
|
extendedInfo
extended-info
|
Show the number and author of each comment |
boolean
|
false
|
|
LONG_PRESS_MS
|
How long a finger has to stay on a tile, and how far it may wander, to count as a long press |
number
|
500
|
|
template
|
Name of the template (a .xet) giving the structure of a comment’s tile |
string
|
"smallpart.comment.rows"
|
|
value
|
The comments, as the server sends them (the first entry of that list is an empty one, it is ignored) |
any[]
|
[]
|
Learn more about attributes and properties.
Inherited properties (21)
Et2Widget
| Name | Description |
|---|---|
accesskey |
Accesskey provides a hint for generating a keyboard shortcut for the current element. The attribute value must consist of a single printable character. |
actions |
Set Actions on the widget Each action is defined as an object: move: { type: “drop”, acceptedTypes: “mail”, icon: “move”, caption: “Move to” onExecute: javascript:mail_move” } This will turn the widget into a drop target for “mail” drag types. When “mail” drag types are dropped, the global function mail_move(egwAction action, egwActionObject sender) will be called. The ID of the dragged “mail” will be in sender.id, some information about the sender will be in sender.context. The etemplate2 widget involved can typically be found in action.parent.data.widget, so your handler can operate in the widget context easily. The location varies depending on your action though. It might be action.parent.parent.data.widget To customise how the actions are handled for a particular widget, override _link_actions(). It handles the more widget-specific parts. |
align |
Used by Et2Box to determine alignment. Allowed values are left, right |
class |
CSS Class. This class is applied to the outside, on the web component itself. Due to how WebComponents work, this might not change anything inside the component. |
data |
Set the dataset from a CSV |
deferredProperties |
Any attribute that refers to row content cannot be resolved immediately, but some like booleans cannot stay a string because it’s a boolean attribute. We store them for later, and parse when they’re fully in their row. If you are creating a widget that can go in a nextmatch row, and it has boolean attributes that can change for each row, add those attributes into deferredProperties |
disabled |
Defines whether this widget is visibly disabled. The widget is still visible, but clearly cannot be interacted with. Widgets disabled in the template will not return a value to the application code, even if re-enabled via javascript before submitting. To allow a disabled widget to be re-enabled and return a value, disable via javascript in the app’s et2_ready() instead of an attribute in the template file. |
dom_id |
Get the actual DOM ID, which has been prefixed to make sure it’s unique. |
full |
Take every column of a layout, the same as span="all". Boolean rather than a bare
marker so it can be driven from content the way the rest of the template can -
full="@is_wide" resolves through the array manager like any other boolean attribute.
That does mean it needs a value: write full="true", since an empty
full="" resolves to false and removes the attribute again. Reflects for the same
reason grow does.
|
grow |
How much of a layout’s leftover space this widget should take. Only means anything inside a
container that has a layout (see Et2LayoutController): a bare grow or
grow="1" takes an equal share of whatever the non-growing children left over,
grow="2" takes twice what a grow="1" sibling does. It has to reflect.
The layout stylesheet matches the [grow] attribute - CSS cannot see a property - and
transformAttributes() only calls setAttribute() for an attribute the element already carries or a
property declared as reflecting. Widgets built from a .xet are created with
document.createElement() and handed their attributes as an object, so they carry none at that
point: without this declaration grow="1" lands as an inert JS property and nothing in
the layout ever matches it.
|
hidden |
The widget is not visible. As far as the user is concerned, the widget does not exist. Widgets hidden with an attribute in the template may not be created in the DOM, and will not return a value. Widgets can be hidden after creation, and they may return a value if hidden this way. |
id |
Get the ID of the widget |
label |
The label of the widget This is usually displayed in some way. It’s also important for accessability. This is defined in the parent somewhere, and re-defining it causes labels to disappear |
noLang |
Disable any translations for the widget |
parentId |
Parent is different than what is specified in the template / hierarchy. Widget ID of another node to insert this node into instead of the normal location |
span |
How many of a layout’s columns this widget should take.
span="all" is every column wherever the widget landed, span="end" /
span="*" is from its own column to the end of its line. Only means anything inside a
container that has a layout, and only for the column layouts -
stack gives every child the full width anyway. The same name is also read straight
off the XML node by the legacy <grid> widget, for the unrelated job of counting
table cells to merge; that path never looks at the widget, so declaring it here does not disturb
it. Reflecting it does mean a legacy grid’s span="all" cells now carry the attribute
too, which matches nothing outside a layout. Reflects for the same reason grow does.
|
statustext |
Tooltip which is shown for this element on hover |
styles |
WebComponent * |
options |
Get property-values as object |
supportedWidgetClasses |
et2_widget compatability |
LitElement
| Name | Description |
|---|---|
updateComplete |
A read-only promise that resolves when the component has finished updating. |
Events
| Name | Description | Event Detail |
|---|---|---|
smallpart-comment-list-ready |
The list was connected to the page. Its store already holds the comments, so whatever shows them
elsewhere (the video’s slider) can subscribe to list.store now. Bubbles and is
composed.
|
CustomEvent
|
smallpart-comment-action |
The user did something to a comment: clicked its edit bubble (edit), its reply button
(retweet) or the tile itself (open). The detail names the comment by id
and carries the comment as the list holds it. Bubbles and is composed; the owner of the list decides
what happens. The context menu does not use it, its items run their actions directly.
|
CustomEvent<{action: "open" | "edit" | "retweet", id: string, comment: object}>
|
Learn more about events.
Methods
| Name | Description | Arguments |
|---|---|---|
followTime()
|
The video is at time seconds: scroll to the comment starting there, if there is one.
|
time: number
|
getComment()
|
The comment as the store holds it, or undefined |
id: number | string
|
getRowData()
|
What the datagrid draws for a comment: the comment, plus what the row template shows that is not in it. |
rowId: string
|
highlight()
|
Highlight one comment (or none, with null) |
id: number | string | null
|
isDirty()
|
A display widget: it never contributes to a submit | - |
scrollToComment()
|
Scroll a comment into view and highlight it. The comment may be far outside what is drawn: the first scroll makes the datagrid draw it, the second one, a frame later, puts it at the top. |
id: number | string, options: { highlight? : boolean }
|
setComments()
|
Replace all comments, eg. with the list an ajax call returned |
comments: any[] | null | undefined
|
Learn more about methods.
Inherited methods (22)
Et2Widget
| Name | Description |
|---|---|
checkCreateNamespace() |
Checks whether a namespace exists for this element in the content array. If yes, an own perspective of the content array is created. If not, the parent content manager is used. Constructor attributes are passed in case a child needs to make decisions |
clone() |
Creates a copy of this widget. |
createElementFromNode() |
Create a et2_widget from an XML node. First the type and attributes are read from the node. Then the readonly & modifications arrays are checked for changes specific to the loaded data. Then the appropriate constructor is called. After the constructor returns, the widget has a chance to further initialize itself from the XML node when the widget’s loadFromXML() method is called with the node. |
getArrayMgr() |
Returns the array manager object for the given part |
getArrayMgrs() |
Returns an associative array containing the top-most array managers. |
getChildren() |
Get child widgets Use |
getInstanceManager() |
Returns the instance manager |
getPath() |
Returns the path into the data array. By default, array manager takes care of this, but some extensions need to override this |
getRoot() |
Returns the base widget Usually this is the same as getInstanceManager().widgetContainer |
loadFromXML() |
Loads the widget tree from an XML node |
loadingFinished() |
Needed for legacy compatability. |
parseXMLAttrs() |
The parseXMLAttrs function takes an XML DOM attributes object and adds the given attributes to the _target associative array. This function also parses the legacyOptions. N.B. This is only used for legacy widgets. WebComponents use transformAttributes() and do their own handling of attributes. |
set_label() |
NOT the setter, since we cannot add to the DOM before connectedCallback() TODO: This is not best practice. Should just set property, DOM modification should be done in render https://lit-element.polymer-project.org/guide/templates#design-a-performant-template |
setArrayMgr() |
Sets the array manager for the given part |
setArrayMgrs() |
Sets all array manager objects - this function can be used to set the root array managers of the container object. |
setInstanceManager() |
Set the instance manager Normally this is not needed as it’s set on the top-level container, and we just return that reference |
transformAttributes() |
Pick the comments up out of the content under our id |
_handleClick() |
Click handler calling custom handler set via onclick attribute to this.onclick |
destroy() |
et2_widget compatability |
set_class() |
Set the widget class |
set_disabled() |
Wrapper on this.disabled because legacy had it. |
set_statustext() |
supports legacy set_statustext |