Skip to content

Commit c35efad

Browse files
parth0025claude
andauthored
fix(ui): consolidate toolbar features into a more menu and fix search and share links (#232)
The nine new feature buttons crowded the board toolbar, so they now live behind a single dots trigger using the standard DropDown pattern from the task context menu. Recent and Export became v-model modals so every entry opens uniformly. Search-all project results now deep-link to the project first active sprint with the list view tab, because project routes always carry a sprint segment; the API attaches sprintId and folderId per project. Public share links work in local dev via a dev-server proxy rule for the server-rendered /share pages. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 8c8080a commit c35efad

7 files changed

Lines changed: 176 additions & 83 deletions

File tree

Modules/GlobalSearch/controller.js

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,44 @@ exports.globalSearch = async (req, res) => {
4848
}, 'find'),
4949
]);
5050

51+
// Client project routes always carry a sprint segment, so attach each
52+
// project's first active sprint (the sprint the sidebar lands on).
53+
let projectResults = projects || [];
54+
if (projectResults.length) {
55+
const sprints = await MongoDbCrudOpration(companyId, {
56+
type: SCHEMA_TYPE.SPRINTS,
57+
data: [
58+
{
59+
projectId: { $in: projectResults.map((project) => project._id) },
60+
deletedStatusKey: 0,
61+
private: { $ne: true },
62+
},
63+
'projectId folderId',
64+
{ sort: { _id: 1 } },
65+
],
66+
}, 'find');
67+
const firstSprintByProject = {};
68+
(sprints || []).forEach((sprint) => {
69+
const key = String(sprint.projectId);
70+
if (!firstSprintByProject[key]) firstSprintByProject[key] = sprint;
71+
});
72+
projectResults = projectResults.map((project) => {
73+
const sprint = firstSprintByProject[String(project._id)];
74+
return {
75+
_id: project._id,
76+
ProjectName: project.ProjectName,
77+
sprintId: sprint ? sprint._id : null,
78+
folderId: sprint && sprint.folderId ? sprint.folderId : null,
79+
};
80+
});
81+
}
82+
5183
return res.send({
5284
status: true,
5385
statusText: 'Search complete.',
5486
data: {
5587
tasks: tasks || [],
56-
projects: projects || [],
88+
projects: projectResults,
5789
comments: (comments || []).map((comment) => ({
5890
_id: comment._id,
5991
message: truncate(comment.message),

frontend/src/components/molecules/ExportTasks/ExportTasksDropdown.vue

Lines changed: 42 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
<template>
2-
<span class="position-re">
3-
<button class="text-nowrap btn-white border-groupBy border-radius-6-px cursor-pointer" @click.stop="isOpen = !isOpen">
4-
<strong :style="{color: (clientWidth <= 767 ? '#535358' : '#000')}" :class="{'font-size-12 font-weight-500' : clientWidth > 767 , 'font-size-14 font-weight-400' : clientWidth <=767}">{{ $t('Projects.export_tasks') }}</strong>
5-
</button>
6-
<span v-if="isOpen" class="export-tasks__overlay" @click.stop="isOpen = false"></span>
7-
<div v-if="isOpen" class="export-tasks__panel">
8-
<div class="cursor-pointer export-tasks__row font-size-13" @click="startExport('csv')">CSV</div>
9-
<div class="cursor-pointer export-tasks__row font-size-13" @click="startExport('xlsx')">XLSX</div>
2+
<div v-if="modelValue" class="export-tasks__overlay" @click.self="$emit('update:modelValue', false)">
3+
<div class="export-tasks__card">
4+
<div class="d-flex align-items-center justify-content-between export-tasks__head">
5+
<span class="font-size-16 font-weight-700">{{ $t('Projects.export_tasks') }}</span>
6+
<span class="cursor-pointer font-size-16 export-tasks__close" @click="$emit('update:modelValue', false)">&#10005;</span>
7+
</div>
8+
<div class="font-size-12 gray81 export-tasks__hint">{{ $t('Projects.export_hint') }}</div>
9+
<div class="d-flex">
10+
<button class="btn-primary font-size-13 mr-10px" :disabled="isBusy" @click="startExport('csv')">CSV</button>
11+
<button class="btn-primary font-size-13" :disabled="isBusy" @click="startExport('xlsx')">XLSX</button>
12+
</div>
1013
</div>
11-
</span>
14+
</div>
1215
</template>
1316

1417
<script setup>
@@ -25,21 +28,27 @@ const { t } = useI18n();
2528
const $toast = useToast();
2629
const { getUser } = useGetterFunctions();
2730
const userId = inject('$userId');
28-
const clientWidth = inject('$clientWidth');
2931
3032
const props = defineProps({
3133
projectData: {
3234
type: Object,
3335
required: true
36+
},
37+
modelValue: {
38+
type: Boolean,
39+
default: false
3440
}
3541
});
3642
37-
const isOpen = ref(false);
43+
const emit = defineEmits(['update:modelValue']);
44+
45+
const isBusy = ref(false);
3846
const POLL_INTERVAL_MS = 2000;
3947
const MAX_POLLS = 30;
4048
4149
function startExport(format) {
42-
isOpen.value = false;
50+
if (isBusy.value) return;
51+
isBusy.value = true;
4352
const user = getUser(userId.value);
4453
apiRequest('post', '/api/v2/exports', {
4554
format,
@@ -49,11 +58,13 @@ function startExport(format) {
4958
}).then((response) => {
5059
if (response.data?.status) {
5160
$toast.info(t('Projects.export_preparing'), { position: 'top-right' });
61+
emit('update:modelValue', false);
5262
pollUntilDone(response.data.data._id, 0);
5363
} else {
5464
$toast.error(response.data?.statusText || t('Toast.something_went_wrong'), { position: 'top-right' });
5565
}
56-
}).catch((error) => console.error('ERROR in start export: ', error));
66+
}).catch((error) => console.error('ERROR in start export: ', error))
67+
.finally(() => { isBusy.value = false; });
5768
}
5869
5970
function pollUntilDone(jobId, attempt) {
@@ -95,19 +106,24 @@ function downloadJob(job) {
95106
</script>
96107
97108
<style scoped>
98-
.export-tasks__overlay { position: fixed; inset: 0; z-index: 19; }
99-
.export-tasks__panel {
100-
position: absolute;
101-
top: 36px;
102-
right: 0;
103-
z-index: 20;
104-
min-width: 120px;
109+
.export-tasks__overlay {
110+
position: fixed;
111+
inset: 0;
112+
background: rgba(0, 0, 0, 0.35);
113+
z-index: 1000;
114+
display: flex;
115+
align-items: center;
116+
justify-content: center;
117+
}
118+
.export-tasks__card {
105119
background: #fff;
106-
border: 1px solid #e0e0e0;
107-
border-radius: 8px;
108-
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
109-
padding: 4px 0;
120+
border-radius: 10px;
121+
width: min(360px, 92vw);
122+
padding: 16px 20px;
123+
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
110124
}
111-
.export-tasks__row { padding: 7px 14px; }
112-
.export-tasks__row:hover { background: #f7f9fc; }
125+
.export-tasks__head { margin-bottom: 8px; }
126+
.export-tasks__close { color: #9a9a9a; }
127+
.export-tasks__close:hover { color: #e84a4a; }
128+
.export-tasks__hint { margin-bottom: 14px; }
113129
</style>

frontend/src/components/molecules/GlobalSearch/GlobalSearchModal.vue

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,19 @@ function openTask(task) {
131131
router.push(path).catch((error) => console.error('ERROR opening search task: ', error));
132132
}
133133
134+
// Project routes always include a sprint segment (see router/projects), so the
135+
// API returns each project's first active sprint. Projects without one fall
136+
// back to the sprint-less /p route, keeping the list view as landing tab.
134137
function openProject(project) {
135138
close();
136-
router.push(`/${companyId.value}/project/${project._id}`).catch((error) => console.error('ERROR opening search project: ', error));
139+
const base = `/${companyId.value}/project/${project._id}`;
140+
let path = `${base}/p?tab=ProjectListView`;
141+
if (project.sprintId) {
142+
path = project.folderId
143+
? `${base}/fs/${project.folderId}/${project.sprintId}?tab=ProjectListView`
144+
: `${base}/s/${project.sprintId}?tab=ProjectListView`;
145+
}
146+
router.push(path).catch((error) => console.error('ERROR opening search project: ', error));
137147
}
138148
</script>
139149

frontend/src/components/molecules/RecentVisits/RecentVisitsDropdown.vue

Lines changed: 38 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
<template>
2-
<span class="position-re">
3-
<button class="text-nowrap btn-white border-groupBy border-radius-6-px cursor-pointer" @click.stop="toggleOpen">
4-
<strong :style="{color: (clientWidth <= 767 ? '#535358' : '#000')}" :class="{'font-size-12 font-weight-500' : clientWidth > 767 , 'font-size-14 font-weight-400' : clientWidth <=767}">{{ $t('Projects.recent_tasks') }}</strong>
5-
</button>
6-
<span v-if="isOpen" class="recent-visits__overlay" @click.stop="isOpen = false"></span>
7-
<div v-if="isOpen" class="recent-visits__panel">
2+
<div v-if="modelValue" class="recent-visits__overlay" @click.self="$emit('update:modelValue', false)">
3+
<div class="recent-visits__card">
4+
<div class="d-flex align-items-center justify-content-between recent-visits__head">
5+
<span class="font-size-16 font-weight-700">{{ $t('Projects.recent_tasks') }}</span>
6+
<span class="cursor-pointer font-size-16 recent-visits__close" @click="$emit('update:modelValue', false)">&#10005;</span>
7+
</div>
88
<div v-if="isLoading" class="gray81 font-size-12 recent-visits__empty">{{ $t('Projects.searching') }}</div>
99
<div v-else-if="!items.length" class="gray81 font-size-12 recent-visits__empty">{{ $t('Projects.no_recent_tasks') }}</div>
1010
<div
@@ -19,12 +19,12 @@
1919
<span v-if="item.task.status && item.task.status.text" class="font-size-11 recent-visits__status">{{ item.task.status.text }}</span>
2020
</div>
2121
</div>
22-
</span>
22+
</div>
2323
</template>
2424

2525
<script setup>
2626
// PACKAGES
27-
import { inject, ref } from "vue";
27+
import { defineProps, inject, ref, watch } from "vue";
2828
import { useRouter } from "vue-router";
2929
3030
// UTILS
@@ -33,18 +33,22 @@ import { apiRequest } from '@/services';
3333
const router = useRouter();
3434
const userId = inject('$userId');
3535
const companyId = inject('$companyId');
36-
const clientWidth = inject('$clientWidth');
3736
38-
const isOpen = ref(false);
37+
const props = defineProps({
38+
modelValue: {
39+
type: Boolean,
40+
default: false
41+
}
42+
});
43+
44+
const emit = defineEmits(['update:modelValue']);
45+
3946
const isLoading = ref(false);
4047
const items = ref([]);
4148
42-
function toggleOpen() {
43-
isOpen.value = !isOpen.value;
44-
if (isOpen.value) {
45-
fetchRecent();
46-
}
47-
}
49+
watch(() => props.modelValue, (open) => {
50+
if (open) fetchRecent();
51+
});
4852
4953
function fetchRecent() {
5054
isLoading.value = true;
@@ -63,7 +67,7 @@ function fetchRecent() {
6367
// Task routes (router/projects): with folder → /:cid/project/:id/fs/:folderId/:sprintId/:taskId,
6468
// without → /:cid/project/:id/s/:sprintId/:taskId
6569
function openTask(task) {
66-
isOpen.value = false;
70+
emit('update:modelValue', false);
6771
const base = `/${companyId.value}/project/${task.ProjectID}`;
6872
const path = task.folderObjId
6973
? `${base}/fs/${task.folderObjId}/${task.sprintId}/${task._id}`
@@ -78,24 +82,27 @@ function openTask(task) {
7882
.recent-visits__overlay {
7983
position: fixed;
8084
inset: 0;
81-
z-index: 19;
85+
background: rgba(0, 0, 0, 0.35);
86+
z-index: 1000;
87+
display: flex;
88+
align-items: center;
89+
justify-content: center;
8290
}
83-
.recent-visits__panel {
84-
position: absolute;
85-
top: 36px;
86-
right: 0;
87-
z-index: 20;
88-
width: 300px;
89-
max-height: 320px;
90-
overflow-y: auto;
91+
.recent-visits__card {
9192
background: #fff;
92-
border: 1px solid #e0e0e0;
93-
border-radius: 8px;
94-
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
95-
padding: 4px 0;
93+
border-radius: 10px;
94+
width: min(440px, 92vw);
95+
max-height: 64vh;
96+
overflow-y: auto;
97+
padding: 14px 16px;
98+
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
9699
}
100+
.recent-visits__head { margin-bottom: 8px; }
101+
.recent-visits__close { color: #9a9a9a; }
102+
.recent-visits__close:hover { color: #e84a4a; }
97103
.recent-visits__row {
98-
padding: 7px 12px;
104+
padding: 7px 8px;
105+
border-radius: 6px;
99106
min-width: 0;
100107
}
101108
.recent-visits__row:hover {

frontend/src/locales/en.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,8 @@ export default {
565565
importing: "Importing...",
566566
start_import: "Import",
567567
auto_archive: "Auto-archive",
568+
more_features: "More",
569+
export_hint: "Download this project's tasks as a file.",
568570
auto_archive_hint: "Completed tasks untouched for the chosen number of days are archived automatically every night.",
569571
auto_archive_enable: "Enable auto-archive for this project",
570572
auto_archive_after: "Archive after",

frontend/src/views/Projects/components/ProjectFiltersToolbar.vue

Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -94,35 +94,53 @@
9494
</DropDownOption>
9595
</template>
9696
</DropDown>
97-
<button class="text-nowrap btn-white border-groupBy border-radius-6-px cursor-pointer mr-1" @click="showBurndown = true">
98-
<strong :style="{color: (clientWidth <= 767 ? '#535358' : '#000')}" :class="{'font-size-12 font-weight-500' : clientWidth > 767 , 'font-size-14 font-weight-400' : clientWidth <=767}">{{ $t('Projects.burndown') }}</strong>
99-
</button>
100-
<BurndownModal v-model="showBurndown" :projectData="projectData" />
101-
<RecentVisitsDropdown class="mr-1" />
102-
<button class="text-nowrap btn-white border-groupBy border-radius-6-px cursor-pointer mr-1" :title="$t('Projects.global_search')" @click="showGlobalSearch = true">
103-
<strong :style="{color: (clientWidth <= 767 ? '#535358' : '#000')}" :class="{'font-size-12 font-weight-500' : clientWidth > 767 , 'font-size-14 font-weight-400' : clientWidth <=767}">{{ $t('Projects.global_search') }}</strong>
104-
</button>
97+
<!-- All newer features live behind one "…" menu (matches
98+
the task context-menu pattern) instead of nine
99+
separate toolbar buttons. -->
100+
<DropDown id="more_features" class="mr-1" :zIndex="10">
101+
<template #button>
102+
<button class="text-nowrap btn-white border-groupBy border-radius-6-px cursor-pointer" ref="more_features_trigger" :title="$t('Projects.more_features')">
103+
<img :src="horizontalDots" alt="more" class="vertical-middle">
104+
</button>
105+
</template>
106+
<template #options>
107+
<DropDownOption @click="$refs.more_features_trigger.click(); showGlobalSearch = true">
108+
<div><span class="dropdown-label">{{ $t('Projects.global_search') }}</span></div>
109+
</DropDownOption>
110+
<DropDownOption @click="$refs.more_features_trigger.click(); showRecent = true">
111+
<div><span class="dropdown-label">{{ $t('Projects.recent_tasks') }}</span></div>
112+
</DropDownOption>
113+
<DropDownOption @click="$refs.more_features_trigger.click(); showBurndown = true">
114+
<div><span class="dropdown-label">{{ $t('Projects.burndown') }}</span></div>
115+
</DropDownOption>
116+
<DropDownOption @click="$refs.more_features_trigger.click(); showEpics = true">
117+
<div><span class="dropdown-label">{{ $t('Projects.epics') }}</span></div>
118+
</DropDownOption>
119+
<DropDownOption @click="$refs.more_features_trigger.click(); showPages = true">
120+
<div><span class="dropdown-label">{{ $t('Projects.pages') }}</span></div>
121+
</DropDownOption>
122+
<DropDownOption @click="$refs.more_features_trigger.click(); showExport = true">
123+
<div><span class="dropdown-label">{{ $t('Projects.export_tasks') }}</span></div>
124+
</DropDownOption>
125+
<DropDownOption @click="$refs.more_features_trigger.click(); showPublicShare = true">
126+
<div><span class="dropdown-label">{{ $t('Projects.public_link') }}</span></div>
127+
</DropDownOption>
128+
<DropDownOption @click="$refs.more_features_trigger.click(); showImportJira = true">
129+
<div><span class="dropdown-label">{{ $t('Projects.import_jira') }}</span></div>
130+
</DropDownOption>
131+
<DropDownOption @click="$refs.more_features_trigger.click(); showAutoArchive = true">
132+
<div><span class="dropdown-label">{{ $t('Projects.auto_archive') }}</span></div>
133+
</DropDownOption>
134+
</template>
135+
</DropDown>
105136
<GlobalSearchModal v-model="showGlobalSearch" />
106-
<button class="text-nowrap btn-white border-groupBy border-radius-6-px cursor-pointer mr-1" @click="showEpics = true">
107-
<strong :style="{color: (clientWidth <= 767 ? '#535358' : '#000')}" :class="{'font-size-12 font-weight-500' : clientWidth > 767 , 'font-size-14 font-weight-400' : clientWidth <=767}">{{ $t('Projects.epics') }}</strong>
108-
</button>
137+
<RecentVisitsDropdown v-model="showRecent" />
138+
<BurndownModal v-model="showBurndown" :projectData="projectData" />
109139
<EpicsPanel v-model="showEpics" :projectData="projectData" />
110-
<ExportTasksDropdown class="mr-1" :projectData="projectData" />
111-
<button class="text-nowrap btn-white border-groupBy border-radius-6-px cursor-pointer mr-1" @click="showPages = true">
112-
<strong :style="{color: (clientWidth <= 767 ? '#535358' : '#000')}" :class="{'font-size-12 font-weight-500' : clientWidth > 767 , 'font-size-14 font-weight-400' : clientWidth <=767}">{{ $t('Projects.pages') }}</strong>
113-
</button>
114140
<PagesPanel v-model="showPages" :projectData="projectData" />
115-
<button class="text-nowrap btn-white border-groupBy border-radius-6-px cursor-pointer mr-1" @click="showPublicShare = true">
116-
<strong :style="{color: (clientWidth <= 767 ? '#535358' : '#000')}" :class="{'font-size-12 font-weight-500' : clientWidth > 767 , 'font-size-14 font-weight-400' : clientWidth <=767}">{{ $t('Projects.public_link') }}</strong>
117-
</button>
141+
<ExportTasksDropdown v-model="showExport" :projectData="projectData" />
118142
<PublicShareModal v-model="showPublicShare" :projectData="projectData" />
119-
<button class="text-nowrap btn-white border-groupBy border-radius-6-px cursor-pointer mr-1" @click="showImportJira = true">
120-
<strong :style="{color: (clientWidth <= 767 ? '#535358' : '#000')}" :class="{'font-size-12 font-weight-500' : clientWidth > 767 , 'font-size-14 font-weight-400' : clientWidth <=767}">{{ $t('Projects.import_jira') }}</strong>
121-
</button>
122143
<ImportJiraModal v-model="showImportJira" :projectData="projectData" />
123-
<button class="text-nowrap btn-white border-groupBy border-radius-6-px cursor-pointer mr-1" @click="showAutoArchive = true">
124-
<strong :style="{color: (clientWidth <= 767 ? '#535358' : '#000')}" :class="{'font-size-12 font-weight-500' : clientWidth > 767 , 'font-size-14 font-weight-400' : clientWidth <=767}">{{ $t('Projects.auto_archive') }}</strong>
125-
</button>
126144
<AutoArchiveModal v-model="showAutoArchive" :projectData="projectData" />
127145
<div class="mr-1 border-groupBy border-radius-6-px d-flex align-items-center assignee-filter manage__filter-users">
128146
<div
@@ -226,6 +244,8 @@ const showPages = ref(false);
226244
const showPublicShare = ref(false);
227245
const showImportJira = ref(false);
228246
const showAutoArchive = ref(false);
247+
const showRecent = ref(false);
248+
const showExport = ref(false);
229249
import { useCustomComposable } from '@/composable';
230250
231251
const { checkPermission, checkApps } = useCustomComposable();

0 commit comments

Comments
 (0)