From 45a80fb3b17335689080161ca865e1694e0a5795 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 4 Jun 2026 21:12:52 +0800 Subject: [PATCH 1/3] v0.3.33 --- addon/components/button.hbs | 2 +- addon/components/chat-tray.hbs | 143 ++- addon/components/chat-tray.js | 176 +++- addon/components/chat-tray/compose-panel.hbs | 61 ++ addon/components/chat-tray/compose-panel.js | 11 + addon/components/chat-tray/contact-row.hbs | 17 + addon/components/chat-tray/contact-row.js | 14 + .../components/chat-tray/conversation-row.hbs | 64 ++ .../components/chat-tray/conversation-row.js | 88 ++ addon/components/chat-tray/inbox-panel.hbs | 40 + addon/components/chat-tray/inbox-panel.js | 7 + addon/components/comment-thread.hbs | 4 +- addon/components/comment-thread.js | 97 ++- addon/components/comment-thread/comment.hbs | 6 +- addon/components/comment-thread/comment.js | 30 +- addon/components/dashboard.hbs | 7 +- addon/components/docs-panel.hbs | 47 + addon/components/docs-panel.js | 22 + addon/components/layout/header.js | 6 +- addon/components/layout/resource/panel.hbs | 2 + addon/components/modal/default.hbs | 6 +- addon/components/toggle.hbs | 10 +- addon/components/toggle.js | 12 +- addon/components/user/pill.hbs | 16 + addon/components/user/pill.js | 7 + addon/helpers/format-duration.js | 6 +- addon/helpers/place-address.js | 6 + addon/services/docs-panel.js | 68 ++ addon/styles/addon.css | 9 + addon/styles/components/badge.css | 11 + addon/styles/components/chat.css | 814 ++++++++++++++++++ addon/styles/components/dashboard.css | 6 + .../components/resource-context-panel.css | 31 +- addon/styles/components/tab-navigation.css | 22 +- addon/utils/format-duration.js | 36 +- addon/utils/place-address.js | 48 ++ app/components/chat-tray/compose-panel.js | 1 + app/components/chat-tray/contact-row.js | 1 + app/components/chat-tray/conversation-row.js | 1 + app/components/chat-tray/inbox-panel.js | 1 + app/components/docs-panel.js | 1 + app/components/user/pill.js | 1 + app/helpers/format-duration.js | 2 +- app/helpers/place-address.js | 1 + app/services/docs-panel.js | 1 + app/utils/place-address.js | 1 + package.json | 2 +- .../integration/components/chat-tray-test.js | 156 +++- .../integration/components/dashboard-test.js | 51 +- .../integration/components/docs-panel-test.js | 29 + tests/integration/components/toggle-test.js | 21 +- .../helpers/format-duration-test.js | 3 +- .../integration/helpers/place-address-test.js | 36 + tests/unit/utils/format-duration-test.js | 11 +- tests/unit/utils/place-address-test.js | 16 + 55 files changed, 2104 insertions(+), 184 deletions(-) create mode 100644 addon/components/chat-tray/compose-panel.hbs create mode 100644 addon/components/chat-tray/compose-panel.js create mode 100644 addon/components/chat-tray/contact-row.hbs create mode 100644 addon/components/chat-tray/contact-row.js create mode 100644 addon/components/chat-tray/conversation-row.hbs create mode 100644 addon/components/chat-tray/conversation-row.js create mode 100644 addon/components/chat-tray/inbox-panel.hbs create mode 100644 addon/components/chat-tray/inbox-panel.js create mode 100644 addon/components/docs-panel.hbs create mode 100644 addon/components/docs-panel.js create mode 100644 addon/components/user/pill.hbs create mode 100644 addon/components/user/pill.js create mode 100644 addon/helpers/place-address.js create mode 100644 addon/services/docs-panel.js create mode 100644 addon/utils/place-address.js create mode 100644 app/components/chat-tray/compose-panel.js create mode 100644 app/components/chat-tray/contact-row.js create mode 100644 app/components/chat-tray/conversation-row.js create mode 100644 app/components/chat-tray/inbox-panel.js create mode 100644 app/components/docs-panel.js create mode 100644 app/components/user/pill.js create mode 100644 app/helpers/place-address.js create mode 100644 app/services/docs-panel.js create mode 100644 app/utils/place-address.js create mode 100644 tests/integration/components/docs-panel-test.js create mode 100644 tests/integration/helpers/place-address-test.js create mode 100644 tests/unit/utils/place-address-test.js diff --git a/addon/components/button.hbs b/addon/components/button.hbs index a644e0d0..cd3ad29c 100644 --- a/addon/components/button.hbs +++ b/addon/components/button.hbs @@ -13,7 +13,7 @@ > {{#if @isLoading}} - + {{/if}} {{#if this.showIcon}} diff --git a/addon/components/chat-tray.hbs b/addon/components/chat-tray.hbs index 8b03404c..324d772c 100644 --- a/addon/components/chat-tray.hbs +++ b/addon/components/chat-tray.hbs @@ -1,75 +1,70 @@ -
- +
-
- {{#each this.channels as |channel|}} -
- -
- {{#if (eq channel.created_by_uuid this.currentUser.id)}} -
- -
- {{/if}} -
-
- {{/each}} -
- - - - \ No newline at end of file + + + {{#if this.unreadCount}} + {{this.unreadCount}} + {{/if}} + + + + {{#if this.isInboxOpen}} + + + + {{/if}} + + {{#if this.isComposeOpen}} + + + + {{/if}} + diff --git a/addon/components/chat-tray.js b/addon/components/chat-tray.js index 07e74fd5..0e9798e1 100644 --- a/addon/components/chat-tray.js +++ b/addon/components/chat-tray.js @@ -2,9 +2,8 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; import { inject as service } from '@ember/service'; -import { isNone } from '@ember/utils'; +import { isBlank, isNone } from '@ember/utils'; import { task } from 'ember-concurrency'; -import calculatePosition from 'ember-basic-dropdown/utils/calculate-position'; import noop from '../utils/noop'; export default class ChatTrayComponent extends Component { @@ -15,8 +14,16 @@ export default class ChatTrayComponent extends Component { @service modalsManager; @service currentUser; @service media; + @service notifications; @tracked channels = []; @tracked unreadCount = 0; + @tracked isInboxOpen = false; + @tracked isComposeOpen = false; + @tracked searchQuery = ''; + @tracked contactSearchQuery = ''; + @tracked availableUsers = []; + @tracked selectedUsers = []; + @tracked newChatName = ''; @tracked notificationSound = new Audio('/sounds/message-notification-sound.mp3'); constructor() { @@ -31,29 +38,52 @@ export default class ChatTrayComponent extends Component { }); } - /** - * Calculate dropdown content position. - * - * @param {HTMLElement} trigger - * @param {HTMLElement} content - * @return {Object} - * @memberof ChatTrayComponent - */ - @action calculatePosition(trigger, content) { - if (this.media.isMobile) { - content.classList.add('is-mobile'); - const triggerRect = trigger.getBoundingClientRect(); - const top = triggerRect.height + triggerRect.top; - - return { style: { left: '0px', right: '0px', top, width: '100%' } }; + get inboxWidth() { + return this.media.isMobile ? '100%' : '420px'; + } + + get composeWidth() { + return this.media.isMobile ? '100%' : '360px'; + } + + get filteredChannels() { + const query = this.searchQuery.trim().toLowerCase(); + const channels = [...(this.channels ?? [])].sort((a, b) => { + const aUnread = a.unread_count ?? 0; + const bUnread = b.unread_count ?? 0; + + if (aUnread !== bUnread) { + return bUnread - aUnread; + } + + return new Date(b.updated_at ?? b.created_at ?? 0) - new Date(a.updated_at ?? a.created_at ?? 0); + }); + + if (isBlank(query)) { + return channels; } - return calculatePosition(...arguments); + return channels.filter((channel) => this.channelMatchesQuery(channel, query)); + } + + get selectedUserIds() { + return this.selectedUsers.map((user) => user.id); } - willDestroy() { - this.chat.off('chat.feed_updated', this.reloadChannelWithDelay.bind(this)); - super.willDestroy(...arguments); + get canCreateChat() { + return this.selectedUsers.length > 0 && this.createChat.isIdle; + } + + get defaultNewChatName() { + if (this.selectedUsers.length === 1) { + return this.selectedUsers[0].name; + } + + if (this.selectedUsers.length > 1) { + return this.selectedUsers.map((user) => user.name).join(', '); + } + + return 'Untitled Chat'; } listenAllChatChannels(channels) { @@ -120,13 +150,65 @@ export default class ChatTrayComponent extends Component { @action openChannel(chatChannelRecord) { this.chat.openChannel(chatChannelRecord); + this.closeInbox(); this.reloadChannels({ relisten: true }); } @action startChat() { - this.chat.createChatChannel('Untitled Chat').then((chatChannelRecord) => { - this.openChannel(chatChannelRecord); - }); + this.isComposeOpen = true; + this.loadAvailableUsers.perform(); + } + + @action toggleInbox() { + if (this.isInboxOpen) { + this.closeInbox(); + } else { + this.openInbox(); + } + } + + @action openInbox() { + this.isInboxOpen = true; + this.unlockAudio(); + } + + @action closeInbox() { + this.isInboxOpen = false; + this.isComposeOpen = false; + } + + @action closeCompose() { + this.isComposeOpen = false; + this.selectedUsers = []; + this.newChatName = ''; + this.contactSearchQuery = ''; + } + + @action setSearchQuery(event) { + this.searchQuery = event.target.value; + } + + @action setContactSearchQuery(event) { + this.contactSearchQuery = event.target.value; + this.loadAvailableUsers.perform({ query: this.contactSearchQuery }); + } + + @action setNewChatName(event) { + this.newChatName = event.target.value; + } + + @action toggleSelectedUser(user) { + const exists = this.selectedUsers.find((selectedUser) => selectedUser.id === user.id); + + if (exists) { + this.selectedUsers = this.selectedUsers.filter((selectedUser) => selectedUser.id !== user.id); + } else { + this.selectedUsers = [...this.selectedUsers, user]; + } + } + + @action removeSelectedUser(user) { + this.selectedUsers = this.selectedUsers.filter((selectedUser) => selectedUser.id !== user.id); } @action removeChannel(chatChannelRecord) { @@ -166,6 +248,36 @@ export default class ChatTrayComponent extends Component { } } + @task *loadAvailableUsers(params = {}) { + try { + const users = yield this.fetch.get('chat-channels/available-participants', params, { + normalizeToEmberData: true, + normalizeModelType: 'user', + }); + + this.availableUsers = users; + return users; + } catch (error) { + console.warn('Error loading chat participants:', error); + this.availableUsers = []; + } + } + + @task *createChat() { + if (this.selectedUsers.length === 0) { + return; + } + + try { + const chatChannelRecord = yield this.chat.createChatChannel(this.newChatName.trim() || this.defaultNewChatName, this.selectedUserIds); + this.closeCompose(); + this.openChannel(chatChannelRecord); + this.reloadChannels({ relisten: true }); + } catch (error) { + this.notifications.error(error.message ?? 'Unable to create chat.'); + } + } + playSoundForIncomingMessage(chatChannelRecord, data) { const sender = this.getSenderFromParticipants(chatChannelRecord); const isNotSender = sender ? sender.id !== data.sender_uuid : false; @@ -185,6 +297,22 @@ export default class ChatTrayComponent extends Component { this.unreadCount = channels.reduce((total, channel) => total + channel.unread_count, 0); } + channelMatchesQuery(channel, query) { + const lastMessage = channel.last_message; + const searchable = [ + channel.title, + channel.name, + lastMessage?.content, + ...(channel.participants ?? []).map((participant) => participant.name), + ...(channel.participants ?? []).map((participant) => participant.email), + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + + return searchable.includes(query); + } + reloadChannels(options = {}) { return this.chat.loadChannels.perform({ withChannels: (channels) => { diff --git a/addon/components/chat-tray/compose-panel.hbs b/addon/components/chat-tray/compose-panel.hbs new file mode 100644 index 00000000..29994357 --- /dev/null +++ b/addon/components/chat-tray/compose-panel.hbs @@ -0,0 +1,61 @@ +
+
+
+

New Chat

+ Select one or more teammates +
+ +
+ +
+ + + + {{#if this.hasSelectedUsers}} +
+ {{#each @selectedUsers as |user|}} + + {{user.name}} + {{user.name}} + + + {{/each}} +
+ {{/if}} + + + +
+ +
+ {{#if @isLoading}} +
+ +
+ {{else if this.hasUsers}} + {{#each @users as |user|}} + + {{/each}} + {{else}} +
+
+ +
+

No contacts found

+

Try a different name, email, or phone number.

+
+ {{/if}} +
+ +
+
+
diff --git a/addon/components/chat-tray/compose-panel.js b/addon/components/chat-tray/compose-panel.js new file mode 100644 index 00000000..06767655 --- /dev/null +++ b/addon/components/chat-tray/compose-panel.js @@ -0,0 +1,11 @@ +import Component from '@glimmer/component'; + +export default class ChatTrayComposePanelComponent extends Component { + get hasUsers() { + return (this.args.users ?? []).length > 0; + } + + get hasSelectedUsers() { + return (this.args.selectedUsers ?? []).length > 0; + } +} diff --git a/addon/components/chat-tray/contact-row.hbs b/addon/components/chat-tray/contact-row.hbs new file mode 100644 index 00000000..47623b58 --- /dev/null +++ b/addon/components/chat-tray/contact-row.hbs @@ -0,0 +1,17 @@ + diff --git a/addon/components/chat-tray/contact-row.js b/addon/components/chat-tray/contact-row.js new file mode 100644 index 00000000..19a68504 --- /dev/null +++ b/addon/components/chat-tray/contact-row.js @@ -0,0 +1,14 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; + +export default class ChatTrayContactRowComponent extends Component { + get isSelected() { + return (this.args.selectedUsers ?? []).some((user) => user.id === this.args.user?.id); + } + + @action toggle() { + if (typeof this.args.toggle === 'function') { + this.args.toggle(this.args.user); + } + } +} diff --git a/addon/components/chat-tray/conversation-row.hbs b/addon/components/chat-tray/conversation-row.hbs new file mode 100644 index 00000000..37b47474 --- /dev/null +++ b/addon/components/chat-tray/conversation-row.hbs @@ -0,0 +1,64 @@ +
+ +
+ {{#if this.isCreator}} + + {{/if}} +
+
diff --git a/addon/components/chat-tray/conversation-row.js b/addon/components/chat-tray/conversation-row.js new file mode 100644 index 00000000..96a856ad --- /dev/null +++ b/addon/components/chat-tray/conversation-row.js @@ -0,0 +1,88 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; + +export default class ChatTrayConversationRowComponent extends Component { + @service currentUser; + + get channel() { + return this.args.channel; + } + + get lastMessage() { + return this.channel?.last_message; + } + + get participants() { + return this.channel?.participants ?? []; + } + + get visibleParticipants() { + return this.participants.slice(0, 3); + } + + get hasVisibleParticipants() { + return this.visibleParticipants.length > 0; + } + + get extraParticipantCount() { + return Math.max((this.participants.length ?? 0) - this.visibleParticipants.length, 0); + } + + get title() { + return this.channel?.title || this.channel?.name || 'Untitled Chat'; + } + + get preview() { + const lastMessage = this.lastMessage; + + if (!lastMessage) { + return 'No messages yet'; + } + + if (lastMessage.content) { + return lastMessage.content; + } + + const attachmentCount = lastMessage.attachments?.length ?? 0; + return attachmentCount > 0 ? `${attachmentCount} attachment${attachmentCount === 1 ? '' : 's'}` : 'Sent a message'; + } + + get senderName() { + return this.lastMessage?.sender?.name; + } + + get timestamp() { + return this.lastMessage?.createdAgo || this.channel?.updatedAgo || this.channel?.createdAgo; + } + + get unreadCount() { + return this.channel?.unread_count ?? 0; + } + + get hasAttachments() { + return (this.lastMessage?.attachments?.length ?? 0) > 0; + } + + get isCreator() { + return this.channel?.created_by_uuid === this.currentUser.id; + } + + get isOpen() { + return this.args.isOpen === true; + } + + @action open() { + if (typeof this.args.open === 'function') { + this.args.open(this.channel); + } + } + + @action remove(event) { + event.stopPropagation(); + + if (typeof this.args.remove === 'function') { + this.args.remove(this.channel); + } + } +} diff --git a/addon/components/chat-tray/inbox-panel.hbs b/addon/components/chat-tray/inbox-panel.hbs new file mode 100644 index 00000000..e79609ce --- /dev/null +++ b/addon/components/chat-tray/inbox-panel.hbs @@ -0,0 +1,40 @@ +
+
+
+

Messages

+ {{pluralize @unreadCount "unread conversation"}} +
+
+ +
+
+ + + +
+ {{#if @isLoading}} +
+ +
+ {{else if this.hasChannels}} + {{#each @channels as |channel|}} + + {{/each}} + {{else}} +
+
+ +
+

No conversations found

+

Start a new chat or adjust your search.

+
+ {{/if}} +
+
diff --git a/addon/components/chat-tray/inbox-panel.js b/addon/components/chat-tray/inbox-panel.js new file mode 100644 index 00000000..3076d6c8 --- /dev/null +++ b/addon/components/chat-tray/inbox-panel.js @@ -0,0 +1,7 @@ +import Component from '@glimmer/component'; + +export default class ChatTrayInboxPanelComponent extends Component { + get hasChannels() { + return (this.args.channels ?? []).length > 0; + } +} diff --git a/addon/components/comment-thread.hbs b/addon/components/comment-thread.hbs index b9f48e81..ded1bc64 100644 --- a/addon/components/comment-thread.hbs +++ b/addon/components/comment-thread.hbs @@ -12,11 +12,11 @@
- {{#each this.comments as |comment|}} + {{#each this.visibleComments as |comment|}} {{#if (has-block)}} {{yield (component "comment-thread/comment" comment=comment contextApi=this.context) comment}} {{else}} {{/if}} {{/each}} -
\ No newline at end of file + diff --git a/addon/components/comment-thread.js b/addon/components/comment-thread.js index 2f4c6c89..d3993a76 100644 --- a/addon/components/comment-thread.js +++ b/addon/components/comment-thread.js @@ -53,6 +53,15 @@ export default class CommentThreadComponent extends Component { reloadComments: () => { return this.reloadComments.perform(); }, + publishReply: (comment, input) => { + return this.publishReply.perform(comment, input); + }, + updateComment: (comment) => { + return this.updateComment.perform(comment); + }, + deleteComment: (comment) => { + return this.deleteComment.perform(comment); + }, }; /** @@ -65,10 +74,22 @@ export default class CommentThreadComponent extends Component { super(...arguments); this.subject = subject; - this.comments = getWithDefault(subject, 'comments', []); + this.comments = getWithDefault(this.args, 'comments', getWithDefault(subject, 'comments', [])); this.subjectType = subjectType ? subjectType : getModelName(subject); } + get visibleComments() { + return this.args.comments ?? this.comments; + } + + get subjectUuid() { + return this.subject?.uuid; + } + + get subjectPublicId() { + return this.subject?.public_id ?? this.subject?.id ?? this.subject?.uuid; + } + /** * Asynchronous task to publish a new comment. * @task @@ -78,13 +99,18 @@ export default class CommentThreadComponent extends Component { return; } - let comment = this.store.createRecord('comment', { - content: this.input, - subject_uuid: this.subject.id, - subject_type: this.subjectType, - }); + if (typeof this.args.onPublishComment === 'function') { + yield this.args.onPublishComment(this.input, this.subject); + } else { + let comment = this.store.createRecord('comment', { + content: this.input, + subject_id: this.subjectPublicId, + subject_type: this.subjectType, + }); + + yield comment.save(); + } - yield comment.save(); yield this.reloadComments.perform(); this.input = ''; @@ -95,7 +121,62 @@ export default class CommentThreadComponent extends Component { * @task */ @task *reloadComments() { - this.comments = yield this.store.query('comment', { subject_uuid: this.subject.id, withoutParent: 1, sort: '-created_at' }); + if (typeof this.args.onReloadComments === 'function') { + this.comments = yield this.args.onReloadComments(this.subject); + } else { + const query = { + withoutParent: 1, + sort: '-created_at', + }; + + if (this.subjectUuid) { + query.subject_uuid = this.subjectUuid; + } else { + query.subject = this.subjectPublicId; + } + + if (this.subjectType) { + query.subject_type = this.subjectType; + } + + this.comments = yield this.store.query('comment', query); + } + } + + @task *publishReply(comment, input) { + if (typeof this.args.onPublishReply === 'function') { + yield this.args.onPublishReply(comment, input, this.subject); + yield this.reloadComments.perform(); + return; + } + + let reply = this.store.createRecord('comment', { + content: input, + parent_comment_uuid: comment.uuid ?? comment.public_id ?? comment.id, + }); + + yield reply.save(); + yield this.reloadComments.perform(); + } + + @task *updateComment(comment) { + if (typeof this.args.onUpdateComment === 'function') { + yield this.args.onUpdateComment(comment, this.subject); + yield this.reloadComments.perform(); + return; + } + + yield comment.save(); + } + + @task *deleteComment(comment) { + if (typeof this.args.onDeleteComment === 'function') { + yield this.args.onDeleteComment(comment, this.subject); + yield this.reloadComments.perform(); + return; + } + + yield comment.destroyRecord(); } /** diff --git a/addon/components/comment-thread/comment.hbs b/addon/components/comment-thread/comment.hbs index 9bacb88c..74025369 100644 --- a/addon/components/comment-thread/comment.hbs +++ b/addon/components/comment-thread/comment.hbs @@ -100,11 +100,11 @@
{{#each this.comment.replies as |reply|}} {{#if (has-block)}} - {{yield (component "comment-thread/comment" comment=reply) reply}} + {{yield (component "comment-thread/comment" comment=reply contextApi=this.contextApi) reply}} {{else}} - + {{/if}} {{/each}}
- \ No newline at end of file + diff --git a/addon/components/comment-thread/comment.js b/addon/components/comment-thread/comment.js index 63725ed7..d0fab764 100644 --- a/addon/components/comment-thread/comment.js +++ b/addon/components/comment-thread/comment.js @@ -81,6 +81,11 @@ export default class CommentThreadCommentComponent extends Component { * @action */ @action delete() { + if (this.contextApi?.deleteComment) { + this.contextApi.deleteComment(this.comment); + return; + } + this.comment.destroyRecord(); } @@ -93,7 +98,12 @@ export default class CommentThreadCommentComponent extends Component { return; } - yield this.comment.save(); + if (this.contextApi?.updateComment) { + yield this.contextApi.updateComment(this.comment); + } else { + yield this.comment.save(); + } + this.editing = false; } @@ -106,15 +116,17 @@ export default class CommentThreadCommentComponent extends Component { return; } - 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, - }); + if (this.contextApi?.publishReply) { + yield this.contextApi.publishReply(this.comment, this.input); + } else { + let comment = this.store.createRecord('comment', { + content: this.input, + parent_comment_uuid: this.comment.uuid ?? this.comment.public_id ?? this.comment.id, + }); - yield comment.save(); - yield this.reloadReplies.perform(); + yield comment.save(); + yield this.reloadReplies.perform(); + } this.replying = false; this.input = ''; diff --git a/addon/components/dashboard.hbs b/addon/components/dashboard.hbs index 426f639a..9c0aa3e1 100644 --- a/addon/components/dashboard.hbs +++ b/addon/components/dashboard.hbs @@ -1,11 +1,12 @@ -
+

{{this.dashboard.currentDashboard.name}}

+ {{yield}} {{/if}} -
\ No newline at end of file +
diff --git a/addon/components/docs-panel.hbs b/addon/components/docs-panel.hbs new file mode 100644 index 00000000..59712cd4 --- /dev/null +++ b/addon/components/docs-panel.hbs @@ -0,0 +1,47 @@ +{{#if this.docsPanel.isOpen}} + + + + <:actions> +
+
+ +
+ + {{#if (and this.docsPanel.canEmbed (not this.docsPanel.iframeFailed))}} + + {{else}} +
+
+ +
+
+
Open Fleetbase documentation
+
+ This page cannot be embedded here, but you can open it in a new tab. +
+
+
+ {{/if}} +
+
+
+{{/if}} diff --git a/addon/components/docs-panel.js b/addon/components/docs-panel.js new file mode 100644 index 00000000..0108dae0 --- /dev/null +++ b/addon/components/docs-panel.js @@ -0,0 +1,22 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; + +export default class DocsPanelComponent extends Component { + @service docsPanel; + + @action + close() { + this.docsPanel.close(); + } + + @action + markIframeFailed() { + this.docsPanel.markIframeFailed(); + } + + @action + openExternal() { + this.docsPanel.openExternal(); + } +} diff --git a/addon/components/layout/header.js b/addon/components/layout/header.js index d2f3c3d9..f4d693a5 100644 --- a/addon/components/layout/header.js +++ b/addon/components/layout/header.js @@ -21,6 +21,7 @@ export default class LayoutHeaderComponent extends Component { @service currentUser; @service abilities; @service fetch; + @service docsPanel; @tracked company; @tracked organizationMenuItems = []; @tracked userMenuItems = []; @@ -242,10 +243,9 @@ export default class LayoutHeaderComponent extends Component { { id: 'docs-user-nav-item', wrapperClass: 'docs-user-nav-item', - href: 'https://docs.fleetbase.io', - target: '_docs', text: 'Documentation', - icon: 'arrow-up-right-from-square', + icon: 'book-open', + onClick: () => this.docsPanel.open('https://www.fleetbase.io/docs', { title: 'Fleetbase Documentation', source: 'user-menu' }), }, ]; diff --git a/addon/components/layout/resource/panel.hbs b/addon/components/layout/resource/panel.hbs index 186188a8..1b1e4067 100644 --- a/addon/components/layout/resource/panel.hbs +++ b/addon/components/layout/resource/panel.hbs @@ -8,6 +8,8 @@ @fullHeight={{true}} @isResizable={{this.isResizable}} @width={{this.width}} + @minResizeWidth={{@minResizeWidth}} + @maxResizeWidth={{@maxResizeWidth}} ...attributes > {{#if @headerComponent}} diff --git a/addon/components/modal/default.hbs b/addon/components/modal/default.hbs index 4c488c72..e9ba5004 100644 --- a/addon/components/modal/default.hbs +++ b/addon/components/modal/default.hbs @@ -12,7 +12,7 @@ > {{#if @options.titleComponent}} - {{component @options.titleComponent options=@options modal=modal}} + {{component (lazy-engine-component @options.titleComponent) options=@options modal=modal}} {{else}} {{#unless @options.hideTitle}} @@ -23,14 +23,14 @@ {{#if @options.bodyComponent}} - {{component @options.bodyComponent options=@options modal=modal}} + {{component (lazy-engine-component @options.bodyComponent) options=@options modal=modal}} {{else}} {{yield @options modal}} {{/if}} {{#if @options.footerComponent}} - {{component @options.footerComponent options=@options confirm=modal.submit modal=modal}} + {{component (lazy-engine-component @options.footerComponent) options=@options confirm=modal.submit modal=modal}} {{else}} {{#unless @options.hideFooterActions}}
-{{/if}} \ No newline at end of file +{{/if}} diff --git a/addon/components/toggle.js b/addon/components/toggle.js index 3a111c31..8fe83fc2 100644 --- a/addon/components/toggle.js +++ b/addon/components/toggle.js @@ -30,10 +30,18 @@ export default class ToggleComponent extends Component { } } - @action toggle(isToggled) { + get isOn() { + if (typeof this.args.isToggled === 'boolean') { + return this.args.isToggled; + } + + return this.isToggled; + } + + @action toggle() { if (this.disabled) return; - this.isToggled = !isToggled; + this.isToggled = !this.isOn; if (typeof this.args.onToggle === 'function') { this.args.onToggle(this.isToggled); } diff --git a/addon/components/user/pill.hbs b/addon/components/user/pill.hbs new file mode 100644 index 00000000..9d2504f0 --- /dev/null +++ b/addon/components/user/pill.hbs @@ -0,0 +1,16 @@ + diff --git a/addon/components/user/pill.js b/addon/components/user/pill.js new file mode 100644 index 00000000..9855c1b5 --- /dev/null +++ b/addon/components/user/pill.js @@ -0,0 +1,7 @@ +import Component from '@glimmer/component'; + +export default class UserPillComponent extends Component { + get resource() { + return this.args.user ?? this.args.resource; + } +} diff --git a/addon/helpers/format-duration.js b/addon/helpers/format-duration.js index 5aab55e8..dca89cde 100644 --- a/addon/helpers/format-duration.js +++ b/addon/helpers/format-duration.js @@ -1,6 +1,10 @@ import { helper } from '@ember/component/helper'; import formatDurationUtil from '../utils/format-duration'; -export default helper(function formatDuration([secs]) { +export function formatDurationValue(secs) { return formatDurationUtil(secs); +} + +export default helper(function formatDuration([secs]) { + return formatDurationValue(secs); }); diff --git a/addon/helpers/place-address.js b/addon/helpers/place-address.js new file mode 100644 index 00000000..3adbcbaa --- /dev/null +++ b/addon/helpers/place-address.js @@ -0,0 +1,6 @@ +import { helper } from '@ember/component/helper'; +import placeAddress from '../utils/place-address'; + +export default helper(function placeAddressHelper([place], hash = {}) { + return placeAddress(place, hash); +}); diff --git a/addon/services/docs-panel.js b/addon/services/docs-panel.js new file mode 100644 index 00000000..de322ffe --- /dev/null +++ b/addon/services/docs-panel.js @@ -0,0 +1,68 @@ +import Service from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; + +const OFFICIAL_DOC_HOSTS = ['www.fleetbase.io', 'fleetbase.io', 'docs.fleetbase.io']; + +export default class DocsPanelService extends Service { + @tracked isOpen = false; + @tracked url = null; + @tracked title = 'Documentation'; + @tracked source = null; + @tracked iframeFailed = false; + + get canEmbed() { + if (!this.url) { + return false; + } + + try { + const parsed = new URL(this.url, window.location.origin); + return OFFICIAL_DOC_HOSTS.includes(parsed.hostname) && (parsed.hostname === 'docs.fleetbase.io' || parsed.pathname.startsWith('/docs')); + } catch { + return false; + } + } + + @action + open(url, options = {}) { + this.url = this.normalizeUrl(url); + this.title = options.title ?? 'Documentation'; + this.source = options.source ?? null; + this.iframeFailed = false; + this.isOpen = true; + } + + @action + close() { + this.isOpen = false; + } + + @action + markIframeFailed() { + this.iframeFailed = true; + } + + @action + openExternal() { + if (this.url) { + window.open(this.url, '_docs'); + } + } + + normalizeUrl(url) { + if (!url) { + return 'https://www.fleetbase.io/docs'; + } + + if (url.startsWith('/docs')) { + return `https://www.fleetbase.io${url}`; + } + + if (url.startsWith('docs/')) { + return `https://www.fleetbase.io/${url}`; + } + + return url; + } +} diff --git a/addon/styles/addon.css b/addon/styles/addon.css index d6e4a866..3d823005 100644 --- a/addon/styles/addon.css +++ b/addon/styles/addon.css @@ -59,3 +59,12 @@ @import "ember-basic-dropdown/vendor/ember-basic-dropdown.css"; @import 'ember-power-select/vendor/ember-power-select.css'; @import 'air-datepicker/air-datepicker.css'; +.fleetbase-docs-panel-body { + height: 100%; + min-height: 0; + padding: 0; +} + +.fleetbase-docs-panel-body > iframe { + display: block; +} diff --git a/addon/styles/components/badge.css b/addon/styles/components/badge.css index a1f40158..7761d923 100644 --- a/addon/styles/components/badge.css +++ b/addon/styles/components/badge.css @@ -48,6 +48,8 @@ .status-badge.active-status-badge > span, .status-badge.available-status-badge > span, .status-badge.completed-status-badge > span, +.status-badge.accepted-status-badge > span, +.status-badge.posted-status-badge > span, /* ── Invoice statuses ── */ .status-badge.paid-status-badge > span, .status-badge.settled-status-badge > span, @@ -74,6 +76,8 @@ .status-badge.active-status-badge > span svg, .status-badge.available-status-badge > span svg, .status-badge.completed-status-badge > span svg, +.status-badge.accepted-status-badge > span svg, +.status-badge.posted-status-badge > span svg, .status-badge.paid-status-badge > span svg, .status-badge.settled-status-badge > span svg, .status-badge.captured-status-badge > span svg { @@ -92,6 +96,7 @@ .status-badge.ready-status-badge > span, .status-badge.verified-status-badge > span, +.status-badge.low-status-badge > span, .status-badge.allocated-status-badge > span, .status-badge.emerald-status-badge > span { @apply bg-emerald-800 border-emerald-700 text-emerald-100; @@ -99,6 +104,7 @@ .status-badge.ready-status-badge > span svg, .status-badge.verified-status-badge > span svg, +.status-badge.low-status-badge > span svg, .status-badge.allocated-status-badge > span svg, .status-badge.emerald-status-badge > span svg { @apply text-emerald-300; @@ -106,12 +112,14 @@ .status-badge.in-review-status-badge > span, .status-badge.started-status-badge > span, +.status-badge.normal-status-badge > span, .status-badge.cyan-status-badge > span { @apply bg-cyan-800 border-cyan-700 text-cyan-100; } .status-badge.in-review-status-badge > span svg, .status-badge.started-status-badge > span svg, +.status-badge.normal-status-badge > span svg, .status-badge.cyan-status-badge > span svg { @apply text-cyan-300; } @@ -154,6 +162,7 @@ .status-badge.degraded-status-badge > span, .status-badge.error-status-badge > span, .status-badge.escalated-status-badge > span, +.status-badge.urgent-status-badge > span, .status-badge.high-status-badge > span, .status-badge.rejected-status-badge > span, .status-badge.critical-status-badge > span, @@ -212,6 +221,7 @@ .status-badge.maintenance-status-badge > span, .status-badge.test-status-badge > span, .status-badge.warning-status-badge > span, +.status-badge.medium-status-badge > span, .status-badge.preparing-status-badge > span, .status-badge.trial-status-badge > span, .status-badge.trialing-status-badge > span, @@ -242,6 +252,7 @@ .status-badge.maintenance-status-badge > span svg, .status-badge.test-status-badge > span svg, .status-badge.warning-status-badge > span svg, +.status-badge.medium-status-badge > span svg, .status-badge.preparing-status-badge > span svg, .status-badge.trial-status-badge > span svg, .status-badge.trialing-status-badge > span svg, diff --git a/addon/styles/components/chat.css b/addon/styles/components/chat.css index e92d9eb2..8816c7f7 100644 --- a/addon/styles/components/chat.css +++ b/addon/styles/components/chat.css @@ -703,3 +703,817 @@ body[data-theme='dark'] .chat-message-container .chat-message-sender-bubble > .c background-color: #b91c1c; cursor: pointer; } + +.chat-tray { + position: relative; + display: flex; + align-items: center; +} + +.chat-tray-panel-trigger { + border: 0; + background: transparent; + padding: 0; +} + +.chat-inbox-overlay, +.chat-compose-overlay { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + min-height: 100vh; + max-height: 100vh; + pointer-events: none; +} + +.chat-inbox-overlay { + z-index: 860; +} + +.chat-compose-overlay { + z-index: 850; +} + +.chat-inbox-overlay > .next-content-overlay-panel-container, +.chat-compose-overlay > .next-content-overlay-panel-container { + position: fixed; + top: 0; + bottom: 0; + height: 100vh; + min-height: 100vh; + max-height: 100vh; + pointer-events: auto; +} + +.chat-inbox-overlay.next-content-overlay-pos-right > .next-content-overlay-panel-container, +.chat-compose-overlay.next-content-overlay-pos-right > .next-content-overlay-panel-container { + right: 0; + transform: translateX(100%); +} + +.chat-inbox-overlay.is-open > .next-content-overlay-panel-container, +.chat-compose-overlay.is-open > .next-content-overlay-panel-container { + transform: translateX(0); +} + +.chat-inbox-overlay > .next-content-overlay-panel-container > .next-content-overlay-panel, +.chat-compose-overlay > .next-content-overlay-panel-container > .next-content-overlay-panel { + height: 100vh; + min-height: 100vh; + max-height: 100vh; + overflow: hidden; +} + +.chat-inbox-overlay .next-content-overlay-panel, +.chat-compose-overlay .next-content-overlay-panel { + border-left: 1px solid #e5e7eb; +} + +.chat-compose-overlay.has-inbox.next-content-overlay-pos-right > .next-content-overlay-panel-container { + right: 420px; + transform: translateX(420px); +} + +.chat-compose-overlay.has-inbox.is-open > .next-content-overlay-panel-container { + transform: translateX(0); +} + +.chat-inbox-panel, +.chat-compose-panel { + display: flex; + flex-direction: column; + height: 100%; + min-height: 100%; + max-height: 100%; + background-color: #f9fafb; + color: #111827; +} + +.chat-inbox-panel-header, +.chat-compose-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 64px; + padding: 0.85rem 1rem; + border-bottom: 1px solid #e5e7eb; + background-color: #fff; +} + +.chat-inbox-panel-title, +.chat-compose-panel-title { + display: flex; + flex-direction: column; + min-width: 0; +} + +.chat-inbox-panel-title h2, +.chat-compose-panel-title h2 { + font-size: 1rem; + line-height: 1.35; + font-weight: 700; + margin: 0; + color: #111827; +} + +.chat-inbox-panel-title span, +.chat-compose-panel-title span { + margin-top: 0.1rem; + font-size: 0.75rem; + color: #6b7280; +} + +.chat-inbox-panel-actions { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} + +.chat-inbox-panel-actions .btn { + min-height: 2rem; + height: 2rem; +} + +.chat-inbox-panel-actions .btn-icon-wrapper { + display: inline-flex; + align-items: center; +} + +.chat-inbox-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 0.5rem; + border: 1px solid #e5e7eb; + color: #4b5563; + background-color: #f9fafb; +} + +.chat-inbox-icon-button:hover, +.chat-inbox-row-action:hover { + background-color: #f3f4f6; + color: #111827; +} + +.chat-inbox-search { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0.85rem 0.65rem; + padding: 0.5rem 0.65rem; + border: 1px solid #d1d5db; + border-radius: 0.5rem; + background-color: #fff; + color: #6b7280; +} + +.chat-inbox-search input { + width: 100%; + border: 0; + padding: 0; + font-size: 0.875rem; + color: #111827; + background-color: transparent; + box-shadow: none; +} + +.chat-inbox-search input:focus { + outline: 0; + box-shadow: none; +} + +.chat-inbox-list, +.chat-compose-contact-list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0 0.65rem 0.85rem; +} + +.chat-inbox-conversation-row { + position: relative; + display: flex; + align-items: flex-start; + gap: 0; + border-radius: 0.5rem; + margin-bottom: 0.5rem; + border: 1px solid #e5e7eb; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(249, 250, 251, 0.98)), + #fff; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); + transition: + border-color 0.15s ease, + background-color 0.15s ease, + box-shadow 0.15s ease; +} + +.chat-inbox-conversation-row:hover, +.chat-inbox-conversation-row.is-open { + border-color: #bad5f5; + background: + linear-gradient(135deg, #fff, #eef6ff), + #f6fbff; + box-shadow: 0 6px 16px rgba(15, 23, 42, 0.08); +} + +.chat-inbox-conversation-row.has-unread { + border-color: #9cc4f1; + background: + linear-gradient(135deg, #f8fbff, #eaf4ff), + #f0f7ff; +} + +.chat-inbox-conversation-row.has-unread::before { + content: ''; + position: absolute; + top: 0.6rem; + bottom: 0.6rem; + left: 0; + width: 3px; + border-radius: 0 9999px 9999px 0; + background-color: #3485e2; +} + +.chat-inbox-conversation-main { + display: grid; + grid-template-columns: 3.15rem minmax(0, 1fr) auto; + align-items: flex-start; + gap: 0.85rem; + width: 100%; + min-width: 0; + border: 0; + border-radius: 0.5rem; + padding: 0.8rem 0.7rem 0.8rem 0.85rem; + background-color: transparent; + text-align: left; +} + +.chat-inbox-conversation-avatars { + position: relative; + width: 3.15rem; + height: 2.55rem; + flex-shrink: 0; + margin-top: 0.05rem; +} + +.chat-inbox-conversation-avatar { + position: absolute; + width: 2.15rem; + height: 2.15rem; + border: 2px solid #fff; + border-radius: 9999px; + object-fit: cover; + background-color: #fff; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.12); +} + +.chat-inbox-conversation-avatar:nth-child(1) { + left: 0; + z-index: 3; +} + +.chat-inbox-conversation-avatar:nth-child(2) { + left: 0.78rem; + z-index: 2; +} + +.chat-inbox-conversation-avatar:nth-child(3) { + left: 1.56rem; + z-index: 1; +} + +.chat-inbox-conversation-avatar-count, +.chat-inbox-conversation-avatar-empty { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.65rem; + font-weight: 700; + color: #16539a; + background-color: #e6f0fb; +} + +.chat-inbox-conversation-avatar-count { + left: 1.56rem; + z-index: 4; +} + +.chat-inbox-conversation-avatar-empty { + left: 0.45rem; + font-size: 0.8rem; +} + +.chat-inbox-conversation-body { + min-width: 0; + padding-right: 0.15rem; +} + +.chat-inbox-conversation-heading, +.chat-inbox-conversation-preview, +.chat-inbox-conversation-meta { + display: flex; + align-items: center; + min-width: 0; +} + +.chat-inbox-conversation-heading { + align-items: flex-start; + justify-content: space-between; + gap: 0.5rem; +} + +.chat-inbox-conversation-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.875rem; + font-weight: 700; + color: #111827; +} + +.chat-inbox-conversation-row.has-unread .chat-inbox-conversation-title { + color: #0f4f94; +} + +.chat-inbox-conversation-time { + flex-shrink: 0; + margin-top: 0.05rem; + font-size: 0.68rem; + color: #6b7280; +} + +.chat-inbox-conversation-preview { + gap: 0.3rem; + margin-top: 0.45rem; + border: 1px solid #e5e7eb; + border-radius: 0.45rem; + padding: 0.35rem 0.45rem; + font-size: 0.78rem; + color: #374151; + background-color: rgba(249, 250, 251, 0.92); + line-height: 1.35; +} + +.chat-inbox-conversation-preview-icon { + flex-shrink: 0; + margin-top: 0.1rem; + font-size: 0.68rem; + color: #9ca3af; +} + +.chat-inbox-conversation-preview-text, +.chat-inbox-conversation-sender { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-inbox-conversation-sender { + flex-shrink: 0; + font-weight: 600; + color: #111827; +} + +.chat-inbox-conversation-row.has-unread .chat-inbox-conversation-preview, +.chat-inbox-conversation-row.has-unread .chat-inbox-conversation-sender { + font-weight: 600; + color: #1f2937; +} + +.chat-inbox-conversation-meta { + gap: 0.35rem; + margin-top: 0.5rem; + font-size: 0.68rem; + color: #6b7280; +} + +.chat-inbox-conversation-pill { + display: inline-flex; + align-items: center; + gap: 0.25rem; + max-width: 100%; + border: 1px solid #e5e7eb; + border-radius: 9999px; + padding: 0.12rem 0.4rem; + color: #4b5563; + background-color: #f9fafb; + line-height: 1.2; +} + +.chat-inbox-conversation-side, +.chat-inbox-conversation-actions { + display: flex; + align-items: flex-start; + justify-content: center; +} + +.chat-inbox-conversation-side { + padding-top: 0.05rem; +} + +.chat-inbox-unread-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.3rem; + height: 1.3rem; + padding: 0 0.35rem; + border-radius: 9999px; + font-size: 0.68rem; + font-weight: 700; + color: #fff; + background-color: #3485e2; + box-shadow: 0 0 0 2px #fff; +} + +.chat-inbox-row-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + margin-top: 0.8rem; + margin-right: 0.35rem; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + color: #9ca3af; + background-color: #fff; + opacity: 0; + transition: + opacity 0.15s ease, + color 0.15s ease, + background-color 0.15s ease; +} + +.chat-inbox-conversation-row:hover .chat-inbox-row-action, +.chat-inbox-conversation-row:focus-within .chat-inbox-row-action { + opacity: 1; +} + +.chat-inbox-row-action:hover, +.chat-inbox-row-action:focus { + border-color: #fecaca; + color: #b91c1c; + background-color: #fef2f2; +} + +.chat-inbox-empty-state { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 15rem; + padding: 2rem 1.25rem; + text-align: center; + color: #6b7280; +} + +.chat-inbox-empty-state h3 { + margin: 0.75rem 0 0.25rem; + font-size: 0.95rem; + font-weight: 700; + color: #111827; +} + +.chat-inbox-empty-state p { + margin: 0 0 1rem; + font-size: 0.8rem; +} + +.chat-inbox-empty-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.75rem; + height: 2.75rem; + border-radius: 0.5rem; + color: #3485e2; + background-color: #e6f0fb; +} + +.chat-compose-form { + padding: 0.85rem 0.65rem; + border-bottom: 1px solid #e5e7eb; + background-color: #fff; +} + +.chat-compose-form .chat-inbox-search { + margin: 0; +} + +.chat-compose-label { + display: block; + margin: 0 0 0.35rem; + font-size: 0.72rem; + font-weight: 700; + color: #374151; +} + +.chat-compose-label:not(:first-child) { + margin-top: 0.85rem; +} + +.chat-compose-form > input { + width: 100%; + border: 1px solid #d1d5db; + border-radius: 0.5rem; + padding: 0.55rem 0.65rem; + font-size: 0.875rem; + color: #111827; + background-color: #fff; +} + +.chat-compose-form > input:focus { + border-color: #3485e2; + outline: 0; + box-shadow: 0 0 0 1px #3485e2; +} + +.chat-compose-selected-users { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: 0.65rem; +} + +.chat-compose-selected-user { + display: inline-flex; + align-items: center; + max-width: 100%; + gap: 0.35rem; + border: 1px solid #bad5f5; + border-radius: 9999px; + padding: 0.2rem 0.35rem 0.2rem 0.2rem; + font-size: 0.75rem; + color: #16539a; + background-color: #e6f0fb; +} + +.chat-compose-selected-user img { + width: 1.25rem; + height: 1.25rem; + border-radius: 9999px; +} + +.chat-compose-selected-user span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-compose-selected-user button { + display: inline-flex; + color: #16539a; +} + +.chat-compose-contact-row { + display: grid; + grid-template-columns: 2.35rem minmax(0, 1fr) 1.75rem; + align-items: center; + gap: 0.7rem; + width: 100%; + border: 0; + border-radius: 0.5rem; + padding: 0.65rem; + background-color: transparent; + text-align: left; +} + +.chat-compose-contact-row:hover, +.chat-compose-contact-row.is-selected { + background-color: #eef6ff; +} + +.chat-compose-contact-avatar-wrap { + position: relative; +} + +.chat-compose-contact-avatar { + width: 2.15rem; + height: 2.15rem; + border-radius: 9999px; + object-fit: cover; +} + +.chat-compose-contact-presence { + position: absolute; + right: -1px; + bottom: -1px; + width: 0.7rem; + height: 0.7rem; + border: 2px solid #f9fafb; + border-radius: 9999px; + background-color: #f59e0b; +} + +.chat-compose-contact-presence.is-online { + background-color: #16a34a; +} + +.chat-compose-contact-body { + display: flex; + flex-direction: column; + min-width: 0; +} + +.chat-compose-contact-name, +.chat-compose-contact-subtitle { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-compose-contact-name { + font-size: 0.86rem; + font-weight: 700; + color: #111827; +} + +.chat-compose-contact-subtitle { + margin-top: 0.1rem; + font-size: 0.75rem; + color: #6b7280; +} + +.chat-compose-contact-check { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.65rem; + height: 1.65rem; + border-radius: 0.5rem; + color: #3485e2; + background-color: #e6f0fb; +} + +.chat-compose-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.5rem; + padding: 0.85rem 1rem; + border-top: 1px solid #e5e7eb; + background-color: #fff; +} + +body[data-theme='dark'] .chat-inbox-overlay .next-content-overlay-panel, +body[data-theme='dark'] .chat-compose-overlay .next-content-overlay-panel { + border-left-color: #374151; +} + +body[data-theme='dark'] .chat-inbox-panel, +body[data-theme='dark'] .chat-compose-panel { + background-color: #111827; + color: #f9fafb; +} + +body[data-theme='dark'] .chat-inbox-panel-header, +body[data-theme='dark'] .chat-compose-panel-header, +body[data-theme='dark'] .chat-compose-form, +body[data-theme='dark'] .chat-compose-footer { + border-color: #374151; + background-color: #1f2937; +} + +body[data-theme='dark'] .chat-inbox-panel-title h2, +body[data-theme='dark'] .chat-compose-panel-title h2, +body[data-theme='dark'] .chat-inbox-conversation-title, +body[data-theme='dark'] .chat-inbox-empty-state h3, +body[data-theme='dark'] .chat-compose-contact-name, +body[data-theme='dark'] .chat-compose-label { + color: #f9fafb; +} + +body[data-theme='dark'] .chat-inbox-panel-title span, +body[data-theme='dark'] .chat-compose-panel-title span, +body[data-theme='dark'] .chat-inbox-conversation-time, +body[data-theme='dark'] .chat-inbox-conversation-preview, +body[data-theme='dark'] .chat-inbox-conversation-meta, +body[data-theme='dark'] .chat-inbox-empty-state, +body[data-theme='dark'] .chat-compose-contact-subtitle { + color: #d1d5db; +} + +body[data-theme='dark'] .chat-inbox-icon-button, +body[data-theme='dark'] .chat-inbox-search, +body[data-theme='dark'] .chat-compose-form > input { + border-color: #374151; + background-color: #111827; + color: #f9fafb; +} + +body[data-theme='dark'] .chat-inbox-search input { + color: #f9fafb; +} + +body[data-theme='dark'] .chat-inbox-icon-button:hover, +body[data-theme='dark'] .chat-inbox-conversation-row:hover, +body[data-theme='dark'] .chat-inbox-conversation-row.is-open, +body[data-theme='dark'] .chat-compose-contact-row:hover, +body[data-theme='dark'] .chat-compose-contact-row.is-selected { + background-color: #1f2937; + color: #f9fafb; +} + +body[data-theme='dark'] .chat-inbox-conversation-row { + border-color: #374151; + background: + linear-gradient(135deg, rgba(17, 24, 39, 0.98), rgba(31, 41, 55, 0.82)), + #111827; + box-shadow: none; +} + +body[data-theme='dark'] .chat-inbox-conversation-row:hover, +body[data-theme='dark'] .chat-inbox-conversation-row.is-open { + border-color: #2563eb; + background: + linear-gradient(135deg, #111827, #172235), + #1f2937; + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.24); +} + +body[data-theme='dark'] .chat-inbox-conversation-row.has-unread { + border-color: #2563eb; + background: + linear-gradient(135deg, #102033, #0f2740), + #102033; +} + +body[data-theme='dark'] .chat-inbox-conversation-row.has-unread .chat-inbox-conversation-title, +body[data-theme='dark'] .chat-inbox-conversation-row.has-unread .chat-inbox-conversation-preview, +body[data-theme='dark'] .chat-inbox-conversation-row.has-unread .chat-inbox-conversation-sender { + color: #f9fafb; +} + +body[data-theme='dark'] .chat-inbox-conversation-sender { + color: #f3f4f6; +} + +body[data-theme='dark'] .chat-inbox-conversation-pill, +body[data-theme='dark'] .chat-inbox-conversation-preview, +body[data-theme='dark'] .chat-inbox-row-action { + border-color: #374151; + color: #d1d5db; + background-color: #1f2937; +} + +body[data-theme='dark'] .chat-inbox-conversation-preview-icon { + color: #9ca3af; +} + +body[data-theme='dark'] .chat-inbox-row-action:hover, +body[data-theme='dark'] .chat-inbox-row-action:focus { + border-color: #7f1d1d; + color: #fecaca; + background-color: #3f1d20; +} + +body[data-theme='dark'] .chat-inbox-conversation-avatar, +body[data-theme='dark'] .chat-compose-contact-presence { + border-color: #111827; +} + +body[data-theme='dark'] .chat-inbox-empty-icon, +body[data-theme='dark'] .chat-compose-contact-check, +body[data-theme='dark'] .chat-compose-selected-user, +body[data-theme='dark'] .chat-inbox-conversation-avatar-count, +body[data-theme='dark'] .chat-inbox-conversation-avatar-empty { + border-color: #16539a; + color: #bad5f5; + background-color: #0d2f57; +} + +@media (max-width: 767px) { + .chat-compose-overlay { + z-index: 870; + } + + .chat-compose-overlay.has-inbox.next-content-overlay-pos-right > .next-content-overlay-panel-container { + right: 0; + transform: translateX(100%); + } + + .chat-compose-overlay.has-inbox.is-open > .next-content-overlay-panel-container { + transform: translateX(0); + } + + .chat-inbox-overlay .next-content-overlay-panel, + .chat-compose-overlay .next-content-overlay-panel { + width: 100% !important; + } + + .chat-inbox-conversation-main { + grid-template-columns: 3.15rem minmax(0, 1fr) auto; + padding-right: 0.55rem; + } + + .chat-inbox-row-action { + opacity: 1; + } +} diff --git a/addon/styles/components/dashboard.css b/addon/styles/components/dashboard.css index 143beb5c..3b30dcb3 100644 --- a/addon/styles/components/dashboard.css +++ b/addon/styles/components/dashboard.css @@ -20,6 +20,12 @@ grid-column: 1 / -1; } +.fleetbase-dashboard-grid--sticky { + position: sticky; + top: var(--fleetbase-dashboard-sticky-top, 0); + z-index: var(--fleetbase-dashboard-sticky-z-index, 20); +} + .fleetbase-dashboard-grid .btn-wrapper > .btn, .fleetbase-dashboard-grid .ember-basic-dropdown-trigger .btn-wrapper > .btn { height: 30px; diff --git a/addon/styles/components/resource-context-panel.css b/addon/styles/components/resource-context-panel.css index 68c7ad39..80caf334 100644 --- a/addon/styles/components/resource-context-panel.css +++ b/addon/styles/components/resource-context-panel.css @@ -14,6 +14,31 @@ animation: fadeIn 0.2s ease-in-out; } +.resource-context-panel-body.has-tabs { + overflow: hidden; +} + +.resource-context-panel-tabs, +.resource-context-panel-tabs > .tab-navigation { + display: flex; + min-height: 0; + flex: 1 1 auto; + flex-direction: column; +} + +.resource-context-panel-tabs .resource-context-panel-tablist { + position: sticky; + top: 0; + z-index: 2; + flex: 0 0 auto; +} + +.resource-context-panel-tabs .resource-context-panel-tab-content { + min-height: 0; + flex: 1 1 auto; + overflow-y: auto; +} + .resource-context-panel-content { animation: fadeIn 0.2s ease-in-out; } @@ -56,4 +81,8 @@ /* Dark mode support */ body[data-theme='dark'] .resource-context-panel-backdrop { background-color: rgba(0, 0, 0, 0.3); -} \ No newline at end of file +} + +body[data-theme='dark'] .resource-context-panel-tabs .resource-context-panel-tablist { + background-color: #111827; +} diff --git a/addon/styles/components/tab-navigation.css b/addon/styles/components/tab-navigation.css index 2af87f42..881f076f 100644 --- a/addon/styles/components/tab-navigation.css +++ b/addon/styles/components/tab-navigation.css @@ -28,12 +28,23 @@ body[data-theme='dark'] { @apply w-full h-full; } +.tab-navigation.tab-navigation-fill { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} + .tab-list { @apply flex items-end; background-color: var(--tab-bg-primary); border-color: var(--tab-border-color); } +.tab-navigation.tab-navigation-fill > .tab-list { + flex: 0 0 auto; +} + .tab-navigation[data-style='github'] .tab-list { @apply border-b; background-color: var(--tab-bg-primary); @@ -104,6 +115,15 @@ body[data-theme='dark'] { height: 100%; } +.tab-navigation.tab-navigation-fill > .tab-content { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-width: 0; + min-height: 0; + height: auto; +} + .tab-icon { @apply flex-shrink-0; } @@ -272,4 +292,4 @@ body[data-theme='dark'] .tab-badge { .tab-navigation-actions input.form-input, .tab-navigation-actions span.btn-wrapper > button.btn { height: 28px; -} \ No newline at end of file +} diff --git a/addon/utils/format-duration.js b/addon/utils/format-duration.js index 33254fae..1143dea8 100644 --- a/addon/utils/format-duration.js +++ b/addon/utils/format-duration.js @@ -1,38 +1,38 @@ export const secondsToTime = (secs) => { - const hours = Math.floor(secs / (60 * 60)); - const divisor_for_minutes = secs % (60 * 60); - const minutes = Math.floor(divisor_for_minutes / 60); - const divisor_for_seconds = divisor_for_minutes % 60; - const seconds = Math.ceil(divisor_for_seconds); - - const obj = { + const value = Number(secs); + const totalSeconds = Number.isFinite(value) ? Math.max(0, Math.ceil(value)) : 0; + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + return { + d: days, h: hours, m: minutes, s: seconds, }; - - return obj; }; export default function formatDuration(secs) { - let time = secondsToTime(secs); - let parts = []; + const time = secondsToTime(secs); + const parts = []; + + if (time.d) { + parts.push(`${time.d}d`); + } if (time.h) { parts.push(`${time.h}h`); } - if (time.m) { + if (!time.d && time.m) { parts.push(`${time.m}m`); } - if (parts.length < 2 && time.s) { + if (!time.d && !time.h && parts.length < 2 && time.s) { parts.push(`${time.s}s`); } - if (parts.length === 0) { - parts.push('0s'); - } - - return parts.join(' '); + return parts.length ? parts.join(' ') : '0s'; } diff --git a/addon/utils/place-address.js b/addon/utils/place-address.js new file mode 100644 index 00000000..56952873 --- /dev/null +++ b/addon/utils/place-address.js @@ -0,0 +1,48 @@ +import { htmlSafe } from '@ember/template'; +import { isBlank } from '@ember/utils'; + +function escapeHtml(value) { + return String(value).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +} + +function line(content, className = '') { + const classAttribute = className ? ` class="${className}"` : ''; + + return `${escapeHtml(content)}`; +} + +export default function placeAddress(place, options = {}) { + const address = place?.place ?? place; + + if (!address) { + return htmlSafe(''); + } + + const { showTitle = true } = options; + const name = address.name === address.street1 ? null : address.name; + const cityStatePostalCode = [address.city, address.province, address.postal_code].filter((value) => !isBlank(value)).join(', '); + const lines = []; + + if (name) { + if (showTitle) { + lines.push(line(name, 'font-semibold')); + } + lines.push(line(address.street1)); + } else if (address.street1) { + lines.push(line(address.street1, 'font-semibold')); + } + + if (address.street2) { + lines.push(line(address.street2)); + } + + if (cityStatePostalCode) { + lines.push(line(cityStatePostalCode)); + } + + if (address.country_name || address.country) { + lines.push(line(address.country_name ?? address.country)); + } + + return htmlSafe(`
${lines.join('')}
`); +} diff --git a/app/components/chat-tray/compose-panel.js b/app/components/chat-tray/compose-panel.js new file mode 100644 index 00000000..0b3d45d0 --- /dev/null +++ b/app/components/chat-tray/compose-panel.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/chat-tray/compose-panel'; diff --git a/app/components/chat-tray/contact-row.js b/app/components/chat-tray/contact-row.js new file mode 100644 index 00000000..e6c08fec --- /dev/null +++ b/app/components/chat-tray/contact-row.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/chat-tray/contact-row'; diff --git a/app/components/chat-tray/conversation-row.js b/app/components/chat-tray/conversation-row.js new file mode 100644 index 00000000..cfc786f6 --- /dev/null +++ b/app/components/chat-tray/conversation-row.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/chat-tray/conversation-row'; diff --git a/app/components/chat-tray/inbox-panel.js b/app/components/chat-tray/inbox-panel.js new file mode 100644 index 00000000..9e66fd41 --- /dev/null +++ b/app/components/chat-tray/inbox-panel.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/chat-tray/inbox-panel'; diff --git a/app/components/docs-panel.js b/app/components/docs-panel.js new file mode 100644 index 00000000..30cf54c2 --- /dev/null +++ b/app/components/docs-panel.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/docs-panel'; diff --git a/app/components/user/pill.js b/app/components/user/pill.js new file mode 100644 index 00000000..a96e1094 --- /dev/null +++ b/app/components/user/pill.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/components/user/pill'; diff --git a/app/helpers/format-duration.js b/app/helpers/format-duration.js index 5dc09dff..84d1e9dd 100644 --- a/app/helpers/format-duration.js +++ b/app/helpers/format-duration.js @@ -1 +1 @@ -export { default } from '@fleetbase/ember-ui/helpers/format-duration'; +export { default, formatDurationValue } from '@fleetbase/ember-ui/helpers/format-duration'; diff --git a/app/helpers/place-address.js b/app/helpers/place-address.js new file mode 100644 index 00000000..c7bb9d66 --- /dev/null +++ b/app/helpers/place-address.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/helpers/place-address'; diff --git a/app/services/docs-panel.js b/app/services/docs-panel.js new file mode 100644 index 00000000..0681e356 --- /dev/null +++ b/app/services/docs-panel.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/services/docs-panel'; diff --git a/app/utils/place-address.js b/app/utils/place-address.js new file mode 100644 index 00000000..ec72b404 --- /dev/null +++ b/app/utils/place-address.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/ember-ui/utils/place-address'; diff --git a/package.json b/package.json index 669896e5..039756ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/ember-ui", - "version": "0.3.32", + "version": "0.3.33", "description": "Fleetbase UI provides all the interface components, helpers, services and utilities for building a Fleetbase extension into the Console.", "keywords": [ "fleetbase-ui", diff --git a/tests/integration/components/chat-tray-test.js b/tests/integration/components/chat-tray-test.js index 1b1533ef..b06af31d 100644 --- a/tests/integration/components/chat-tray-test.js +++ b/tests/integration/components/chat-tray-test.js @@ -1,26 +1,158 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { click, fillIn, render } from '@ember/test-helpers'; +import Service from '@ember/service'; +import Evented from '@ember/object/evented'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Component | chat-tray', 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) { ... }); + hooks.beforeEach(function () { + const channels = [ + { + id: 'chat-1', + public_id: 'chat_public_1', + title: 'Dispatch Team', + name: 'Dispatch Team', + unread_count: 2, + updated_at: new Date('2026-05-31T02:00:00Z'), + created_at: new Date('2026-05-31T01:00:00Z'), + updatedAgo: '5 minutes', + createdAgo: '1 hour', + created_by_uuid: 'user-current', + participants: [ + { + id: 'participant-1', + user_uuid: 'user-current', + name: 'Current User', + avatar_url: null, + }, + { + id: 'participant-2', + user_uuid: 'user-2', + name: 'Alex Driver', + email: 'alex@example.test', + avatar_url: null, + }, + ], + last_message: { + content: 'Arrived at loading dock', + createdAgo: '5 minutes ago', + sender: { + name: 'Alex Driver', + }, + attachments: [], + }, + }, + ]; + class ChatStub extends Service { + openChannels = []; + createdChatArgs; + openedChannel; + loadChannels = { + isIdle: true, + perform: ({ withChannels } = {}) => { + if (typeof withChannels === 'function') { + withChannels(channels); + } + + return Promise.resolve(channels); + }, + }; + + openChannel(channel) { + this.openedChannel = channel; + } + + closeChannel() {} + + deleteChatChannel() { + return Promise.resolve(); + } + + createChatChannel(name, participants) { + this.createdChatArgs = { name, participants }; + return Promise.resolve(channels[0]); + } + } + + class SocketStub extends Service { + listen() {} + } + + class FetchStub extends Service { + get(path) { + if (path === 'chat-channels/available-participants') { + return Promise.resolve([ + { + id: 'user-2', + name: 'Alex Driver', + email: 'alex@example.test', + avatar_url: null, + is_online: true, + }, + ]); + } + + return Promise.resolve({ unreadCount: 2 }); + } + } + + class CurrentUserStub extends Service { + id = 'user-current'; + } + + class MediaStub extends Service.extend(Evented) { + isMobile = false; + } + + class ModalsManagerStub extends Service { + confirm() {} + } + + class NotificationsStub extends Service { + error() {} + } + + this.owner.register('service:chat', ChatStub); + this.owner.register('service:socket', SocketStub); + this.owner.register('service:fetch', FetchStub); + this.owner.register('service:current-user', CurrentUserStub); + this.owner.register('service:media', MediaStub); + this.owner.register('service:modals-manager', ModalsManagerStub); + this.owner.register('service:notifications', NotificationsStub); + }); + + test('opens a conversation inbox overlay from the tray button', async function (assert) { await render(hbs``); - assert.dom().hasText(''); + assert.dom('.chat-inbox-panel').doesNotExist(); + + await click('[aria-label="Open chat inbox"]'); + + assert.dom('.chat-inbox-panel').exists(); + assert.dom('.chat-inbox-conversation-title').hasText('Dispatch Team'); + assert.dom('.chat-inbox-conversation-preview').includesText('Arrived at loading dock'); + assert.dom('.chat-inbox-unread-badge').hasText('2'); + }); + + test('creates a participant-backed chat from the compose overlay', async function (assert) { + await render(hbs``); + + await click('[aria-label="Open chat inbox"]'); + await click('.chat-inbox-panel-actions .btn-primary'); + + assert.dom('.chat-compose-panel').exists(); + assert.dom('.chat-compose-contact-name').hasText('Alex Driver'); - // Template block usage: - await render(hbs` - - template block text - - `); + await click('.chat-compose-contact-row'); + await fillIn('#chat-compose-name', 'Dock handoff'); + await click('.chat-compose-footer .btn-primary'); - assert.dom().hasText('template block text'); + const chat = this.owner.lookup('service:chat'); + assert.deepEqual(chat.createdChatArgs, { name: 'Dock handoff', participants: ['user-2'] }); + assert.strictEqual(chat.openedChannel.id, 'chat-1'); }); }); diff --git a/tests/integration/components/dashboard-test.js b/tests/integration/components/dashboard-test.js index 862bd5d3..2fcc5dbb 100644 --- a/tests/integration/components/dashboard-test.js +++ b/tests/integration/components/dashboard-test.js @@ -2,17 +2,48 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import Service from '@ember/service'; +import { helper } from '@ember/component/helper'; + +class DashboardStubService extends Service { + currentDashboard = { id: 'dashboard', name: 'Test Dashboard', user_uuid: 'system', widgets: [] }; + dashboards = [this.currentDashboard]; + isEditingDashboard = false; + isAddingWidget = false; + showPanelWhenZeroWidgets = false; + loadDashboards = { perform() {} }; + + reset() {} + onAddingWidget() {} + onChangeEdit() {} +} + +class IntlStubService extends Service { + t(key) { + return key; + } +} module('Integration | Component | dashboard', 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) { ... }); + hooks.beforeEach(function () { + this.owner.register('service:dashboard', DashboardStubService); + this.owner.register('service:intl', IntlStubService); + this.owner.register('service:store', class extends Service {}); + this.owner.register('service:notifications', class extends Service {}); + this.owner.register('service:modals-manager', class extends Service {}); + this.owner.register('service:fetch', class extends Service {}); + this.owner.register( + 'helper:t', + helper(([key]) => key) + ); + }); + test('it renders', async function (assert) { await render(hbs``); - assert.dom().hasText(''); + assert.dom('.fleetbase-dashboard-grid').exists(); // Template block usage: await render(hbs` @@ -21,6 +52,16 @@ module('Integration | Component | dashboard', function (hooks) { `); - assert.dom().hasText('template block text'); + assert.dom().includesText('template block text'); + }); + + test('it supports an opt-in sticky header class', async function (assert) { + await render(hbs``); + + assert.dom('.fleetbase-dashboard-grid').hasClass('fleetbase-dashboard-grid--sticky'); + + await render(hbs``); + + assert.dom('.fleetbase-dashboard-grid').doesNotHaveClass('fleetbase-dashboard-grid--sticky'); }); }); diff --git a/tests/integration/components/docs-panel-test.js b/tests/integration/components/docs-panel-test.js new file mode 100644 index 00000000..f079fdf7 --- /dev/null +++ b/tests/integration/components/docs-panel-test.js @@ -0,0 +1,29 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render, waitFor } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | docs-panel', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.wormhole = document.createElement('div'); + this.wormhole.id = 'application-root-wormhole'; + document.body.appendChild(this.wormhole); + }); + + hooks.afterEach(function () { + this.wormhole.remove(); + }); + + test('it renders official docs in a right overlay', async function (assert) { + const docsPanel = this.owner.lookup('service:docs-panel'); + docsPanel.open('https://www.fleetbase.io/docs/fleet-ops/orders', { title: 'Orders docs' }); + + await render(hbs``); + await waitFor('.fleetbase-docs-panel-overlay'); + + assert.dom('.fleetbase-docs-panel-overlay').exists(); + assert.dom('.fleetbase-docs-panel-overlay iframe').hasAttribute('src', 'https://www.fleetbase.io/docs/fleet-ops/orders'); + }); +}); diff --git a/tests/integration/components/toggle-test.js b/tests/integration/components/toggle-test.js index 80086c8e..523ccc78 100644 --- a/tests/integration/components/toggle-test.js +++ b/tests/integration/components/toggle-test.js @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { click, render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Component | toggle', function (hooks) { @@ -23,4 +23,23 @@ module('Integration | Component | toggle', function (hooks) { assert.dom(this.element).hasText('template block text'); }); + + test('it reflects controlled isToggled changes', async function (assert) { + this.set('enabled', true); + + await render(hbs``); + + assert.dom('[role="checkbox"]').hasAttribute('aria-checked', 'true'); + assert.dom('[role="checkbox"] span:first-child').hasClass('bg-green-400'); + + this.set('enabled', false); + + assert.dom('[role="checkbox"]').hasAttribute('aria-checked', 'false'); + assert.dom('[role="checkbox"] span:first-child').hasClass('bg-gray-200'); + + await click('[role="checkbox"]'); + + assert.true(this.enabled); + assert.dom('[role="checkbox"]').hasAttribute('aria-checked', 'true'); + }); }); diff --git a/tests/integration/helpers/format-duration-test.js b/tests/integration/helpers/format-duration-test.js index ee1dd363..ccc8b453 100644 --- a/tests/integration/helpers/format-duration-test.js +++ b/tests/integration/helpers/format-duration-test.js @@ -6,12 +6,11 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | format-duration', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. test('it renders', async function (assert) { this.set('inputValue', '1234'); await render(hbs`{{format-duration this.inputValue}}`); - assert.dom(this.element).hasText('1234'); + assert.dom(this.element).hasText('20m 34s'); }); }); diff --git a/tests/integration/helpers/place-address-test.js b/tests/integration/helpers/place-address-test.js new file mode 100644 index 00000000..db6e230e --- /dev/null +++ b/tests/integration/helpers/place-address-test.js @@ -0,0 +1,36 @@ +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 | place-address', function (hooks) { + setupRenderingTest(hooks); + + test('it renders the place title by default', async function (assert) { + this.set('place', { + name: 'North Dock', + street1: '100 Harbor Road', + city: 'Singapore', + country_name: 'Singapore', + }); + + await render(hbs`{{place-address this.place}}`); + + assert.dom('address').containsText('North Dock'); + assert.dom('address').containsText('100 Harbor Road'); + }); + + test('it hides the place title when showTitle is false', async function (assert) { + this.set('place', { + name: 'North Dock', + street1: '100 Harbor Road', + city: 'Singapore', + country_name: 'Singapore', + }); + + await render(hbs`{{place-address this.place showTitle=false}}`); + + assert.dom('address').doesNotContainText('North Dock'); + assert.dom('address').containsText('100 Harbor Road'); + }); +}); diff --git a/tests/unit/utils/format-duration-test.js b/tests/unit/utils/format-duration-test.js index 843e0039..756036fd 100644 --- a/tests/unit/utils/format-duration-test.js +++ b/tests/unit/utils/format-duration-test.js @@ -2,9 +2,12 @@ import formatDuration from 'dummy/utils/format-duration'; import { module, test } from 'qunit'; module('Unit | Utility | format-duration', function () { - // TODO: Replace this with your real tests. - test('it works', function (assert) { - let result = formatDuration(); - assert.ok(result); + test('it formats seconds into compact duration parts', function (assert) { + assert.strictEqual(formatDuration(), '0s'); + assert.strictEqual(formatDuration(59), '59s'); + assert.strictEqual(formatDuration(90), '1m 30s'); + assert.strictEqual(formatDuration(3670), '1h 1m'); + assert.strictEqual(formatDuration(90000), '1d 1h'); + assert.strictEqual(formatDuration(-10), '0s'); }); }); diff --git a/tests/unit/utils/place-address-test.js b/tests/unit/utils/place-address-test.js new file mode 100644 index 00000000..b373c1a5 --- /dev/null +++ b/tests/unit/utils/place-address-test.js @@ -0,0 +1,16 @@ +import placeAddress from 'dummy/utils/place-address'; +import { module, test } from 'qunit'; + +module('Unit | Utility | place-address', function () { + test('it returns a safe address string', function (assert) { + const result = placeAddress({ + name: 'North Dock', + street1: '100 Harbor Road', + city: 'Singapore', + country_name: 'Singapore', + }); + + assert.true(String(result).includes('North Dock')); + assert.true(String(result).includes('100 Harbor Road')); + }); +}); From 9fc606832d33a8dc5ff346b76f7b3a7e112b03bc Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 4 Jun 2026 21:24:25 +0800 Subject: [PATCH 2/3] fix minor lint issue --- addon/components/button.hbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addon/components/button.hbs b/addon/components/button.hbs index cd3ad29c..c36cca6d 100644 --- a/addon/components/button.hbs +++ b/addon/components/button.hbs @@ -13,7 +13,7 @@ > {{#if @isLoading}} - + {{/if}} {{#if this.showIcon}} From e228975306db9f695611b930d78be27ade524225 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 4 Jun 2026 22:17:11 +0800 Subject: [PATCH 3/3] minor fix on docs-panel --- addon/components/docs-panel.hbs | 84 ++++++++++++++++----------------- 1 file changed, 41 insertions(+), 43 deletions(-) diff --git a/addon/components/docs-panel.hbs b/addon/components/docs-panel.hbs index 59712cd4..c5e92ada 100644 --- a/addon/components/docs-panel.hbs +++ b/addon/components/docs-panel.hbs @@ -1,47 +1,45 @@ {{#if this.docsPanel.isOpen}} - - - - <:actions> -
-
+ +
+ + {{#if (and this.docsPanel.canEmbed (not this.docsPanel.iframeFailed))}} + + {{else}} +
+
+
- - - - {{#if (and this.docsPanel.canEmbed (not this.docsPanel.iframeFailed))}} - - {{else}} -
-
- +
+
Open Fleetbase documentation
+
+ This page cannot be embedded here, but you can open it in a new tab.
-
-
Open Fleetbase documentation
-
- This page cannot be embedded here, but you can open it in a new tab. -
-
-
- {{/if}} - - - -{{/if}} +
+ {{/if}} + + +{{/if}} \ No newline at end of file