Skip to content

Commit 3cbe349

Browse files
authored
fix: three small dashboard fixes (#232, #935, #299) (#942)
* fix(categories): submit the category edit modal on Enter (#232) Pressing Enter in the category edit modal did nothing, so creating or editing a category always required reaching for the OK button. Enter now submits, except on elements that handle it themselves (textarea, button, link, select), and it respects the same validity condition that disables the OK button. * fix(views): rebuild visualizations when switching dashboard views (#935) The visualization cards were keyed by their position in the list only, so switching to another view let Vue reuse the instance that sat at the same index in the previous view. Visualizations that keep local state — the Top Bucket Data picker holds its selected bucket, field and fetched events in `data`, populated once in `mounted` — kept showing the previous view's selection and events. Keying on the view id as well forces a fresh instance per view. * fix(header): keep the navbar sticky on desktop too (#299) The navbar was only pinned on Android, so tall pages (the timeline, a long activity view) scrolled the navigation out of reach everywhere else and required scrolling back up to change views. The padding that reserves space for the fixed navbar was already in place, it was just gated on the platform. * address greptile review feedback (greploop iteration 1) - Offset the sticky settings sidebar by the fixed navbar's height, via a new --aw-navbar-height custom property shared with the navbar padding, so it no longer sticks underneath the navbar on desktop. - Replace the source-text assertion on the visualization key with a mount test that switches view_id and asserts a fresh child instance is created; verified it fails without the key change.
1 parent 3092e41 commit 3cbe349

7 files changed

Lines changed: 158 additions & 5 deletions

File tree

src/components/CategoryEditModal.vue

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<template lang="pug">
22
// The category edit modal
3-
b-modal(id="edit" ref="edit" title="Edit category" @show="resetModal" @hidden="hidden" @ok="handleOk" :ok-disabled="editing.rule.type === 'regex' && !validPattern")
3+
b-modal(id="edit" ref="edit" title="Edit category" @show="resetModal" @hidden="hidden" @ok="handleOk" @keydown.native.enter="handleEnter" :ok-disabled="editing.rule.type === 'regex' && !validPattern")
44
div.my-1
55
b-input-group.my-1(prepend="Name")
66
b-form-input(v-model="editing.name")
@@ -133,6 +133,22 @@ export default {
133133
}
134134
return true;
135135
},
136+
handleEnter(event) {
137+
// Enter submits the modal, same as pressing OK (#232).
138+
// Skipped for elements where Enter already has a meaning of its own,
139+
// so we don't swallow their default behavior.
140+
const tag = event.target && event.target.tagName;
141+
if (tag === 'TEXTAREA' || tag === 'BUTTON' || tag === 'A' || tag === 'SELECT') {
142+
return;
143+
}
144+
// Mirrors the :ok-disabled condition on the modal
145+
if (this.editing.rule.type === 'regex' && !this.validPattern) {
146+
return;
147+
}
148+
event.preventDefault();
149+
this.handleSubmit();
150+
this.$emit('ok');
151+
},
136152
handleOk(event) {
137153
// Prevent modal from closing
138154
event.preventDefault();

src/components/Header.vue

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ div(:class="{'fixed-top-padding': fixedTopMenu}")
9999

100100
<style lang="scss" scoped>
101101
.fixed-top-padding {
102-
padding-bottom: 3.5em;
102+
padding-bottom: var(--aw-navbar-height);
103103
}
104104
</style>
105105

@@ -138,7 +138,10 @@ export default {
138138
data() {
139139
return {
140140
activityViews: null,
141-
fixedTopMenu: this.$isAndroid,
141+
// Sticky on every platform: tall pages (the timeline, a long activity
142+
// view) otherwise scroll the navigation out of reach.
143+
// See https://github.com/ActivityWatch/aw-webui/issues/299
144+
fixedTopMenu: true,
142145
researchEdition: typeof AW_RESEARCH_EDITION !== 'undefined' && AW_RESEARCH_EDITION,
143146
};
144147
},

src/style/style.scss

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
@import "globals";
22

3+
:root {
4+
// Height of the fixed navbar. Anything that positions itself against the
5+
// top of the viewport (the padding that reserves the navbar's space in
6+
// Header.vue, the sticky settings sidebar) offsets by this.
7+
--aw-navbar-height: 3.5rem;
8+
}
9+
310
body,
411
html,
512
body,

src/views/activity/ActivityView.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ div(v-if="viewMissing")
99
div(v-else-if="view")
1010
draggable.row(v-model="elements" handle=".handle")
1111
// TODO: Handle large/variable sized visualizations better
12-
div.col-md-6.col-lg-4.p-3(v-for="el, index in elements", :key="index", :class="{'col-md-12': isVisLarge(el), 'col-lg-12': isVisLarge(el)}")
12+
//- Key on the view id as well as the position, so that switching views
13+
rebuilds the visualizations instead of reusing the instance that sat at
14+
the same index in the previous view (which kept its stale local state).
15+
div.col-md-6.col-lg-4.p-3(v-for="el, index in elements", :key="view.id + '-' + index", :class="{'col-md-12': isVisLarge(el), 'col-lg-12': isVisLarge(el)}")
1316
aw-selectable-vis(:id="index" :type="el.type" :props="el.props" :view-id="view.id" @onTypeChange="onTypeChange" @onRemove="onRemove" :editable="editing")
1417

1518
div.col-md-6.col-lg-4.p-3(v-if="editing")

src/views/settings/Settings.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,8 @@ export default {
154154
155155
.settings-nav {
156156
position: sticky;
157-
top: 1rem;
157+
// Clear the fixed navbar, which overlaps the top of the viewport
158+
top: calc(var(--aw-navbar-height) + 1rem);
158159
align-self: start;
159160
}
160161

test/unit/ActivityView.test.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
1+
import { shallowMount } from '@vue/test-utils';
12
import ActivityView from '~/views/activity/ActivityView.vue';
23

4+
const mockViews = [
5+
{ id: 'default', name: 'Default', elements: [{ type: 'top_apps', props: {} }] },
6+
{ id: 'second', name: 'Second', elements: [{ type: 'top_bucket_data', props: {} }] },
7+
];
8+
9+
jest.mock('~/stores/views', () => ({
10+
useViewsStore: () => ({
11+
viewsForHost: () => mockViews,
12+
}),
13+
}));
14+
315
describe('ActivityView isVisLarge', () => {
416
test('treats wide visualizations as full-width cards', () => {
517
expect(ActivityView.methods.isVisLarge({ type: 'sunburst_clock' })).toBe(true);
@@ -8,3 +20,65 @@ describe('ActivityView isVisLarge', () => {
820
expect(ActivityView.methods.isVisLarge({ type: 'top_apps' })).toBe(false);
921
});
1022
});
23+
24+
// Visualizations keep local state — the Top Bucket Data picker holds its
25+
// selected bucket, field and fetched events in `data` and fills them in
26+
// `mounted`. Every view renders the same list at the same positions, so if a
27+
// card is keyed by its index alone Vue reuses the instance from the previous
28+
// view and the stale state comes along with it.
29+
// See https://github.com/ActivityWatch/aw-webui/issues/935
30+
describe('ActivityView view switching', () => {
31+
const passthroughStub = { template: '<div><slot /></div>' };
32+
33+
// Records a line per aw-selectable-vis instance created, so we can tell a
34+
// reused instance (no new record) from a fresh one.
35+
let created;
36+
37+
const visStub = {
38+
name: 'aw-selectable-vis',
39+
props: ['id', 'type', 'props', 'viewId', 'editable'],
40+
created() {
41+
created.push(`${this.viewId}:${this.id}:${this.type}`);
42+
},
43+
render: h => h('div'),
44+
};
45+
46+
function mountView() {
47+
return shallowMount(ActivityView, {
48+
propsData: { view_id: 'default' },
49+
mocks: { $route: { params: {}, path: '/activity/view/default' }, $t: key => key },
50+
stubs: {
51+
draggable: { template: '<div><slot /></div>' },
52+
'aw-selectable-vis': visStub,
53+
// Globally registered in main.js, so not resolvable from a bare mount
54+
'b-button': passthroughStub,
55+
'b-modal': passthroughStub,
56+
icon: passthroughStub,
57+
},
58+
});
59+
}
60+
61+
beforeEach(() => {
62+
created = [];
63+
});
64+
65+
test('creates a fresh visualization instance when switching views', async () => {
66+
const wrapper = mountView();
67+
expect(created).toEqual(['default:0:top_apps']);
68+
69+
await wrapper.setProps({ view_id: 'second' });
70+
71+
// Without the view id in the key this stays at one entry: Vue patches the
72+
// props of the instance already sitting at index 0 rather than rebuilding.
73+
expect(created).toEqual(['default:0:top_apps', 'second:0:top_bucket_data']);
74+
wrapper.destroy();
75+
});
76+
77+
test('does not rebuild visualizations while staying on the same view', async () => {
78+
const wrapper = mountView();
79+
await wrapper.setProps({ view_id: 'default' });
80+
81+
expect(created).toEqual(['default:0:top_apps']);
82+
wrapper.destroy();
83+
});
84+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import CategoryEditModal from '~/components/CategoryEditModal.vue';
2+
3+
// Enter inside the category edit modal should submit it, same as pressing OK.
4+
// See https://github.com/ActivityWatch/aw-webui/issues/232
5+
describe('CategoryEditModal handleEnter', () => {
6+
function ctx({ tagName = 'INPUT', ruleType = 'regex', validPattern = true } = {}) {
7+
const event = { target: { tagName }, preventDefault: jest.fn() };
8+
const vm = {
9+
editing: { rule: { type: ruleType } },
10+
validPattern,
11+
handleSubmit: jest.fn(),
12+
$emit: jest.fn(),
13+
};
14+
return { vm, event };
15+
}
16+
17+
const handleEnter = (vm, event) => CategoryEditModal.methods.handleEnter.call(vm, event);
18+
19+
test('submits when Enter is pressed in a text input', () => {
20+
const { vm, event } = ctx();
21+
handleEnter(vm, event);
22+
expect(event.preventDefault).toHaveBeenCalled();
23+
expect(vm.handleSubmit).toHaveBeenCalled();
24+
expect(vm.$emit).toHaveBeenCalledWith('ok');
25+
});
26+
27+
test.each(['TEXTAREA', 'BUTTON', 'A', 'SELECT'])(
28+
'leaves Enter alone on <%s>, which handles it itself',
29+
tagName => {
30+
const { vm, event } = ctx({ tagName });
31+
handleEnter(vm, event);
32+
expect(event.preventDefault).not.toHaveBeenCalled();
33+
expect(vm.handleSubmit).not.toHaveBeenCalled();
34+
}
35+
);
36+
37+
test('does not submit an invalid regex, mirroring the disabled OK button', () => {
38+
const { vm, event } = ctx({ validPattern: false });
39+
handleEnter(vm, event);
40+
expect(vm.handleSubmit).not.toHaveBeenCalled();
41+
expect(vm.$emit).not.toHaveBeenCalled();
42+
});
43+
44+
test('submits a rule with no pattern to validate', () => {
45+
const { vm, event } = ctx({ ruleType: 'none', validPattern: false });
46+
handleEnter(vm, event);
47+
expect(vm.handleSubmit).toHaveBeenCalled();
48+
});
49+
});

0 commit comments

Comments
 (0)