From 336f4c8960ec94b4a16d18d9e28c6bebd240f2f3 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" <me@ron.dev> Date: Thu, 25 Jan 2024 17:42:13 +0800 Subject: [PATCH 1/5] bumped version preparing for next release with improvements --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5765337..e1d10ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/ember-ui", - "version": "0.2.9", + "version": "0.2.10", "description": "Fleetbase UI provides all the interface components, helpers, services and utilities for building a Fleetbase extension into the Console.", "keywords": [ "fleetbase-ui", From 8ed728464836b42447e91923fc8b3ece84808a65 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" <me@ron.dev> Date: Thu, 25 Jan 2024 19:01:29 +0800 Subject: [PATCH 2/5] few minor patches to item --- .../components/layout/header/dropdown/item.hbs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/addon/components/layout/header/dropdown/item.hbs b/addon/components/layout/header/dropdown/item.hbs index fe737b9..2481d44 100644 --- a/addon/components/layout/header/dropdown/item.hbs +++ b/addon/components/layout/header/dropdown/item.hbs @@ -1,9 +1,9 @@ {{#if this.isTextOnly}} - <div class="px-1 flex flex-row {{@item.wrapperClass}}"> + <div class={{if @item.overwriteWrapperClass @item.wrapperClass (concat @item.wrapperClass " px-1 flex flex-row")}}> {{#if @item.icon}} - <FaIcon @icon={{@item.icon}} @size={{or @item.iconSize "sm"}} class="mr-2" /> + <FaIcon @icon={{@item.icon}} @size={{or @item.iconSize "sm"}} class={{or @item.iconClass "mr-2"}} /> {{/if}} - <div class="inline-flex flex-col"> + <div class="inline-flex flex-col {{@item.inlineClass}}"> {{#if (is-array @item.text)}} {{#each @item.text as |line|}} <div class="{{@item.class}}">{{line}}</div> @@ -16,10 +16,10 @@ {{/if}} {{#if this.isLink}} - <div class="px-1 {{@item.wrapperClass}}"> + <div class={{if @item.overwriteWrapperClass @item.wrapperClass (concat @item.wrapperClass " px-1")}}> <LinkTo @route={{@item.route}} disabled={{@item.disabled}} class="next-dd-item {{@item.class}}"> {{#if @item.icon}} - <FaIcon @icon={{@item.icon}} @size={{or @item.iconSize "sm"}} class="mr-2" /> + <FaIcon @icon={{@item.icon}} @size={{or @item.iconSize "sm"}} class={{or @item.iconClass "mr-2"}} /> {{/if}} <span>{{@item.text}}</span> </LinkTo> @@ -27,10 +27,10 @@ {{/if}} {{#if this.isAnchor}} - <div class="px-1 {{@item.wrapperClass}}"> + <div class={{if @item.overwriteWrapperClass @item.wrapperClass (concat @item.wrapperClass " px-1")}}> <a href={{@item.href}} class="next-dd-item {{if @item.disabled 'disabled'}} {{@item.class}}" target={{@item.target}} disabled={{@item.disabled}} {{on "click" (fn @onAction @item.action @item.params)}}> {{#if @item.icon}} - <FaIcon @icon={{@item.icon}} @size={{or @item.iconSize "sm"}} class="mr-2" /> + <FaIcon @icon={{@item.icon}} @size={{or @item.iconSize "sm"}} class={{or @item.iconClass "mr-2"}} /> {{/if}} <span>{{@item.text}}</span> </a> @@ -38,10 +38,10 @@ {{/if}} {{#if this.isInteractive}} - <div class="px-1 {{@item.wrapperClass}}"> + <div class={{if @item.overwriteWrapperClass @item.wrapperClass (concat @item.wrapperClass " px-1")}}> <a href="javascript:;" class="next-dd-item {{if this.active 'active'}}" disabled={{@item.disabled}} {{on "click" @item.onClick}} ...attributes> {{#if @item.icon}} - <FaIcon @icon={{@item.icon}} @size={{or @item.iconSize "sm"}} class="mr-2" /> + <FaIcon @icon={{@item.icon}} @size={{or @item.iconSize "sm"}} class={{or @item.iconClass "mr-2"}} /> {{/if}} <span>{{@item.text}}</span> </a> From 1c29773be451241d3d67fe6a6ceb63ca58d46691 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" <me@ron.dev> Date: Thu, 1 Feb 2024 15:02:27 +0800 Subject: [PATCH 3/5] added comment thread component for subjects --- addon/components/comment-thread.hbs | 15 +++++ addon/components/comment-thread.js | 60 ++++++++++++++++++ addon/components/comment-thread/comment.hbs | 46 ++++++++++++++ addon/components/comment-thread/comment.js | 62 +++++++++++++++++++ addon/styles/layout/next.css | 10 +++ app/components/comment-thread.js | 1 + app/components/comment-thread/comment.js | 1 + .../components/comment-thread-test.js | 26 ++++++++ .../components/comment-thread/comment-test.js | 26 ++++++++ 9 files changed, 247 insertions(+) create mode 100644 addon/components/comment-thread.hbs create mode 100644 addon/components/comment-thread.js create mode 100644 addon/components/comment-thread/comment.hbs create mode 100644 addon/components/comment-thread/comment.js create mode 100644 app/components/comment-thread.js create mode 100644 app/components/comment-thread/comment.js create mode 100644 tests/integration/components/comment-thread-test.js create mode 100644 tests/integration/components/comment-thread/comment-test.js diff --git a/addon/components/comment-thread.hbs b/addon/components/comment-thread.hbs new file mode 100644 index 0000000..991b7e8 --- /dev/null +++ b/addon/components/comment-thread.hbs @@ -0,0 +1,15 @@ +<div class="flex flex-col mb-4" ...attributes> + <Textarea @value={{this.input}} class="form-input w-full" placeholder={{t "component.comment-thread.comment-input-placeholder"}} rows={{3}} disabled={{not this.publishComment.isIdle}} /> + <div class="flex flex-row items-center justify-end mt-2"> + <Button @type="primary" @buttonType="button" @icon="paper-plane" @text={{t "component.comment-thread.publish-comment-button-text"}} @onClick={{perform this.publishComment}} @disabled={{or (not this.publishComment.isIdle) (not this.input)}} /> + </div> +</div> +<div> + {{#each this.comments as |comment|}} + {{#if (has-block)}} + {{yield (component "comment-thread/comment" comment=comment) comment}} + {{else}} + <CommentThread::Comment @comment={{comment}} /> + {{/if}} + {{/each}} +</div> \ No newline at end of file diff --git a/addon/components/comment-thread.js b/addon/components/comment-thread.js new file mode 100644 index 0000000..54fa438 --- /dev/null +++ b/addon/components/comment-thread.js @@ -0,0 +1,60 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { isArray } from '@ember/array'; +import { task } from 'ember-concurrency-decorators'; +import getWithDefault from '@fleetbase/ember-core/utils/get-with-default'; +import getModelName from '@fleetbase/ember-core/utils/get-model-name'; + +export default class CommentThreadComponent extends Component { + @service store; + @service notifications; + @tracked subject; + @tracked comments = []; + @tracked input = ''; + + constructor(owner, { subject, subjectType }) { + super(...arguments); + + this.subject = subject; + this.comments = subject.comments; + this.subjectType = subjectType ? subjectType : getModelName(subject); + } + + @task *publishComment() { + if (this.isCommentInvalid(this.input)) { + return; + } + + let comment = this.store.createRecord('comment', { + content: this.input, + subject_uuid: this.subject.id, + subject_type: this.subjectType, + }); + + yield comment.save(); + yield this.reloadComments.perform(); + + this.input = ''; + } + + @task *reloadComments() { + this.comments = yield this.store.query('comment', { subject_uuid: this.subject.id, sort: '-created_at' }); + } + + isCommentInvalid(comment) { + if (!comment) { + this.notification.warning(this.intl.t('fleet-ops.operations.orders.index.view.comment-input-empty-notification')); + return true; + } + + // make sure comment is atleast 12 characters + if (typeof comment === 'string' && comment.length < 2) { + this.notification.warning(this.intl.t('fleet-ops.operations.orders.index.view.comment-min-length-notification')); + return true; + } + + return false; + } +} diff --git a/addon/components/comment-thread/comment.hbs b/addon/components/comment-thread/comment.hbs new file mode 100644 index 0000000..09f07d3 --- /dev/null +++ b/addon/components/comment-thread/comment.hbs @@ -0,0 +1,46 @@ +<div class="thread-comment flex flex-row p-1 space-x-3" ...attributes> + <div class="thread-comment-avatar-wrapper w-18 flex flex-col items-center"> + <Image src={{this.comment.author.avatar_url}} @fallbackSrc={{config "defaultValues.userImage"}} alt={{this.comment.author.name}} class="w-8 h-8 rounded-full" /> + </div> + <div class="thread-comment-content-wrapper flex-1"> + <div class="thread-comment-author flex flex-row items-center"> + <div class="thread-comment-author-name text-sm dark:text-white text-black font-bold mr-1.5">{{this.comment.author.name}}</div> + <div class="thread-comment-created-at dark:text-gray-300 text-gray-600 text-xs">{{t "component.comment-thread.comment-published-ago" createdAgo=this.comment.createdAgo}}</div> + </div> + <div class="thread-comment-conent-paragraph-wrapper mt-2"> + {{#if this.editing}} + <Textarea @value={{this.comment.content}} class="form-input w-full" placeholder={{t "component.comment-thread.comment-reply-placeholder"}} rows={{2}} disabled={{not this.updateComment.isIdle}} /> + <div class="mt-2 flex flex-row items-center justify-end"> + <Button @type="primary" @buttonType="button" @icon="save" @size="xs" @iconSize="xs" @iconClass="text-xs" @text={{t "common.save"}} @onClick={{perform this.updateComment}} @disabled={{or (not this.updateComment.isIdle) (not this.comment.content)}} /> + </div> + {{else}} + <p class="thread-comment-conent-paragraph text-xs text-gray-900 dark:text-gray-100">{{this.comment.content}}</p> + {{/if}} + </div> + <div class="thread-comment-conent-actions-wrapper flex flex-row items-center mt-2 space-x-4"> + <Button @wrapperClass="thread-comment-conent-actions-reply" @type="link" @buttonType="button" @size="xs" @iconSize="xs" @textClass="text-xs" @icon="reply" @text={{t "component.comment-thread.reply-comment-button-text"}} @onClick={{this.reply}} /> + {{#if this.comment.editable}} + <Button @wrapperClass="thread-comment-conent-actions-edit" @type="link" @buttonType="button" @size="xs" @iconSize="xs" @textClass="text-xs" @icon="edit" @text={{t "component.comment-thread.edit-comment-button-text"}} @onClick={{this.edit}} /> + <Button @wrapperClass="thread-comment-conent-actions-delete" @type="link" @buttonType="button" @size="xs" @iconSize="xs" @iconClass="text-xs text-danger" @textClass="text-xs text-danger" @icon="trash" @text={{t "component.comment-thread.delete-comment-button-text"}} @onClick={{this.delete}} /> + {{/if}} + </div> + {{#if this.replying}} + <div class="flex flex-col mt-3"> + <Textarea @value={{this.input}} class="form-input w-full" placeholder={{t "component.comment-thread.comment-reply-placeholder"}} rows={{2}} disabled={{not this.publishReply.isIdle}} /> + <div class="flex flex-row items-center justify-end mt-2 space-x-4"> + <Button @type="link" @buttonType="button" @size="xs" @text={{t "common.cancel"}} @onClick={{this.cancelReply}} @disabled={{not this.publishReply.isIdle}} /> + <Button @type="primary" @buttonType="button" @icon="reply" @size="xs" @iconSize="xs" @iconClass="text-xs" @text={{t "component.comment-thread.publish-reply-button-text"}} @onClick={{perform this.publishReply}} @disabled={{or (not this.publishReply.isIdle) (not this.input)}} /> + </div> + </div> + {{/if}} + <div class="thread-comment-replies mt-3"> + {{#each this.comment.replies as |reply|}} + {{#if (has-block)}} + {{yield (component "comment-thread/comment" comment=reply) reply}} + {{else}} + <CommentThread::Comment @comment={{reply}} /> + {{/if}} + {{/each}} + </div> + </div> +</div> \ No newline at end of file diff --git a/addon/components/comment-thread/comment.js b/addon/components/comment-thread/comment.js new file mode 100644 index 0000000..026c954 --- /dev/null +++ b/addon/components/comment-thread/comment.js @@ -0,0 +1,62 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { task } from 'ember-concurrency-decorators'; + +export default class CommentThreadCommentComponent extends Component { + @service store; + @tracked input = ''; + @tracked replying = false; + @tracked editing = false; + + constructor(owner, { comment }) { + super(...arguments); + + this.comment = comment; + } + + @action reply() { + this.replying = true; + } + + @action cancelReply() { + this.replying = false; + } + + @action edit() { + this.editing = true; + } + + @action cancelEdit() { + this.editing = false; + } + + @action delete() { + this.comment.destroyRecord(); + } + + @task *updateComment() { + yield this.comment.save(); + this.editing = false; + } + + @task *publishReply() { + let comment = this.store.createRecord('comment', { + content: this.input, + parent_comment_uuid: this.comment.id, + subject_uuid: this.comment.subject_uuid, + subject_type: this.comment.subject_type, + }); + + yield comment.save(); + yield this.reloadReplies.perform(); + + this.replying = false; + this.input = ''; + } + + @task *reloadReplies() { + this.comment = yield this.comment.reload(); + } +} diff --git a/addon/styles/layout/next.css b/addon/styles/layout/next.css index 8feb518..95ebf76 100644 --- a/addon/styles/layout/next.css +++ b/addon/styles/layout/next.css @@ -1724,10 +1724,16 @@ body[data-theme='light'] .next-dd-menu-seperator { height: 57px; } +.text-danger, +span.text-danger, a.text-danger { @apply text-red-500; } +.text-danger:active, +.text-danger:hover, +span.text-danger:active, +span.text-danger:hover, a.text-danger:active, a.text-danger:hover { @apply text-red-400; @@ -1855,4 +1861,8 @@ input.order-list-overlay-search:focus { padding-top: 0.5rem; padding-bottom: 0.5rem; max-width: 1200px; +} + +.thread-comment-conent-paragraph-wrapper { + min-height: 1.75rem; } \ No newline at end of file diff --git a/app/components/comment-thread.js b/app/components/comment-thread.js new file mode 100644 index 0000000..8b188bd --- /dev/null +++ b/app/components/comment-thread.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/comment-thread'; diff --git a/app/components/comment-thread/comment.js b/app/components/comment-thread/comment.js new file mode 100644 index 0000000..88c68cd --- /dev/null +++ b/app/components/comment-thread/comment.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/comment-thread/comment'; diff --git a/tests/integration/components/comment-thread-test.js b/tests/integration/components/comment-thread-test.js new file mode 100644 index 0000000..19f657c --- /dev/null +++ b/tests/integration/components/comment-thread-test.js @@ -0,0 +1,26 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | comment-thread', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + // Set any properties with this.set('myProperty', 'value'); + // Handle any actions with this.set('myAction', function(val) { ... }); + + await render(hbs`<CommentThread />`); + + assert.dom().hasText(''); + + // Template block usage: + await render(hbs` + <CommentThread> + template block text + </CommentThread> + `); + + assert.dom().hasText('template block text'); + }); +}); diff --git a/tests/integration/components/comment-thread/comment-test.js b/tests/integration/components/comment-thread/comment-test.js new file mode 100644 index 0000000..b71e953 --- /dev/null +++ b/tests/integration/components/comment-thread/comment-test.js @@ -0,0 +1,26 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | comment-thread/comment', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + // Set any properties with this.set('myProperty', 'value'); + // Handle any actions with this.set('myAction', function(val) { ... }); + + await render(hbs`<CommentThread::Comment />`); + + assert.dom().hasText(''); + + // Template block usage: + await render(hbs` + <CommentThread::Comment> + template block text + </CommentThread::Comment> + `); + + assert.dom().hasText('template block text'); + }); +}); From 4e735b761cb541682eac33d8d963a19fcbbe0f7b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" <me@ron.dev> Date: Thu, 1 Feb 2024 15:48:56 +0800 Subject: [PATCH 4/5] added spacer component and patched and completed comment thread component --- addon/components/comment-thread.hbs | 4 +- addon/components/comment-thread.js | 76 ++++++++++++++++++--- addon/components/comment-thread/comment.hbs | 5 +- addon/components/comment-thread/comment.js | 72 ++++++++++++++++++- addon/components/spacer.hbs | 1 + addon/components/spacer.js | 17 +++++ addon/styles/layout/next.css | 8 +++ app/components/spacer.js | 1 + tests/integration/components/spacer-test.js | 26 +++++++ 9 files changed, 196 insertions(+), 14 deletions(-) create mode 100644 addon/components/spacer.hbs create mode 100644 addon/components/spacer.js create mode 100644 app/components/spacer.js create mode 100644 tests/integration/components/spacer-test.js diff --git a/addon/components/comment-thread.hbs b/addon/components/comment-thread.hbs index 991b7e8..39c8e60 100644 --- a/addon/components/comment-thread.hbs +++ b/addon/components/comment-thread.hbs @@ -7,9 +7,9 @@ <div> {{#each this.comments as |comment|}} {{#if (has-block)}} - {{yield (component "comment-thread/comment" comment=comment) comment}} + {{yield (component "comment-thread/comment" comment=comment contextApi=this.context) comment}} {{else}} - <CommentThread::Comment @comment={{comment}} /> + <CommentThread::Comment @comment={{comment}} @contextApi={{this.context}} /> {{/if}} {{/each}} </div> \ No newline at end of file diff --git a/addon/components/comment-thread.js b/addon/components/comment-thread.js index 54fa438..b8259bb 100644 --- a/addon/components/comment-thread.js +++ b/addon/components/comment-thread.js @@ -1,27 +1,78 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; -import { action } from '@ember/object'; import { inject as service } from '@ember/service'; -import { isArray } from '@ember/array'; import { task } from 'ember-concurrency-decorators'; import getWithDefault from '@fleetbase/ember-core/utils/get-with-default'; import getModelName from '@fleetbase/ember-core/utils/get-model-name'; +/** + * Component to handle a thread of comments. + */ export default class CommentThreadComponent extends Component { + /** + * Service to handle data store operations. + * @service + */ @service store; + + /** + * Service for handling notifications. + * @service + */ @service notifications; + + /** + * Service for internationalization. + * @service + */ + @service intl; + + /** + * The subject related to the comments. + * @tracked + */ @tracked subject; + + /** + * Array of comments related to the subject. + * @tracked + */ @tracked comments = []; + + /** + * The text input for publishing a new comment. + * @tracked + */ @tracked input = ''; + /** + * Context object containing utility functions. + */ + context = { + isCommentInvalid: this.isCommentInvalid.bind(this), + reloadComments: () => { + return this.reloadComments.perform(); + }, + }; + + /** + * Constructor for the comment thread component. + * @param owner - The owner of the component. + * @param subject - The subject of the comment thread. + * @param subjectType - The type of the subject. + */ constructor(owner, { subject, subjectType }) { super(...arguments); this.subject = subject; - this.comments = subject.comments; + this.comments = getWithDefault(subject, 'comments', []); this.subjectType = subjectType ? subjectType : getModelName(subject); } + /** + * Asynchronous task to publish a new comment. + * @task + */ @task *publishComment() { if (this.isCommentInvalid(this.input)) { return; @@ -39,19 +90,28 @@ export default class CommentThreadComponent extends Component { this.input = ''; } + /** + * Asynchronous task to reload the comments related to the subject. + * @task + */ @task *reloadComments() { - this.comments = yield this.store.query('comment', { subject_uuid: this.subject.id, sort: '-created_at' }); + this.comments = yield this.store.query('comment', { subject_uuid: this.subject.id, withoutParent: 1, sort: '-created_at' }); } + /** + * Checks if a comment is invalid. + * @param {string} comment - The comment to validate. + * @returns {boolean} True if the comment is invalid, false otherwise. + */ isCommentInvalid(comment) { if (!comment) { - this.notification.warning(this.intl.t('fleet-ops.operations.orders.index.view.comment-input-empty-notification')); + this.notifications.warning(this.intl.t('component.comment-thread.comment-input-empty-notification')); return true; } - // make sure comment is atleast 12 characters - if (typeof comment === 'string' && comment.length < 2) { - this.notification.warning(this.intl.t('fleet-ops.operations.orders.index.view.comment-min-length-notification')); + // make sure comment is at least 2 characters + if (typeof comment === 'string' && comment.length <= 1) { + this.notifications.warning(this.intl.t('component.comment-thread.comment-min-length-notification')); return true; } diff --git a/addon/components/comment-thread/comment.hbs b/addon/components/comment-thread/comment.hbs index 09f07d3..92b76da 100644 --- a/addon/components/comment-thread/comment.hbs +++ b/addon/components/comment-thread/comment.hbs @@ -10,7 +10,8 @@ <div class="thread-comment-conent-paragraph-wrapper mt-2"> {{#if this.editing}} <Textarea @value={{this.comment.content}} class="form-input w-full" placeholder={{t "component.comment-thread.comment-reply-placeholder"}} rows={{2}} disabled={{not this.updateComment.isIdle}} /> - <div class="mt-2 flex flex-row items-center justify-end"> + <div class="flex flex-row items-center justify-end space-x-2 mt-2"> + <Button @type="link" @buttonType="button" @size="xs" @text={{t "common.cancel"}} @onClick={{this.cancelEdit}} @disabled={{not this.updateComment.isIdle}} /> <Button @type="primary" @buttonType="button" @icon="save" @size="xs" @iconSize="xs" @iconClass="text-xs" @text={{t "common.save"}} @onClick={{perform this.updateComment}} @disabled={{or (not this.updateComment.isIdle) (not this.comment.content)}} /> </div> {{else}} @@ -27,7 +28,7 @@ {{#if this.replying}} <div class="flex flex-col mt-3"> <Textarea @value={{this.input}} class="form-input w-full" placeholder={{t "component.comment-thread.comment-reply-placeholder"}} rows={{2}} disabled={{not this.publishReply.isIdle}} /> - <div class="flex flex-row items-center justify-end mt-2 space-x-4"> + <div class="flex flex-row items-center justify-end space-x-2 mt-2"> <Button @type="link" @buttonType="button" @size="xs" @text={{t "common.cancel"}} @onClick={{this.cancelReply}} @disabled={{not this.publishReply.isIdle}} /> <Button @type="primary" @buttonType="button" @icon="reply" @size="xs" @iconSize="xs" @iconClass="text-xs" @text={{t "component.comment-thread.publish-reply-button-text"}} @onClick={{perform this.publishReply}} @disabled={{or (not this.publishReply.isIdle) (not this.input)}} /> </div> diff --git a/addon/components/comment-thread/comment.js b/addon/components/comment-thread/comment.js index 026c954..e6fa4fd 100644 --- a/addon/components/comment-thread/comment.js +++ b/addon/components/comment-thread/comment.js @@ -4,44 +4,108 @@ import { action } from '@ember/object'; import { inject as service } from '@ember/service'; import { task } from 'ember-concurrency-decorators'; +/** + * Component to handle individual comments in a comment thread. + */ export default class CommentThreadCommentComponent extends Component { + /** + * Service to handle data store operations. + * @service + */ @service store; + + /** + * The text input for replying or editing comments. + * @tracked + */ @tracked input = ''; + + /** + * Flag to indicate if the reply interface is active. + * @tracked + */ @tracked replying = false; + + /** + * Flag to indicate if the edit interface is active. + * @tracked + */ @tracked editing = false; - constructor(owner, { comment }) { + /** + * The constructor for the comment component. + * @param owner - The owner of the component. + * @param comment - The comment data for the component. + */ + constructor(owner, { comment, contextApi }) { super(...arguments); - + this.comment = comment; + this.contextApi = contextApi; } + /** + * Activates the reply interface. + * @action + */ @action reply() { this.replying = true; } + /** + * Deactivates the reply interface. + * @action + */ @action cancelReply() { this.replying = false; } + /** + * Activates the edit interface. + * @action + */ @action edit() { this.editing = true; } + /** + * Deactivates the edit interface. + * @action + */ @action cancelEdit() { this.editing = false; } + /** + * Deletes the current comment. + * @action + */ @action delete() { this.comment.destroyRecord(); } + /** + * Asynchronous task to update the current comment. + * @task + */ @task *updateComment() { + if (this.contextApi && this.contextApi.isCommentInvalid(this.comment.content)) { + return; + } + yield this.comment.save(); this.editing = false; } + /** + * Asynchronous task to publish a reply to the current comment. + * @task + */ @task *publishReply() { + if (this.contextApi && this.contextApi.isCommentInvalid(this.input)) { + return; + } + let comment = this.store.createRecord('comment', { content: this.input, parent_comment_uuid: this.comment.id, @@ -56,6 +120,10 @@ export default class CommentThreadCommentComponent extends Component { this.input = ''; } + /** + * Asynchronous task to reload replies to the current comment. + * @task + */ @task *reloadReplies() { this.comment = yield this.comment.reload(); } diff --git a/addon/components/spacer.hbs b/addon/components/spacer.hbs new file mode 100644 index 0000000..a0616c2 --- /dev/null +++ b/addon/components/spacer.hbs @@ -0,0 +1 @@ +<div class="x-fleetbase-spacer" {{did-insert this.createSpacer}}>{{yield}}</div> \ No newline at end of file diff --git a/addon/components/spacer.js b/addon/components/spacer.js new file mode 100644 index 0000000..61fdd9d --- /dev/null +++ b/addon/components/spacer.js @@ -0,0 +1,17 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { camelize } from '@ember/string'; + +export default class SpacerComponent extends Component { + @action createSpacer(spacerElement) { + const properties = Object.keys(this.args); + + for (let index = 0; index < properties.length; index++) { + const prop = properties[index]; + + if (typeof this.args[prop] === 'string' || !isNaN(this.args[prop])) { + spacerElement.style[camelize(prop)] = this.args[prop]; + } + } + } +} diff --git a/addon/styles/layout/next.css b/addon/styles/layout/next.css index 95ebf76..f7ff45c 100644 --- a/addon/styles/layout/next.css +++ b/addon/styles/layout/next.css @@ -211,10 +211,15 @@ body[data-theme='dark'] .fleetbase-next-container { .next-content-overlay.full-height > .next-content-overlay-panel-container > .next-content-overlay-panel .next-content-overlay-panel-body { height: 100%; + min-height: 100%; + max-height: 100%; + padding-bottom: 300px; } .next-content-overlay.full-height > .next-content-overlay-panel-container > .next-content-overlay-panel .next-content-overlay-panel-body .next-content-overlay-panel-body-inner-wrapper { height: 100%; + min-height: 100%; + max-height: 100%; box-sizing: border-box; } @@ -253,6 +258,9 @@ body[data-theme='dark'] .fleetbase-next-container { .next-content-overlay > .next-content-overlay-panel-container { @apply absolute transform transition ease-in-out duration-500 pointer-events-auto; + height: 100%; + min-height: 100%; + max-height: 100%; } .next-content-overlay > .next-content-overlay-panel-container > .next-content-overlay-panel { diff --git a/app/components/spacer.js b/app/components/spacer.js new file mode 100644 index 0000000..f8dcba7 --- /dev/null +++ b/app/components/spacer.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/spacer'; diff --git a/tests/integration/components/spacer-test.js b/tests/integration/components/spacer-test.js new file mode 100644 index 0000000..bc39a63 --- /dev/null +++ b/tests/integration/components/spacer-test.js @@ -0,0 +1,26 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | spacer', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + // Set any properties with this.set('myProperty', 'value'); + // Handle any actions with this.set('myAction', function(val) { ... }); + + await render(hbs`<Spacer />`); + + assert.dom().hasText(''); + + // Template block usage: + await render(hbs` + <Spacer> + template block text + </Spacer> + `); + + assert.dom().hasText('template block text'); + }); +}); From 0a83e5effc4c60792daa0665a85ec0f7efc93825 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" <me@ron.dev> Date: Thu, 1 Feb 2024 18:47:36 +0800 Subject: [PATCH 5/5] completed styling updates and added new component and helpers --- addon/components/dropdown-button.hbs | 2 +- addon/components/file-icon.hbs | 10 ++- addon/components/file-icon.js | 26 ++----- addon/components/file.hbs | 41 +++++++++++ addon/components/file.js | 43 +++++++++++ addon/helpers/truncate-filename.js | 20 +++++ addon/styles/layout/legacy.css | 2 +- addon/styles/layout/next.css | 73 ++++++++++++++++++- app/components/file.js | 1 + app/helpers/truncate-filename.js | 1 + tests/integration/components/file-test.js | 26 +++++++ .../helpers/truncate-filename-test.js | 17 +++++ 12 files changed, 235 insertions(+), 27 deletions(-) create mode 100644 addon/components/file.hbs create mode 100644 addon/components/file.js create mode 100644 addon/helpers/truncate-filename.js create mode 100644 app/components/file.js create mode 100644 app/helpers/truncate-filename.js create mode 100644 tests/integration/components/file-test.js create mode 100644 tests/integration/helpers/truncate-filename-test.js diff --git a/addon/components/dropdown-button.hbs b/addon/components/dropdown-button.hbs index 4d5cfcc..11d486e 100644 --- a/addon/components/dropdown-button.hbs +++ b/addon/components/dropdown-button.hbs @@ -1,4 +1,4 @@ -<BasicDropdown class={{@wrapperClass}} @renderInPlace={{@renderInPlace}} @registerAPI={{@registerAPI}} @horizontalPosition={{@horizontalPosition}} @verticalPosition={{@verticalPosition}} @calculatePosition={{@calculatePosition}} @defaultClass={{@defaultClass}} @matchTriggerWidth={{@matchTriggerWidth}} @onOpen={{@onOpen}} @onClose={{@onClose}} {{did-insert this.onInsert}} as |dd|> +<BasicDropdown id={{@dropdownId}} class={{@wrapperClass}} @renderInPlace={{@renderInPlace}} @registerAPI={{@registerAPI}} @horizontalPosition={{@horizontalPosition}} @verticalPosition={{@verticalPosition}} @calculatePosition={{@calculatePosition}} @defaultClass={{@defaultClass}} @matchTriggerWidth={{@matchTriggerWidth}} @onOpen={{@onOpen}} @onClose={{@onClose}} {{did-insert this.onInsert}} as |dd|> <dd.Trigger class={{@triggerClass}}> {{#if @buttonComponent}} {{component @buttonComponent buttonComponentArgs=this.buttonComponentArgs text=@text class=(concat @buttonClass (if dd.isOpen ' dd-is-open')) wrapperClass=@buttonWrapperClass type=this.type active=@active size=this.buttonSize isLoading=@isLoading disabled=@disabled textClass=@textClass helpText=@helpText tooltipPlacement=@tooltipPlacement img=@img imgClass=@imgClass alt=@alt}} diff --git a/addon/components/file-icon.hbs b/addon/components/file-icon.hbs index f5ecbdd..5c65059 100644 --- a/addon/components/file-icon.hbs +++ b/addon/components/file-icon.hbs @@ -1,8 +1,10 @@ -<div class="file-icon file-icon-{{this.extension}}"> +<div class="file-icon file-icon-{{this.extension}}" ...attributes> <FaIcon @icon={{this.icon}} class={{@iconClass}} @size={{@iconSize}} /> - <span class="file-extension truncate"> - {{this.extension}} - </span> + {{#unless @hideExtension}} + <span class="file-extension truncate {{@fileExtensionClass}}"> + {{this.extension}} + </span> + {{/unless}} <div> {{yield}} </div> diff --git a/addon/components/file-icon.js b/addon/components/file-icon.js index 8e76b48..1195f47 100644 --- a/addon/components/file-icon.js +++ b/addon/components/file-icon.js @@ -16,24 +16,14 @@ export default class FileIconComponent extends Component { } getExtension(file) { - return getWithDefault( - { - 'application/vnd.ms-excel': 'xls', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xls', - 'vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xls', - 'vnd.ms-excel': 'xls', - 'text/csv': 'csv', - 'text/tsv': 'tsv', - xlsx: 'xls', - xls: 'xls', - xlsb: 'xls', - xlsm: 'xls', - docx: 'doc', - docm: 'doc', - }, - getWithDefault(file, 'extension', 'xls'), - 'xls' - ); + if (!file || (!file.original_filename && !file.url && !file.path)) { + return null; + } + + // Prefer to use the original filename if available, then URL, then path + const filename = file.original_filename || file.url || file.path; + const extensionMatch = filename.match(/\.(.+)$/); + return extensionMatch ? extensionMatch[1] : null; } getIcon(file) { diff --git a/addon/components/file.hbs b/addon/components/file.hbs new file mode 100644 index 0000000..c5488a9 --- /dev/null +++ b/addon/components/file.hbs @@ -0,0 +1,41 @@ +<div class="x-fleetbase-file" ...attributes> + <div class="x-fleetbase-file-wrapper"> + <div class="x-fleetbase-file-actions"> + <DropdownButton @dropdownId="x-fleetbase-file-actions-dropdown" @icon="ellipsis" @iconSize="xs" @iconPrefix={{@dropdownButtonIconPrefix}} @text={{@dropdownButtonText}} @size="xs" @horizontalPosition="left" @calculatePosition={{@dropdownButtonCalculatePosition}} @renderInPlace={{or @dropdownButtonRenderInPlace true}} @wrapperClass={{concat @dropdownButtonWrapperClass " " "next-nav-item-dropdown-button"}} @triggerClass={{@dropdownButtonTriggerClass}} @registerAPI={{@registerAPI}} @onInsert={{this.onDropdownButtonInsert}} as |dd|> + <div class="next-dd-menu mt-0i" role="menu" aria-orientation="vertical" aria-labelledby="user-menu"> + <div class="px-1"> + <div class="text-sm flex flex-row items-center px-3 py-1 rounded-md my-1 text-gray-800 dark:text-gray-300"> + {{t "component.file.dropdown-label"}} + </div> + </div> + <div class="next-dd-menu-seperator"></div> + <div role="group" class="px-1"> + {{!-- template-lint-disable no-nested-interactive --}} + <a href="javascript:;" role="menuitem" class="next-dd-item text-danger" {{on "click" (fn this.onDropdownItemClick "onDelete" dd)}}> + <span class="mr-1"> + <FaIcon @icon="trash" @prefix={{@dropdownButtonIconPrefix}} /> + </span> + {{t "common.delete"}} + </a> + </div> + </div> + </DropdownButton> + </div> + <div class="flex flex-1 flex-col justify-between items-center"> + <div class="flex-1"> + {{#if this.isImage}} + <Image src={{@file.url}} alt={{@file.original_filename}} class="x-fleetbase-file-preview rounded-md shadow-sm" /> + {{else}} + <div class="x-fleetbase-file-preview"> + <FileIcon @file={{@file}} @hideExtension={{true}} @iconSize="2xl" /> + </div> + {{/if}} + </div> + <div class="flex-1 overflow-hidden flex flex-col items-center justify-end"> + <div class="x-fleetbase-file-name"> + {{truncate-filename @file.original_filename}} + </div> + </div> + </div> + </div> +</div> \ No newline at end of file diff --git a/addon/components/file.js b/addon/components/file.js new file mode 100644 index 0000000..509ef96 --- /dev/null +++ b/addon/components/file.js @@ -0,0 +1,43 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; + +export default class FileComponent extends Component { + @tracked file; + @tracked isImage = false; + + constructor(owner, { file }) { + super(...arguments); + + this.file = file; + this.isImage = this.isImageFile(file); + } + + @action onDropdownItemClick(action, dd) { + if (typeof dd.actions === 'object' && typeof dd.actions.close === 'function') { + dd.actions.close(); + } + + if (typeof this.args[action] === 'function') { + this.args[action](this.file); + } + } + + isImageFile(file) { + if (!file || (!file.original_filename && !file.url && !file.path)) { + return false; + } + + const filename = file.original_filename || file.url || file.path; + const extensionMatch = filename.match(/\.(.+)$/); + + if (!extensionMatch) { + return false; + } + + const extension = extensionMatch[1].toLowerCase(); + const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'webp']; + + return imageExtensions.includes(extension); + } +} diff --git a/addon/helpers/truncate-filename.js b/addon/helpers/truncate-filename.js new file mode 100644 index 0000000..4e630b5 --- /dev/null +++ b/addon/helpers/truncate-filename.js @@ -0,0 +1,20 @@ +import { helper } from '@ember/component/helper'; + +export default helper(function truncateFilename([filename, maxLength = 20]) { + if (!filename || typeof filename !== 'string' || filename.length <= maxLength) { + return filename; + } + + const extensionMatch = filename.match(/\.(.+)$/); + const extension = extensionMatch ? extensionMatch[0] : ''; + const baseName = filename.slice(0, -extension.length); + + if (maxLength <= extension.length) { + // If the maximum length is less than or equal to the extension's length, return only the extension + return `...${extension}`; + } + + const truncated = baseName.slice(0, maxLength - extension.length - 3) + '...'; + + return truncated + extension; +}); diff --git a/addon/styles/layout/legacy.css b/addon/styles/layout/legacy.css index 18b528e..7458fbb 100644 --- a/addon/styles/layout/legacy.css +++ b/addon/styles/layout/legacy.css @@ -737,7 +737,7 @@ body[data-theme='dark'] .ui-combo-box .options-list a.combo-box-option:hover { .file-dropzone { @apply w-full rounded-lg px-4 py-8 bg-gray-50 text-gray-800 text-center flex flex-col items-center justify-center border-2 border-dashed border-gray-200; - min-height: 14rem; + min-height: 10rem; } body[data-theme='dark'] .file-dropzone { diff --git a/addon/styles/layout/next.css b/addon/styles/layout/next.css index f7ff45c..77fd08a 100644 --- a/addon/styles/layout/next.css +++ b/addon/styles/layout/next.css @@ -1,3 +1,9 @@ +body { + height: 100%; + min-height: 100%; + max-height: 100%; +} + body, html, button, a, * { cursor: default; } @@ -70,8 +76,9 @@ body[data-theme='dark'] .next-mobile-navbar .next-mobile-navbar-tabs > .next-mob @apply text-gray-800 flex flex-col bg-white border-t-0; overflow: hidden; width: 100%; - height: 100%; - min-height: 100%; + height: 100%; + min-height: 100%; + max-height: 100%; align-items: stretch; border-top: none; } @@ -997,7 +1004,7 @@ body[data-theme='dark'] .next-content-panel-wrapper .next-content-panel-containe height: 57px; flex-shrink: 0; min-width: 100vw; - max-width: 100vw; + max-width: 100%; -moz-user-select: none; user-select: none; -webkit-user-select: none; @@ -1873,4 +1880,64 @@ input.order-list-overlay-search:focus { .thread-comment-conent-paragraph-wrapper { min-height: 1.75rem; +} + +.x-fleetbase-file > .x-fleetbase-file-wrapper { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: .65rem; + max-width: 96px; + height: 135px; + max-height: 160px; + border: 1px #d1d5db solid; + border-radius: .5rem; +} + +body[data-theme="dark"] .x-fleetbase-file > .x-fleetbase-file-wrapper { + border: 1px #374151 solid; +} + +.x-fleetbase-file > .x-fleetbase-file-wrapper:hover { + border: 1px #3b82f6 solid; +} + +.x-fleetbase-file > .x-fleetbase-file-wrapper .x-fleetbase-file-name { + background-color: #3b82f6; + padding: .15rem; + border-radius: .15rem; + text-align: center; + font-size: 0.75rem; + line-height: 1rem; + color: #fff; +} + +.x-fleetbase-file > .x-fleetbase-file-wrapper .x-fleetbase-file-preview { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 4rem; + height: 4rem; + margin-bottom: .5rem; +} + +.x-fleetbase-file > .x-fleetbase-file-wrapper .x-fleetbase-file-actions { + display: flex; + flex-direction: row; + align-items: center; + justify-content: end; +} + +.x-fleetbase-file > .x-fleetbase-file-wrapper .x-fleetbase-file-actions #x-fleetbase-file-actions-dropdown { + position: absolute; + right: 0; + top: 0; + margin: 0.25rem; +} + +.x-fleetbase-file > .x-fleetbase-file-wrapper .x-fleetbase-file-actions #x-fleetbase-file-actions-dropdown .ember-basic-dropdown-trigger button.btn { + padding: 0.15rem 0.65rem; } \ No newline at end of file diff --git a/app/components/file.js b/app/components/file.js new file mode 100644 index 0000000..dfdb575 --- /dev/null +++ b/app/components/file.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/file'; diff --git a/app/helpers/truncate-filename.js b/app/helpers/truncate-filename.js new file mode 100644 index 0000000..b3d9880 --- /dev/null +++ b/app/helpers/truncate-filename.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/helpers/truncate-filename'; diff --git a/tests/integration/components/file-test.js b/tests/integration/components/file-test.js new file mode 100644 index 0000000..8ddc8eb --- /dev/null +++ b/tests/integration/components/file-test.js @@ -0,0 +1,26 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | file', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + // Set any properties with this.set('myProperty', 'value'); + // Handle any actions with this.set('myAction', function(val) { ... }); + + await render(hbs`<File />`); + + assert.dom().hasText(''); + + // Template block usage: + await render(hbs` + <File> + template block text + </File> + `); + + assert.dom().hasText('template block text'); + }); +}); diff --git a/tests/integration/helpers/truncate-filename-test.js b/tests/integration/helpers/truncate-filename-test.js new file mode 100644 index 0000000..4c278b3 --- /dev/null +++ b/tests/integration/helpers/truncate-filename-test.js @@ -0,0 +1,17 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Helper | truncate-filename', function (hooks) { + setupRenderingTest(hooks); + + // TODO: Replace this with your real tests. + test('it renders', async function (assert) { + this.set('inputValue', '1234'); + + await render(hbs`{{truncate-filename this.inputValue}}`); + + assert.dom().hasText('1234'); + }); +});