Skip to content

Commit 2809ee2

Browse files
fix(ux): replace prompt() with modals for Android WebView compatibility (#966)
* fix(ux): replace prompt() with modals for Android WebView compatibility prompt() is unsupported in Android WebView — it silently returns null with no error or feedback. Affected 5 call sites: - CategorizationSettings.vue: new category set name - QueryExplorer.vue: save query name and rename query - ActivityView.vue: watcher name + visualization title (combined modal) Each replaced with a b-modal + b-form-input, preserving the same validation logic. The custom_vis case uses a single modal with two fields instead of two sequential prompts. Git-Session-Id: 77f0 * ci: retrigger CI (pre-existing log-collection failure, tests all pass) Git-Session-Id: 4c53 * fix(ci): handle missing log dir in Move logs step When aw-server-rust master runs in test mode it does not create log files under ~/.cache/activitywatch/log. The bare glob mv then fails with 'cannot stat' (exit 1), failing the job even though all 7 e2e tests passed. Replace the glob mv with find ... -exec mv, which is a no-op when no logs exist (|| true guards the 2>/dev/null stderr case too). * fix(ux): keep invalid modal forms open Git-Session-Id: 54708688-1fac-5f0f-860f-38890e2aa434 * fix(ci): tolerate missing server logs Git-Session-Id: 54708688-1fac-5f0f-860f-38890e2aa434 * fix(ux): retain query modal on persistence failure Git-Session-Id: 4072740d-707c-569f-84e1-194d4c0175a6 * fix(ux): cancel query modal close before persistence Git-Session-Id: 0ce90394-8c74-545a-b4a3-e84ba2da00cf --------- Co-authored-by: Erik Bjäreholt <erik@bjareho.lt>
1 parent ec73005 commit 2809ee2

5 files changed

Lines changed: 259 additions & 28 deletions

File tree

src/views/QueryExplorer.vue

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,34 @@ div
4848
hr
4949

5050
aw-selectable-eventview(:events="events", :event_type="event_type")
51+
52+
b-modal(
53+
v-model="showSaveQueryModal"
54+
title="Save Query"
55+
ok-title="Save"
56+
@ok="onSaveQueryConfirm"
57+
@shown="$refs.saveQueryNameInput && $refs.saveQueryNameInput.focus()"
58+
)
59+
b-form-group(label="Name for the saved query:")
60+
b-form-input(
61+
ref="saveQueryNameInput"
62+
v-model="saveQueryName"
63+
placeholder="Query name"
64+
)
65+
66+
b-modal(
67+
v-model="showRenameQueryModal"
68+
title="Rename Query"
69+
ok-title="Rename"
70+
@ok="onRenameQueryConfirm"
71+
@shown="$refs.renameQueryNameInput && $refs.renameQueryNameInput.focus()"
72+
)
73+
b-form-group(label="New name for saved query:")
74+
b-form-input(
75+
ref="renameQueryNameInput"
76+
v-model="renameQueryName"
77+
placeholder="Query name"
78+
)
5179
</template>
5280

5381
<style scoped lang="scss">
@@ -108,6 +136,10 @@ RETURN = sort_by_duration(merged_events);
108136
selected_saved_query_id: '',
109137
startdate: today.format('YYYY-MM-DD'),
110138
enddate: tomorrow.format('YYYY-MM-DD'),
139+
showSaveQueryModal: false,
140+
saveQueryName: '',
141+
showRenameQueryModal: false,
142+
renameQueryName: '',
111143
};
112144
},
113145
computed: {
@@ -178,15 +210,16 @@ RETURN = sort_by_duration(merged_events);
178210
return;
179211
}
180212
181-
const defaultName = getDefaultSavedQueryName(this.query_code);
182-
const name = prompt('Name for the saved query:', defaultName);
183-
if (name === null) {
184-
return;
185-
}
213+
// No existing query — open modal to get a name
214+
this.saveQueryName = getDefaultSavedQueryName(this.query_code);
215+
this.showSaveQueryModal = true;
216+
},
217+
onSaveQueryConfirm: async function (event) {
218+
event.preventDefault();
186219
187-
const trimmedName = name.trim();
220+
const trimmedName = this.saveQueryName.trim();
188221
if (_.isEmpty(trimmedName)) {
189-
alert('Saved query name cannot be empty.');
222+
this.saved_query_error = 'Saved query name cannot be empty.';
190223
return;
191224
}
192225
@@ -206,30 +239,39 @@ RETURN = sort_by_duration(merged_events);
206239
const didPersist = await this.persistSavedQueries([...this.savedQueries, newQuery]);
207240
if (didPersist) {
208241
this.selected_saved_query_id = newId;
242+
this.showSaveQueryModal = false;
209243
}
210244
},
211245
renameSelectedQuery: async function () {
212246
if (!this.selectedSavedQuery) {
213247
return;
214248
}
215249
216-
const name = prompt('Rename saved query:', this.selectedSavedQuery.name);
217-
if (name === null) {
250+
this.renameQueryName = this.selectedSavedQuery.name;
251+
this.showRenameQueryModal = true;
252+
},
253+
onRenameQueryConfirm: async function (event) {
254+
event.preventDefault();
255+
256+
if (!this.selectedSavedQuery) {
218257
return;
219258
}
220259
221-
const trimmedName = name.trim();
260+
const trimmedName = this.renameQueryName.trim();
222261
if (_.isEmpty(trimmedName)) {
223-
alert('Saved query name cannot be empty.');
262+
this.saved_query_error = 'Saved query name cannot be empty.';
224263
return;
225264
}
226265
227266
const selectedQueryId = this.selectedSavedQuery.id;
228-
await this.persistSavedQueries(
267+
const didPersist = await this.persistSavedQueries(
229268
this.savedQueries.map(savedQuery =>
230269
savedQuery.id === selectedQueryId ? { ...savedQuery, name: trimmedName } : savedQuery
231270
)
232271
);
272+
if (didPersist) {
273+
this.showRenameQueryModal = false;
274+
}
233275
},
234276
deleteSelectedQuery: async function () {
235277
if (!this.selectedSavedQuery) {

src/views/activity/ActivityView.vue

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,23 @@ div(v-else-if="view")
5454
br
5555
br
5656
| This will delete the view's configuration. You can run #[b Restore defaults] to bring built-in views back.
57+
58+
b-modal(
59+
v-model="showCustomVisModal"
60+
title="Add Custom Visualization"
61+
ok-title="Add"
62+
@ok="onCustomVisConfirm"
63+
)
64+
b-form-group(label="Watcher name:")
65+
b-form-input(
66+
v-model="customVisWatcherName"
67+
placeholder="aw-watcher-"
68+
)
69+
b-form-group(label="Visualization title:")
70+
b-form-input(
71+
v-model="customVisTitle"
72+
placeholder="My Visualization"
73+
)
5774
</template>
5875

5976
<script lang="ts">
@@ -75,7 +92,13 @@ export default {
7592
view_id: { type: String, default: 'default' },
7693
},
7794
data() {
78-
return { editing: false };
95+
return {
96+
editing: false,
97+
showCustomVisModal: false,
98+
customVisWatcherName: 'aw-watcher-',
99+
customVisTitle: '',
100+
pendingCustomVisId: null as number | null,
101+
};
79102
},
80103
computed: {
81104
views: function () {
@@ -131,22 +154,32 @@ export default {
131154
useViewsStore().addVisualization({ view_id: this.view.id, type: 'top_apps' });
132155
},
133156
async onTypeChange(id, type) {
134-
let props = {};
135-
136157
if (type === 'custom_vis') {
137-
const visname = prompt('Please enter the watcher name', 'aw-watcher-');
138-
if (!visname) return;
139-
140-
const title = prompt('Please enter the visualization title');
141-
if (!title) return;
142-
143-
props = {
144-
visname,
145-
title,
146-
};
158+
// Show modal to collect watcher name and visualization title
159+
this.pendingCustomVisId = id;
160+
this.customVisWatcherName = 'aw-watcher-';
161+
this.customVisTitle = '';
162+
this.showCustomVisModal = true;
163+
return;
147164
}
148165
149-
await useViewsStore().editView({ view_id: this.view.id, el_id: id, type, props });
166+
await useViewsStore().editView({ view_id: this.view.id, el_id: id, type, props: {} });
167+
},
168+
async onCustomVisConfirm(event) {
169+
if (!this.customVisWatcherName.trim() || !this.customVisTitle.trim()) {
170+
event.preventDefault();
171+
return;
172+
}
173+
const props = {
174+
visname: this.customVisWatcherName,
175+
title: this.customVisTitle,
176+
};
177+
await useViewsStore().editView({
178+
view_id: this.view.id,
179+
el_id: this.pendingCustomVisId,
180+
type: 'custom_vis',
181+
props,
182+
});
150183
},
151184
async onRemove(id) {
152185
await useViewsStore().removeVisualization({ view_id: this.view.id, el_id: id });

src/views/settings/CategorizationSettings.vue

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,20 @@ div
8585
b-collapse#category-builder-collapse(v-model="builderOpen")
8686
div.mt-3(v-if="builderMounted")
8787
CategoryBuilder(embedded)
88+
89+
b-modal(
90+
v-model="showCreateSetModal"
91+
title="New Category Set"
92+
ok-title="Create"
93+
@ok="onCreateSetConfirm"
94+
@shown="$refs.newSetNameInput && $refs.newSetNameInput.focus()"
95+
)
96+
b-form-group(label="Name for the new category set:")
97+
b-form-input(
98+
ref="newSetNameInput"
99+
v-model="newSetName"
100+
placeholder="Category set name"
101+
)
88102
</template>
89103
<script lang="ts">
90104
import { mapState, mapGetters } from 'pinia';
@@ -112,6 +126,8 @@ export default {
112126
activeSetId: 'default',
113127
builderOpen: false,
114128
builderMounted: false,
129+
showCreateSetModal: false,
130+
newSetName: '',
115131
}),
116132
computed: {
117133
...mapState(useCategoryStore, ['classes_unsaved_changes']),
@@ -231,9 +247,17 @@ export default {
231247
}
232248
},
233249
createSet: function () {
234-
const name = prompt('Name for the new category set:');
235-
if (!name) return;
250+
this.newSetName = '';
251+
this.showCreateSetModal = true;
252+
},
253+
onCreateSetConfirm: function (event) {
254+
const name = this.newSetName.trim();
255+
if (!name) {
256+
event.preventDefault();
257+
return;
258+
}
236259
if (this.categoryStore.category_sets.find(s => s.id === name)) {
260+
event.preventDefault();
237261
alert(`A set named "${name}" already exists.`);
238262
return;
239263
}

test/unit/ActivityView.test.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,17 @@ describe('ActivityView isVisLarge', () => {
2121
});
2222
});
2323

24+
describe('ActivityView custom visualization modal', () => {
25+
test('stays open when a required field is blank', async () => {
26+
const event = { preventDefault: jest.fn() };
27+
const vm = { customVisWatcherName: 'aw-watcher-window', customVisTitle: ' ' };
28+
29+
await ActivityView.methods.onCustomVisConfirm.call(vm, event);
30+
31+
expect(event.preventDefault).toHaveBeenCalledTimes(1);
32+
});
33+
});
34+
2435
// Visualizations keep local state — the Top Bucket Data picker holds its
2536
// selected bucket, field and fetched events in `data` and fills them in
2637
// `mounted`. Every view renders the same list at the same positions, so if a

test/unit/QueryExplorer.test.js

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,125 @@ describe('QueryExplorer saveCurrentQuery', () => {
4242
confirmSpy.mockRestore();
4343
promptSpy.mockRestore();
4444
});
45+
46+
test('keeps save modal open when the query name is blank', async () => {
47+
const event = { preventDefault: jest.fn() };
48+
const vm = {
49+
saveQueryName: ' ',
50+
saved_query_error: '',
51+
};
52+
53+
await QueryExplorer.methods.onSaveQueryConfirm.call(vm, event);
54+
55+
expect(event.preventDefault).toHaveBeenCalledTimes(1);
56+
expect(vm.saved_query_error).toBe('Saved query name cannot be empty.');
57+
});
58+
59+
test('cancels save modal closure before awaiting persistence failure', async () => {
60+
const event = { preventDefault: jest.fn() };
61+
let resolvePersistence;
62+
const persistence = new Promise(resolve => {
63+
resolvePersistence = resolve;
64+
});
65+
const vm = {
66+
enddate: '2026-05-21',
67+
event_type: 'currentwindow',
68+
persistSavedQueries: jest.fn().mockReturnValue(persistence),
69+
query_code: 'RETURN = [];',
70+
saveQueryName: 'Daily Coding Time',
71+
savedQueries: [],
72+
selected_saved_query_id: '',
73+
showSaveQueryModal: true,
74+
startdate: '2026-05-20',
75+
};
76+
77+
const confirmation = QueryExplorer.methods.onSaveQueryConfirm.call(vm, event);
78+
79+
expect(event.preventDefault).toHaveBeenCalledTimes(1);
80+
expect(vm.showSaveQueryModal).toBe(true);
81+
82+
resolvePersistence(false);
83+
await confirmation;
84+
85+
expect(vm.selected_saved_query_id).toBe('');
86+
expect(vm.showSaveQueryModal).toBe(true);
87+
});
88+
89+
test('closes save modal after persistence succeeds', async () => {
90+
const event = { preventDefault: jest.fn() };
91+
const vm = {
92+
enddate: '2026-05-21',
93+
event_type: 'currentwindow',
94+
persistSavedQueries: jest.fn().mockResolvedValue(true),
95+
query_code: 'RETURN = [];',
96+
saveQueryName: 'Daily Coding Time',
97+
savedQueries: [],
98+
selected_saved_query_id: '',
99+
showSaveQueryModal: true,
100+
startdate: '2026-05-20',
101+
};
102+
103+
await QueryExplorer.methods.onSaveQueryConfirm.call(vm, event);
104+
105+
expect(event.preventDefault).toHaveBeenCalledTimes(1);
106+
expect(vm.selected_saved_query_id).not.toBe('');
107+
expect(vm.showSaveQueryModal).toBe(false);
108+
});
109+
110+
test('keeps rename modal open when the query name is blank', async () => {
111+
const event = { preventDefault: jest.fn() };
112+
const vm = {
113+
renameQueryName: ' ',
114+
saved_query_error: '',
115+
selectedSavedQuery: { id: 'daily-coding-time', name: 'Daily Coding Time' },
116+
};
117+
118+
await QueryExplorer.methods.onRenameQueryConfirm.call(vm, event);
119+
120+
expect(event.preventDefault).toHaveBeenCalledTimes(1);
121+
expect(vm.saved_query_error).toBe('Saved query name cannot be empty.');
122+
});
123+
124+
test('cancels rename modal closure before awaiting persistence failure', async () => {
125+
const event = { preventDefault: jest.fn() };
126+
let resolvePersistence;
127+
const persistence = new Promise(resolve => {
128+
resolvePersistence = resolve;
129+
});
130+
const selectedSavedQuery = { id: 'daily-coding-time', name: 'Daily Coding Time' };
131+
const vm = {
132+
persistSavedQueries: jest.fn().mockReturnValue(persistence),
133+
renameQueryName: 'Coding Time',
134+
savedQueries: [selectedSavedQuery],
135+
selectedSavedQuery,
136+
showRenameQueryModal: true,
137+
};
138+
139+
const confirmation = QueryExplorer.methods.onRenameQueryConfirm.call(vm, event);
140+
141+
expect(event.preventDefault).toHaveBeenCalledTimes(1);
142+
expect(vm.showRenameQueryModal).toBe(true);
143+
144+
resolvePersistence(false);
145+
await confirmation;
146+
147+
expect(vm.showRenameQueryModal).toBe(true);
148+
});
149+
150+
test('closes rename modal after persistence succeeds', async () => {
151+
const event = { preventDefault: jest.fn() };
152+
const selectedSavedQuery = { id: 'daily-coding-time', name: 'Daily Coding Time' };
153+
const vm = {
154+
persistSavedQueries: jest.fn().mockResolvedValue(true),
155+
renameQueryName: 'Coding Time',
156+
savedQueries: [selectedSavedQuery],
157+
selectedSavedQuery,
158+
showRenameQueryModal: true,
159+
};
160+
161+
await QueryExplorer.methods.onRenameQueryConfirm.call(vm, event);
162+
163+
expect(event.preventDefault).toHaveBeenCalledTimes(1);
164+
expect(vm.showRenameQueryModal).toBe(false);
165+
});
45166
});

0 commit comments

Comments
 (0)