Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
241 changes: 119 additions & 122 deletions addon/components/activity-log.hbs

Large diffs are not rendered by default.

201 changes: 119 additions & 82 deletions addon/components/activity-log.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,41 +5,53 @@ import { action } from '@ember/object';
import { debug } from '@ember/debug';
import { isArray } from '@ember/array';
import { capitalize } from '@ember/string';
import { htmlSafe } from '@ember/template';
import { task } from 'ember-concurrency';
import { parseISO, isDate, isValid, format, formatDistanceToNow } from 'date-fns';
import smartHumanize from '../utils/smart-humanize';

export default class ActivityLogComponent extends Component {
@service store;
@tracked activities = [];
@tracked expanded = new Set();
@tracked dateFilter = null;
@tracked query = null;

// Optional style “knobs” (you can pass in from the parent)
get density() {
return this.args.density ?? 'compact';
} // 'cozy'|'compact'
}

get showAvatars() {
return this.args.showAvatars ?? true;
}

get showBadges() {
return this.args.showBadges ?? true;
return this.args.showBadges ?? false;
}

get groups() {
const activities = isArray(this.activities) ? this.activities : [];
get showHeader() {
return this.args.showHeader ?? true;
}

const normalized = activities.map((a, i) => this.#normalizeActivity(a, i)).sort((a, b) => (b.timestamp?.dateMs ?? 0) - (a.timestamp?.dateMs ?? 0));
get showControls() {
return this.args.showControls ?? true;
}

const byDay = new Map();
for (const item of normalized) {
const key = item.dayKey ?? 'unknown';
if (!byDay.has(key)) byDay.set(key, { dateLabel: item.dayLabel ?? key, items: [] });
byDay.get(key).items.push(item);
get showAttributePreviousValues() {
return this.args.showAttributePreviousValues ?? true;
}

get items() {
const activities = isArray(this.activities) ? this.activities : [];
const normalized = activities.map((activity, index) => this.#normalizeActivity(activity, index)).sort((a, b) => (b.timestamp?.dateMs ?? 0) - (a.timestamp?.dateMs ?? 0));
const limit = Number(this.args.maxVisibleActivities);

if (Number.isFinite(limit) && limit > 0) {
return normalized.slice(0, limit);
}
return [...byDay.values()];

return normalized;
}

get hasItems() {
return this.items.length > 0;
}

constructor() {
Expand All @@ -51,6 +63,7 @@ export default class ActivityLogComponent extends Component {
@task *loadActivities() {
try {
const params = {};
if (this.args.companyUuid) params.company_uuid = this.args.companyUuid;
if (this.args.subjectId) params.subject_id = this.args.subjectId;
if (this.args.causerId) params.causer_id = this.args.causerId;
if (this.dateFilter) params.created_at = this.dateFilter;
Expand Down Expand Up @@ -79,12 +92,6 @@ export default class ActivityLogComponent extends Component {
if (typeof this.args.onSubjectClick === 'function') this.args.onSubjectClick(subject);
}

@action toggleAdvanced(itemKey) {
const next = new Set(this.expanded);
next.has(itemKey) ? next.delete(itemKey) : next.add(itemKey);
this.expanded = next;
}

// ── Normalize & Phrase ──────────────────────────────────────────────────────
#normalizeActivity(activity, idx = 0) {
const createdISO = activity?.created_at ?? null;
Expand All @@ -94,105 +101,81 @@ export default class ActivityLogComponent extends Component {
const d = this.#parseDate(tsISO);
const dateMs = d ? d.getTime() : 0;
const dayKey = d ? format(d, 'yyyy-MM-dd') : 'unknown';
const dayLabel = d ? format(d, 'EEE, MMM dd, yyyy') : 'Unknown date';
const exactLocal = d ? format(d, 'PP p') : '';
const relative = d ? formatDistanceToNow(d, { addSuffix: true }) : '';

const causer = activity?.causer ?? {};
const subject = activity?.subject ?? {};
const subjectTypeLabel = activity?.humanized_subject_type ?? this.#subjectTypeLabel(activity?.subject_type);
const subjectDisplay = this.#subjectDisplay(subject);
const event = String(activity?.event || '').toLowerCase();
const changes = this.#computeChanges(activity?.properties);
const changeCount = changes.length;
const shouldShowSubjectContext = this.#shouldShowSubjectContext();
const verb = this.#eventToVerb(event, activity?.description, changeCount, shouldShowSubjectContext);
const hasMultipleChanges = changeCount > 1;
const inlineChange = changeCount === 1 ? this.#inlineChangeSummary(changes[0]) : null;
const objectLabel = this.#objectLabel(activity, subjectTypeLabel);
const targetPhrase = shouldShowSubjectContext ? this.#targetPhrase(activity, subjectTypeLabel, event) : null;
const actorName = causer?.name ?? 'Someone';

const eventLabel = capitalize(event || 'updated');
const verb = this.#eventToVerb(event, activity?.description);

// Diffs → split into simple vs advanced, and also build a human inline sentence
const allChanges = this.#computeChanges(activity?.properties);
const simpleChanges = [];
const advancedChanges = [];

for (const c of allChanges) {
if (this.#isAdvancedValue(c.fromRaw, c.toRaw) || this.#isLikelyUuidKey(c.key)) {
advancedChanges.push(c);
} else {
simpleChanges.push(c);
}
}

const inlineSummary = this.#summarizeSimple(simpleChanges);

const sentence = `${causer?.name ?? 'Someone'} ${verb} ${subjectTypeLabel} (${subjectDisplay})`;

// Event → badge style (Fleetbase-ish accent mapping)
const badge = this.#eventBadge(event);

return {
key: `${dayKey}-${idx}`,
actor: {
name: causer?.name ?? 'Unknown',
avatarUrl: causer?.avatar_url ?? null,
initial: this.#initial(actorName),
raw: causer,
},
subject,
causer,
verb,
event,
eventLabel,
badge, // {text, class}
eventLabel: capitalize(event || 'updated'),
badge,
subjectTypeLabel,
subjectDisplay,
sentence,
inlineSummary, // "set color to red; status to live"
objectLabel,
targetPhrase,
changes,
changeCount,
hasChanges: changeCount > 0,
hasMultipleChanges,
inlineChange,
timestamp: {
iso: tsISO,
exactLocal,
relative,
dateMs,
},
dayKey,
dayLabel,
simpleChanges,
advancedChanges,
raw: activity,
};
}

#summarizeSimple(simpleChanges) {
if (!simpleChanges?.length) return '';

const parts = [];
for (const c of simpleChanges) {
const k = c.key.replace(/_/g, ' ');

if (c.from !== 'null' && c.from !== undefined && c.from !== '' && c.from !== c.to) {
parts.push(
`<span class="activity-change">changed <span class="activity-change-prop highlight-gray ${
this.args.activityChangePropClass ?? ''
}">${k}</span> from <span class="activity-change-prop highlight-gray ${this.args.activityPreviousValueClass ?? ''}">${this.#code(
c.from
)}</span> to <span class="activity-change-prop highlight-blue ${this.args.activityNewValueClass ?? ''}">${this.#code(c.to)}</span></span>`
);
} else {
parts.push(
`<span class="activity-change">set <span class="activity-change-prop highlight-gray ${
this.args.activityChangePropClass ?? ''
}">${k}</span> to <span class="activity-change-prop highlight-blue ${this.args.activityNewValueClass ?? ''}">${this.#code(c.to)}</span></span>`
);
}
#inlineChangeSummary(change) {
if (!change) return null;
if (this.#isAdvancedValue(change.fromRaw, change.toRaw) || this.#isLikelyUuidKey(change.key)) return null;
if (change.from !== 'null' && change.from !== undefined && change.from !== '' && change.from !== change.to) {
return {
attribute: change.label,
from: change.from,
to: change.to,
hasPreviousValue: true,
};
}

// Make the entire thing safe once at the end
return htmlSafe(parts.join(', '));
}

#code(v) {
// lightweight backtick wrapper for inline emphasis
return this.args.backtickValues ? `\`${String(v)}\`` : v;
return {
attribute: change.label,
to: change.to,
hasPreviousValue: false,
};
}

#eventToVerb(event, description) {
#eventToVerb(event, description, changeCount = 0, showSubjectContext = false) {
if (description && typeof description === 'string') return description;
if (showSubjectContext && event === 'updated') return 'updated';
if (changeCount > 0 && (!event || event === 'updated')) return 'changed';
switch (event) {
case 'created':
return 'created';
Expand Down Expand Up @@ -234,6 +217,7 @@ export default class ActivityLogComponent extends Component {

out.push({
key,
label: this.#attributeLabel(key),
from: this.#formatValue(prev),
to: this.#formatValue(next),
fromRaw: prev,
Expand Down Expand Up @@ -261,6 +245,10 @@ export default class ActivityLogComponent extends Component {
return typeof key === 'string' && (key.endsWith('_uuid') || key === 'uuid' || key.endsWith('Id') || key.endsWith('_id'));
}

#attributeLabel(key) {
return smartHumanize(String(key).replace(/_/g, ' '));
}

#looksLikeUuid(v) {
return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/.test(v);
}
Expand Down Expand Up @@ -292,6 +280,55 @@ export default class ActivityLogComponent extends Component {
return subject.display_name || subject.name || subject.title || subject.address || subject.tracking || subject.public_id || subject.uuid || 'Unknown';
}

#objectLabel(activity, subjectTypeLabel) {
const subjectDisplay = this.#subjectDisplay(activity?.subject);
return subjectDisplay !== 'Unknown' ? subjectDisplay : subjectTypeLabel;
}

#shouldShowSubjectContext() {
if (typeof this.args.showSubjectContext === 'boolean') {
return this.args.showSubjectContext;
}

if (this.args.subjectId) {
return false;
}

return Boolean(this.args.companyUuid || this.args.causerId);
}

#targetPhrase(activity, subjectTypeLabel, event) {
const typeLabel = this.#sentenceCaseLabel(subjectTypeLabel);
if (!typeLabel) return null;

const subjectDisplay = this.#subjectDisplay(activity?.subject);
const hasDisplay = subjectDisplay !== 'Unknown' && subjectDisplay.toLowerCase() !== typeLabel;
const displaySuffix = hasDisplay ? ` (${subjectDisplay})` : '';
const article = this.#indefiniteArticle(typeLabel);

if (event === 'created') {
return `${article} new ${typeLabel}${displaySuffix}`;
}

return `${article} ${typeLabel}${displaySuffix}`;
}

#sentenceCaseLabel(label) {
if (!label || typeof label !== 'string') return '';
return label.trim().toLowerCase();
}

#indefiniteArticle(label) {
return /^[aeiou]/i.test(label) ? 'an' : 'a';
}

#initial(name) {
return String(name || 'S')
.trim()
.charAt(0)
.toUpperCase();
}

#isPlainObject(v) {
return v && typeof v === 'object' && !isArray(v);
}
Expand Down
2 changes: 1 addition & 1 deletion addon/components/custom-field/input.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
@radioId={{concat this.customField.name "-radio-option-" index}}
@value={{radioOption}}
@groupValue={{this.value}}
@name={{radioOption}}
@name={{this.customField.name}}
@changed={{this.onChangeHandler}}
/>
<div class="ml-2 text-sm dark:text-gray-100 text-gray-900 truncate">{{radioOption}}</div>
Expand Down
24 changes: 21 additions & 3 deletions addon/components/date-time-input.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { parse, format } from 'date-fns';
import { parse, format, isValid } from 'date-fns';

export default class DateTimeInputComponent extends Component {
@tracked timeFormat = 'HH:mm';
Expand All @@ -13,8 +13,26 @@ export default class DateTimeInputComponent extends Component {
constructor() {
super(...arguments);

this.date = this.args.value instanceof Date ? format(this.args.value, this.dateFormat) : null;
this.time = this.args.value instanceof Date ? format(this.args.value, this.timeFormat) : null;
const value = this.parseValue(this.args.value);

this.date = value ? format(value, this.dateFormat) : null;
this.time = value ? format(value, this.timeFormat) : null;
}

parseValue(value) {
if (value instanceof Date && isValid(value)) {
return value;
}

if (typeof value === 'string') {
const parsedValue = parse(value, this.dateTimeFormat, new Date());

if (isValid(parsedValue)) {
return parsedValue;
}
}

return null;
}

/**
Expand Down
13 changes: 10 additions & 3 deletions addon/components/docs-panel.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,21 @@
</div>
</:actions>
</Overlay::Header>
<Overlay::Body class="no-padding" @wrapperClass="fleetbase-docs-panel-body">
<Overlay::Body class="no-padding" @wrapperClass={{this.docsPanel.bodyWrapperClass}}>
{{#if (and this.docsPanel.canEmbed (not this.docsPanel.iframeFailed))}}
{{#if this.docsPanel.isIframeLoading}}
<div class="fleetbase-docs-panel-loading">
<Spinner @iconClass="text-sky-500 fa-spin-800ms" />
<span>Loading documentation...</span>
</div>
{{/if}}
<iframe
src={{this.docsPanel.url}}
title={{this.docsPanel.title}}
class="h-full w-full border-0 bg-white dark:bg-gray-900"
class="h-full w-full border-0 {{if this.docsPanel.isIframeThemeDark "bg-gray-900" "bg-white"}}"
loading="lazy"
referrerpolicy="strict-origin-when-cross-origin"
{{on "load" this.markIframeLoaded}}
{{on "error" this.markIframeFailed}}
></iframe>
{{else}}
Expand All @@ -42,4 +49,4 @@
{{/if}}
</Overlay::Body>
</Overlay>
{{/if}}
{{/if}}
5 changes: 5 additions & 0 deletions addon/components/docs-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export default class DocsPanelComponent extends Component {
this.docsPanel.markIframeFailed();
}

@action
markIframeLoaded() {
this.docsPanel.markIframeLoaded();
}

@action
openExternal() {
this.docsPanel.openExternal();
Expand Down
Loading
Loading