Skip to content

Commit eeff10a

Browse files
authored
Merge pull request #135 from fleetbase/dev-v0.3.34
v0.3.34
2 parents 993b62a + 69f5839 commit eeff10a

47 files changed

Lines changed: 4196 additions & 322 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

addon/components/activity-log.hbs

Lines changed: 119 additions & 122 deletions
Large diffs are not rendered by default.

addon/components/activity-log.js

Lines changed: 119 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -5,41 +5,53 @@ import { action } from '@ember/object';
55
import { debug } from '@ember/debug';
66
import { isArray } from '@ember/array';
77
import { capitalize } from '@ember/string';
8-
import { htmlSafe } from '@ember/template';
98
import { task } from 'ember-concurrency';
109
import { parseISO, isDate, isValid, format, formatDistanceToNow } from 'date-fns';
1110
import smartHumanize from '../utils/smart-humanize';
1211

1312
export default class ActivityLogComponent extends Component {
1413
@service store;
1514
@tracked activities = [];
16-
@tracked expanded = new Set();
1715
@tracked dateFilter = null;
18-
@tracked query = null;
1916

20-
// Optional style “knobs” (you can pass in from the parent)
2117
get density() {
2218
return this.args.density ?? 'compact';
23-
} // 'cozy'|'compact'
19+
}
20+
2421
get showAvatars() {
2522
return this.args.showAvatars ?? true;
2623
}
24+
2725
get showBadges() {
28-
return this.args.showBadges ?? true;
26+
return this.args.showBadges ?? false;
2927
}
3028

31-
get groups() {
32-
const activities = isArray(this.activities) ? this.activities : [];
29+
get showHeader() {
30+
return this.args.showHeader ?? true;
31+
}
3332

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

36-
const byDay = new Map();
37-
for (const item of normalized) {
38-
const key = item.dayKey ?? 'unknown';
39-
if (!byDay.has(key)) byDay.set(key, { dateLabel: item.dayLabel ?? key, items: [] });
40-
byDay.get(key).items.push(item);
37+
get showAttributePreviousValues() {
38+
return this.args.showAttributePreviousValues ?? true;
39+
}
40+
41+
get items() {
42+
const activities = isArray(this.activities) ? this.activities : [];
43+
const normalized = activities.map((activity, index) => this.#normalizeActivity(activity, index)).sort((a, b) => (b.timestamp?.dateMs ?? 0) - (a.timestamp?.dateMs ?? 0));
44+
const limit = Number(this.args.maxVisibleActivities);
45+
46+
if (Number.isFinite(limit) && limit > 0) {
47+
return normalized.slice(0, limit);
4148
}
42-
return [...byDay.values()];
49+
50+
return normalized;
51+
}
52+
53+
get hasItems() {
54+
return this.items.length > 0;
4355
}
4456

4557
constructor() {
@@ -51,6 +63,7 @@ export default class ActivityLogComponent extends Component {
5163
@task *loadActivities() {
5264
try {
5365
const params = {};
66+
if (this.args.companyUuid) params.company_uuid = this.args.companyUuid;
5467
if (this.args.subjectId) params.subject_id = this.args.subjectId;
5568
if (this.args.causerId) params.causer_id = this.args.causerId;
5669
if (this.dateFilter) params.created_at = this.dateFilter;
@@ -79,12 +92,6 @@ export default class ActivityLogComponent extends Component {
7992
if (typeof this.args.onSubjectClick === 'function') this.args.onSubjectClick(subject);
8093
}
8194

82-
@action toggleAdvanced(itemKey) {
83-
const next = new Set(this.expanded);
84-
next.has(itemKey) ? next.delete(itemKey) : next.add(itemKey);
85-
this.expanded = next;
86-
}
87-
8895
// ── Normalize & Phrase ──────────────────────────────────────────────────────
8996
#normalizeActivity(activity, idx = 0) {
9097
const createdISO = activity?.created_at ?? null;
@@ -94,105 +101,81 @@ export default class ActivityLogComponent extends Component {
94101
const d = this.#parseDate(tsISO);
95102
const dateMs = d ? d.getTime() : 0;
96103
const dayKey = d ? format(d, 'yyyy-MM-dd') : 'unknown';
97-
const dayLabel = d ? format(d, 'EEE, MMM dd, yyyy') : 'Unknown date';
98104
const exactLocal = d ? format(d, 'PP p') : '';
99105
const relative = d ? formatDistanceToNow(d, { addSuffix: true }) : '';
100106

101107
const causer = activity?.causer ?? {};
102108
const subject = activity?.subject ?? {};
103109
const subjectTypeLabel = activity?.humanized_subject_type ?? this.#subjectTypeLabel(activity?.subject_type);
104-
const subjectDisplay = this.#subjectDisplay(subject);
105110
const event = String(activity?.event || '').toLowerCase();
111+
const changes = this.#computeChanges(activity?.properties);
112+
const changeCount = changes.length;
113+
const shouldShowSubjectContext = this.#shouldShowSubjectContext();
114+
const verb = this.#eventToVerb(event, activity?.description, changeCount, shouldShowSubjectContext);
115+
const hasMultipleChanges = changeCount > 1;
116+
const inlineChange = changeCount === 1 ? this.#inlineChangeSummary(changes[0]) : null;
117+
const objectLabel = this.#objectLabel(activity, subjectTypeLabel);
118+
const targetPhrase = shouldShowSubjectContext ? this.#targetPhrase(activity, subjectTypeLabel, event) : null;
119+
const actorName = causer?.name ?? 'Someone';
106120

107-
const eventLabel = capitalize(event || 'updated');
108-
const verb = this.#eventToVerb(event, activity?.description);
109-
110-
// Diffs → split into simple vs advanced, and also build a human inline sentence
111-
const allChanges = this.#computeChanges(activity?.properties);
112-
const simpleChanges = [];
113-
const advancedChanges = [];
114-
115-
for (const c of allChanges) {
116-
if (this.#isAdvancedValue(c.fromRaw, c.toRaw) || this.#isLikelyUuidKey(c.key)) {
117-
advancedChanges.push(c);
118-
} else {
119-
simpleChanges.push(c);
120-
}
121-
}
122-
123-
const inlineSummary = this.#summarizeSimple(simpleChanges);
124-
125-
const sentence = `${causer?.name ?? 'Someone'} ${verb} ${subjectTypeLabel} (${subjectDisplay})`;
126-
127-
// Event → badge style (Fleetbase-ish accent mapping)
128121
const badge = this.#eventBadge(event);
129122

130123
return {
131124
key: `${dayKey}-${idx}`,
132125
actor: {
133126
name: causer?.name ?? 'Unknown',
134127
avatarUrl: causer?.avatar_url ?? null,
128+
initial: this.#initial(actorName),
135129
raw: causer,
136130
},
137131
subject,
138132
causer,
139133
verb,
140134
event,
141-
eventLabel,
142-
badge, // {text, class}
135+
eventLabel: capitalize(event || 'updated'),
136+
badge,
143137
subjectTypeLabel,
144-
subjectDisplay,
145-
sentence,
146-
inlineSummary, // "set color to red; status to live"
138+
objectLabel,
139+
targetPhrase,
140+
changes,
141+
changeCount,
142+
hasChanges: changeCount > 0,
143+
hasMultipleChanges,
144+
inlineChange,
147145
timestamp: {
148146
iso: tsISO,
149147
exactLocal,
150148
relative,
151149
dateMs,
152150
},
153151
dayKey,
154-
dayLabel,
155-
simpleChanges,
156-
advancedChanges,
157152
raw: activity,
158153
};
159154
}
160155

161-
#summarizeSimple(simpleChanges) {
162-
if (!simpleChanges?.length) return '';
163-
164-
const parts = [];
165-
for (const c of simpleChanges) {
166-
const k = c.key.replace(/_/g, ' ');
167-
168-
if (c.from !== 'null' && c.from !== undefined && c.from !== '' && c.from !== c.to) {
169-
parts.push(
170-
`<span class="activity-change">changed <span class="activity-change-prop highlight-gray ${
171-
this.args.activityChangePropClass ?? ''
172-
}">${k}</span> from <span class="activity-change-prop highlight-gray ${this.args.activityPreviousValueClass ?? ''}">${this.#code(
173-
c.from
174-
)}</span> to <span class="activity-change-prop highlight-blue ${this.args.activityNewValueClass ?? ''}">${this.#code(c.to)}</span></span>`
175-
);
176-
} else {
177-
parts.push(
178-
`<span class="activity-change">set <span class="activity-change-prop highlight-gray ${
179-
this.args.activityChangePropClass ?? ''
180-
}">${k}</span> to <span class="activity-change-prop highlight-blue ${this.args.activityNewValueClass ?? ''}">${this.#code(c.to)}</span></span>`
181-
);
182-
}
156+
#inlineChangeSummary(change) {
157+
if (!change) return null;
158+
if (this.#isAdvancedValue(change.fromRaw, change.toRaw) || this.#isLikelyUuidKey(change.key)) return null;
159+
if (change.from !== 'null' && change.from !== undefined && change.from !== '' && change.from !== change.to) {
160+
return {
161+
attribute: change.label,
162+
from: change.from,
163+
to: change.to,
164+
hasPreviousValue: true,
165+
};
183166
}
184167

185-
// Make the entire thing safe once at the end
186-
return htmlSafe(parts.join(', '));
187-
}
188-
189-
#code(v) {
190-
// lightweight backtick wrapper for inline emphasis
191-
return this.args.backtickValues ? `\`${String(v)}\`` : v;
168+
return {
169+
attribute: change.label,
170+
to: change.to,
171+
hasPreviousValue: false,
172+
};
192173
}
193174

194-
#eventToVerb(event, description) {
175+
#eventToVerb(event, description, changeCount = 0, showSubjectContext = false) {
195176
if (description && typeof description === 'string') return description;
177+
if (showSubjectContext && event === 'updated') return 'updated';
178+
if (changeCount > 0 && (!event || event === 'updated')) return 'changed';
196179
switch (event) {
197180
case 'created':
198181
return 'created';
@@ -234,6 +217,7 @@ export default class ActivityLogComponent extends Component {
234217

235218
out.push({
236219
key,
220+
label: this.#attributeLabel(key),
237221
from: this.#formatValue(prev),
238222
to: this.#formatValue(next),
239223
fromRaw: prev,
@@ -261,6 +245,10 @@ export default class ActivityLogComponent extends Component {
261245
return typeof key === 'string' && (key.endsWith('_uuid') || key === 'uuid' || key.endsWith('Id') || key.endsWith('_id'));
262246
}
263247

248+
#attributeLabel(key) {
249+
return smartHumanize(String(key).replace(/_/g, ' '));
250+
}
251+
264252
#looksLikeUuid(v) {
265253
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);
266254
}
@@ -292,6 +280,55 @@ export default class ActivityLogComponent extends Component {
292280
return subject.display_name || subject.name || subject.title || subject.address || subject.tracking || subject.public_id || subject.uuid || 'Unknown';
293281
}
294282

283+
#objectLabel(activity, subjectTypeLabel) {
284+
const subjectDisplay = this.#subjectDisplay(activity?.subject);
285+
return subjectDisplay !== 'Unknown' ? subjectDisplay : subjectTypeLabel;
286+
}
287+
288+
#shouldShowSubjectContext() {
289+
if (typeof this.args.showSubjectContext === 'boolean') {
290+
return this.args.showSubjectContext;
291+
}
292+
293+
if (this.args.subjectId) {
294+
return false;
295+
}
296+
297+
return Boolean(this.args.companyUuid || this.args.causerId);
298+
}
299+
300+
#targetPhrase(activity, subjectTypeLabel, event) {
301+
const typeLabel = this.#sentenceCaseLabel(subjectTypeLabel);
302+
if (!typeLabel) return null;
303+
304+
const subjectDisplay = this.#subjectDisplay(activity?.subject);
305+
const hasDisplay = subjectDisplay !== 'Unknown' && subjectDisplay.toLowerCase() !== typeLabel;
306+
const displaySuffix = hasDisplay ? ` (${subjectDisplay})` : '';
307+
const article = this.#indefiniteArticle(typeLabel);
308+
309+
if (event === 'created') {
310+
return `${article} new ${typeLabel}${displaySuffix}`;
311+
}
312+
313+
return `${article} ${typeLabel}${displaySuffix}`;
314+
}
315+
316+
#sentenceCaseLabel(label) {
317+
if (!label || typeof label !== 'string') return '';
318+
return label.trim().toLowerCase();
319+
}
320+
321+
#indefiniteArticle(label) {
322+
return /^[aeiou]/i.test(label) ? 'an' : 'a';
323+
}
324+
325+
#initial(name) {
326+
return String(name || 'S')
327+
.trim()
328+
.charAt(0)
329+
.toUpperCase();
330+
}
331+
295332
#isPlainObject(v) {
296333
return v && typeof v === 'object' && !isArray(v);
297334
}

addon/components/custom-field/input.hbs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
@radioId={{concat this.customField.name "-radio-option-" index}}
5555
@value={{radioOption}}
5656
@groupValue={{this.value}}
57-
@name={{radioOption}}
57+
@name={{this.customField.name}}
5858
@changed={{this.onChangeHandler}}
5959
/>
6060
<div class="ml-2 text-sm dark:text-gray-100 text-gray-900 truncate">{{radioOption}}</div>

addon/components/date-time-input.js

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import Component from '@glimmer/component';
22
import { tracked } from '@glimmer/tracking';
33
import { action } from '@ember/object';
4-
import { parse, format } from 'date-fns';
4+
import { parse, format, isValid } from 'date-fns';
55

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

16-
this.date = this.args.value instanceof Date ? format(this.args.value, this.dateFormat) : null;
17-
this.time = this.args.value instanceof Date ? format(this.args.value, this.timeFormat) : null;
16+
const value = this.parseValue(this.args.value);
17+
18+
this.date = value ? format(value, this.dateFormat) : null;
19+
this.time = value ? format(value, this.timeFormat) : null;
20+
}
21+
22+
parseValue(value) {
23+
if (value instanceof Date && isValid(value)) {
24+
return value;
25+
}
26+
27+
if (typeof value === 'string') {
28+
const parsedValue = parse(value, this.dateTimeFormat, new Date());
29+
30+
if (isValid(parsedValue)) {
31+
return parsedValue;
32+
}
33+
}
34+
35+
return null;
1836
}
1937

2038
/**

addon/components/docs-panel.hbs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,21 @@
1616
</div>
1717
</:actions>
1818
</Overlay::Header>
19-
<Overlay::Body class="no-padding" @wrapperClass="fleetbase-docs-panel-body">
19+
<Overlay::Body class="no-padding" @wrapperClass={{this.docsPanel.bodyWrapperClass}}>
2020
{{#if (and this.docsPanel.canEmbed (not this.docsPanel.iframeFailed))}}
21+
{{#if this.docsPanel.isIframeLoading}}
22+
<div class="fleetbase-docs-panel-loading">
23+
<Spinner @iconClass="text-sky-500 fa-spin-800ms" />
24+
<span>Loading documentation...</span>
25+
</div>
26+
{{/if}}
2127
<iframe
2228
src={{this.docsPanel.url}}
2329
title={{this.docsPanel.title}}
24-
class="h-full w-full border-0 bg-white dark:bg-gray-900"
30+
class="h-full w-full border-0 {{if this.docsPanel.isIframeThemeDark "bg-gray-900" "bg-white"}}"
2531
loading="lazy"
2632
referrerpolicy="strict-origin-when-cross-origin"
33+
{{on "load" this.markIframeLoaded}}
2734
{{on "error" this.markIframeFailed}}
2835
></iframe>
2936
{{else}}
@@ -42,4 +49,4 @@
4249
{{/if}}
4350
</Overlay::Body>
4451
</Overlay>
45-
{{/if}}
52+
{{/if}}

addon/components/docs-panel.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ export default class DocsPanelComponent extends Component {
1515
this.docsPanel.markIframeFailed();
1616
}
1717

18+
@action
19+
markIframeLoaded() {
20+
this.docsPanel.markIframeLoaded();
21+
}
22+
1823
@action
1924
openExternal() {
2025
this.docsPanel.openExternal();

0 commit comments

Comments
 (0)