1<template>
2 <Combobox
3 v-if="!sortValues?.size"
4 :options="options"
5 :model-value="null"
6 @update:model-value="(value) => value && setSort(value)"
7 >
8 <template #trigger>
9 <Button :label="__('Sort')">
10 <template v-if="hideLabel" #icon>
11 <SortIcon class="h-4" />
12 </template>
13 <template v-else-if="!sortValues?.size" #prefix>
14 <SortIcon class="h-4" />
15 </template>
16 </Button>
17 </template>
18 </Combobox>
19 <Popover v-else bare :offset="8">
20 <template #trigger="{ open }">
21 <Button v-if="sortValues.size > 1" :label="__('Sort')">
22 <template v-if="hideLabel" #icon>
23 <SortIcon class="h-4" />
24 </template>
25 <template v-else #prefix><SortIcon class="h-4" /></template>
26 <template v-if="sortValues?.size" #suffix>
27 <div
28 class="flex h-5 w-5 items-center justify-center rounded-[5px] bg-surface-base pt-px text-xs-medium text-ink-gray-8 shadow-sm"
29 >
30 {{ sortValues.size }}
31 </div>
32 </template>
33 </Button>
34 <div v-else class="flex items-center justify-center">
35 <Button
36 v-if="sortValues.size"
37 class="rounded-e-none border-e"
38 @click.stop="
39 () => {
40 Array.from(sortValues)[0].direction =
41 Array.from(sortValues)[0].direction == 'asc' ? 'desc' : 'asc';
42 apply();
43 }
44 "
45 >
46 <AscendingIcon
47 v-if="Array.from(sortValues)[0].direction == 'asc'"
48 class="h-4"
49 />
50 <DescendingIcon v-else class="h-4" />
51 </Button>
52 <Button
53 :label="getSortLabel()"
54 :class="sortValues.size ? 'rounded-s-none' : ''"
55 >
56 <template v-if="!hideLabel && !sortValues?.size" #prefix>
57 <SortIcon class="h-4" />
58 </template>
59 <template v-if="sortValues?.size" #suffix>
60 <component
61 :is="open ? LucideChevronUp : LucideChevronDown"
62 class="h-4 text-ink-gray-5"
63 />
64 </template>
65 </Button>
66 </div>
67 </template>
68 <template #default="{ close }">
69 <div
70 class="rounded-6 border border-outline-gray-1 bg-surface-base shadow-xl"
71 >
72 <div class="min-w-60 p-2">
73 <div
74 v-if="sortValues?.size"
75 ref="sortList"
76 class="mb-3 flex flex-col gap-2"
77 >
78 <div
79 v-for="(sort, i) in sortValues"
80 :key="sort.fieldname"
81 class="flex items-center gap-1"
82 >
83 <div class="handle flex h-7 w-7 items-center justify-center">
84 <DragIcon class="h-4 w-4 cursor-grab text-ink-gray-5" />
85 </div>
86 <div class="flex">
87 <Button
88 size="md"
89 class="rounded-e-none border-e"
90 @click="
91 () => {
92 sort.direction = sort.direction == 'asc' ? 'desc' : 'asc';
93 apply();
94 }
95 "
96 >
97 <AscendingIcon v-if="sort.direction == 'asc'" class="h-4" />
98 <DescendingIcon v-else class="h-4" />
99 </Button>
100 <!-- width on the wrapper: a Combobox with its own #trigger
101 slot drops the class it is handed -->
102 <div class="w-32">
103 <Combobox
104 :model-value="sort.fieldname"
105 :options="sortOptions.data || []"
106 @update:model-value="
107 (value) => value && updateSort(value, i)
108 "
109 >
110 <template #trigger="{ displayValue }">
111 <Button
112 class="flex w-full items-center justify-between rounded-s-none !text-ink-gray-5 text-xs"
113 size="md"
114 >
115 {{ __(displayValue) }}
116 <template #suffix>
117 <LucideChevronDown class="size-4 text-ink-gray-5" />
118 </template>
119 </Button>
120 </template>
121 </Combobox>
122 </div>
123 </div>
124 <Button variant="ghost" icon="lucide-x" @click="removeSort(i)" />
125 </div>
126 </div>
127 <div
128 v-else
129 class="mb-3 flex h-7 items-center px-3 text-sm text-ink-gray-5"
130 >
131 {{ __("Empty - Choose a field to sort by") }}
132 </div>
133 <div class="flex items-center justify-between gap-2">
134 <Combobox
135 :options="options"
136 :model-value="null"
137 @update:model-value="(value) => value && setSort(value)"
138 >
139 <template #trigger>
140 <Button
141 class="!text-ink-gray-5"
142 variant="ghost"
143 :label="__('Add Sort')"
144 >
145 <template #prefix>
146 <LucidePlus class="size-4" />
147 </template>
148 </Button>
149 </template>
150 </Combobox>
151 <Button
152 v-if="sortValues?.size"
153 class="!text-ink-gray-5"
154 variant="ghost"
155 :label="__('Clear Sort')"
156 @click="clearSort(close)"
157 />
158 </div>
159 </div>
160 </div>
161 </template>
162 </Popover>
163</template>
164
165<script setup>
166import LucideChevronUp from "~icons/lucide/chevron-up";
167import LucideChevronDown from "~icons/lucide/chevron-down";
168import LucidePlus from "~icons/lucide/plus";
169import { computed, inject, ref } from "vue";
170import { useSortable } from "@vueuse/integrations/useSortable";
171import { Combobox, Popover } from "frappe-ui";
172import {
173 AscendingIcon,
174 DescendingIcon,
175 SortIcon,
176 DragIcon,
177} from "@/components/icons";
178
179const props = defineProps({
180 hideLabel: {
181 type: Boolean,
182 default: false,
183 },
184});
185
186const emit = defineEmits(["update"]);
187
188const listViewData = inject("listViewData");
189const listViewActions = inject("listViewActions");
190const { list, sortableFields: sortOptions } = listViewData;
191
192const sortValues = computed({
193 get: () => {
194 if (!list) return new Set();
195 let allSortValues = list.params?.order_by;
196 if (!allSortValues || !sortOptions.data) return new Set();
197 // if (allSortValues.trim() === "modified desc") return new Set();
198
199 allSortValues = allSortValues.split(", ").map((sortValue) => {
200 const [fieldname, direction] = sortValue.split(" ");
201 return { fieldname, direction };
202 });
203 // allSortValues = removeDuplicateSorts();
204 return new Set(allSortValues);
205 },
206 set: (value) => {
207 list.params.order_by = convertToString(value);
208 },
209});
210
211const options = computed(() => {
212 if (!sortOptions.data) return [];
213 if (!sortValues.value.size) return sortOptions.data;
214 const selectedOptions = [...sortValues.value].map((sort) => sort.fieldname);
215 return sortOptions.data.filter((option) => {
216 return !selectedOptions.includes(option.value);
217 });
218});
219
220const sortList = ref(null);
221
222// watchElement, and a ref rather than a selector: the popover panel unmounts on
223// close, so a one-shot document.querySelector at mount binds nothing.
224useSortable(sortList, sortValues, {
225 handle: ".handle",
226 animation: 200,
227 watchElement: true,
228 onEnd: () => apply(),
229});
230
231function getSortLabel() {
232 if (!sortValues.value.size) return __("Sort");
233 let values = Array.from(sortValues.value);
234 let label = sortOptions.data?.find(
235 (option) => option.value === values[0].fieldname
236 )?.label;
237
238 return __(label) || __(values[0].fieldname);
239}
240
241function setSort(fieldname) {
242 sortValues.value.add({ fieldname, direction: "asc" });
243 apply();
244}
245
246function updateSort(fieldname, index) {
247 let oldSort = Array.from(sortValues.value)[index];
248 sortValues.value.delete(oldSort);
249 sortValues.value.add({
250 fieldname,
251 direction: oldSort.direction,
252 });
253 apply();
254}
255
256function removeSort(index) {
257 sortValues.value.delete(Array.from(sortValues.value)[index]);
258 apply();
259}
260
261function clearSort(close) {
262 sortValues.value.clear();
263 apply();
264 close();
265}
266
267function apply() {
268 listViewActions.applySort(convertToString(sortValues.value));
269}
270
271function convertToString(values) {
272 let _sortValues = "";
273 values.forEach((f) => {
274 _sortValues += `${f.fieldname} ${f.direction}, `;
275 });
276 _sortValues = _sortValues.slice(0, -2);
277 return _sortValues;
278}
279</script>
280