Skip to content

Commit f42282d

Browse files
committed
feat(admin): bulk tag assignment for products
1 parent f4057fc commit f42282d

File tree

3 files changed

+194
-4
lines changed

3 files changed

+194
-4
lines changed

app/components/BulkProductEditor.vue

Lines changed: 180 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,24 @@ import type { CamelCasedPropertiesDeep } from 'type-fest';
88
import { productTypes } from '~~/db/schema';
99
import type { Tables } from '~~/types/database.types';
1010
import { useBrands } from '~/queries/brands';
11+
import { useTags } from '~/queries/tags';
1112
1213
const supabase = useSupabaseClient();
1314
const toast = useToast();
1415
const statusToaster = new StatusToaster('Bulk Products');
1516
1617
const { brands } = useBrands();
18+
const { tags } = useTags();
1719
1820
const { products } = defineProps<{
1921
products: PendingProduct[];
2022
}>();
2123
const emit = defineEmits(['clearAll', 'refetchAll', 'selectAll']);
2224
25+
const availableTagsByCategory: Ref<
26+
ReturnType<typeof getTagsForProductTypeByCategory>
27+
> = ref([]);
28+
2329
const selectedProducts = computed(() => {
2430
return products.filter((p) => p != null && p.selected);
2531
});
@@ -54,8 +60,22 @@ const productType: Ref<productTypeType> = ref('[null]');
5460
const productTypeNotes = ref('');
5561
const productTypeOptions = ref(['[null]', ...productTypes]);
5662
63+
const tagsToAdd: Ref<string[]> = ref([]);
64+
const tagsToRemove: Ref<string[]> = ref([]);
65+
const addTagToBeChanged = (tagId: string, selected: boolean | string) => {
66+
// Selected is reversed because it is the value of the checkbox before the
67+
// change.
68+
if (selected === false || selected === 'indeterminate') {
69+
tagsToAdd.value = _.uniq([...tagsToAdd.value, tagId]);
70+
tagsToRemove.value = tagsToRemove.value.filter((t) => t !== tagId);
71+
} else if (selected === true) {
72+
tagsToRemove.value = _.uniq([...tagsToRemove.value, tagId]);
73+
tagsToAdd.value = tagsToAdd.value.filter((t) => t !== tagId);
74+
}
75+
};
76+
5777
watch(
58-
[() => products, () => brands],
78+
[() => products, () => brands, () => tags],
5979
() => {
6080
// Brand
6181
if (brands.value.data) {
@@ -82,6 +102,35 @@ watch(
82102
productType.value = '';
83103
productTypeNotes.value = `${currentTypes.length} current types`;
84104
}
105+
106+
// Tags
107+
if (tags?.value?.data && currentTypes.length === 1) {
108+
availableTagsByCategory.value = getTagsForProductTypeByCategory(
109+
tags.value.data ?? [],
110+
currentTypes[0] ?? null,
111+
);
112+
for (const tagCat of availableTagsByCategory.value) {
113+
if (tagCat.tags) {
114+
for (const tag of tagCat.tags) {
115+
const checkedCount = selectedProducts.value.reduce((acc, p) => {
116+
if (p.tags.map((t) => t.id).includes(tag.id)) {
117+
return acc + 1;
118+
}
119+
return acc;
120+
}, 0);
121+
122+
if (checkedCount === selectedProducts.value.length) {
123+
tag.selected = true;
124+
} else if (checkedCount === 0) {
125+
tag.selected = false;
126+
} else {
127+
tag.selected = 'indeterminate';
128+
}
129+
tag.selectedCount = checkedCount;
130+
}
131+
}
132+
}
133+
}
85134
},
86135
{ immediate: true, deep: true },
87136
);
@@ -145,6 +194,67 @@ const { mutate: updateProductType } = useMutation({
145194
},
146195
onSettled: async () => emit('refetchAll'),
147196
});
197+
198+
const { mutate: updateTags } = useMutation({
199+
mutation: async () => {
200+
sending.value = true;
201+
202+
let toAddErrored = false;
203+
let toRemoveErrored = false;
204+
if (tagsToAdd.value.length > 0) {
205+
const { error } = await supabase.from('product_tags').insert(
206+
tagsToAdd.value
207+
.filter((t) => t != null)
208+
.map((tag) =>
209+
selectedProducts.value.map((sp) =>
210+
objectToSnake({
211+
productId: sp.id,
212+
tagId: tag,
213+
}),
214+
),
215+
)
216+
.flat(),
217+
);
218+
219+
if (error) {
220+
statusToaster.error('Update Failed!', error.message);
221+
console.log(error);
222+
toAddErrored = true;
223+
} else {
224+
statusToaster.success('Updated!');
225+
}
226+
}
227+
228+
if (!toAddErrored && tagsToRemove.value.length > 0) {
229+
for (const toRemove of tagsToRemove.value) {
230+
const { error } = await supabase
231+
.from('product_tags')
232+
.delete()
233+
.eq('tag_id', toRemove)
234+
.in(
235+
'product_id',
236+
selectedProducts.value.map((sp) => sp.id),
237+
);
238+
239+
if (error) {
240+
statusToaster.error('Update Failed!', error.message);
241+
console.log(error);
242+
toRemoveErrored = true;
243+
break;
244+
}
245+
}
246+
247+
if (!toAddErrored && !toRemoveErrored) {
248+
tagsToAdd.value = [];
249+
tagsToRemove.value = [];
250+
statusToaster.success('Updated!');
251+
}
252+
}
253+
254+
sending.value = false;
255+
},
256+
onSettled: async () => emit('refetchAll'),
257+
});
148258
</script>
149259
<template>
150260
<div>
@@ -201,6 +311,75 @@ const { mutate: updateProductType } = useMutation({
201311
:products="products"
202312
@refetch-all="emit('refetchAll')"
203313
/>
314+
<div>
315+
<div class="flex flex-wrap">
316+
<div
317+
v-for="tagCat of availableTagsByCategory"
318+
:key="tagCat.categoryName"
319+
class="mx-1 flex flex-col flex-wrap"
320+
>
321+
{{ tagCat.categoryName }}
322+
<div v-for="tag of tagCat.tags" :key="tag.id">
323+
<UCheckbox
324+
v-model="tag.selected"
325+
color="primary"
326+
variant="card"
327+
:disabled="sending"
328+
class="py-1"
329+
@click="addTagToBeChanged(tag.id, tag.selected)"
330+
>
331+
<template #label>
332+
{{ tag.name }}
333+
<UBadge
334+
variant="outline"
335+
:color="
336+
tag.selectedCount === 0
337+
? 'neutral'
338+
: tag.selectedCount === selectedProducts.length
339+
? 'success'
340+
: 'info'
341+
"
342+
class="ml-2"
343+
>{{ tag.selectedCount }}/{{
344+
selectedProducts.length
345+
}}</UBadge
346+
>
347+
<UBadge
348+
v-if="tagsToAdd.includes(tag.id)"
349+
color="success"
350+
class="ml-2"
351+
><UIcon name="i-lucide-plus"
352+
/></UBadge>
353+
<UBadge
354+
v-if="tagsToRemove.includes(tag.id)"
355+
color="error"
356+
class="ml-2"
357+
><UIcon name="i-lucide-minus"
358+
/></UBadge>
359+
</template>
360+
</UCheckbox>
361+
</div>
362+
</div>
363+
</div>
364+
<UButton
365+
type="button"
366+
color="info"
367+
class="mt-2 h-fit self-end"
368+
@click="updateTags()"
369+
>Update {{ tagsToAdd.length + tagsToRemove.length }} tags</UButton
370+
>
371+
<UButton
372+
type="button"
373+
color="info"
374+
variant="outline"
375+
class="mx-2 mt-2 h-fit self-end"
376+
@click="
377+
tagsToAdd = [];
378+
tagsToRemove = [];
379+
"
380+
>Clear Tag Changes</UButton
381+
>
382+
</div>
204383
</div>
205384
</div>
206385
</template>

app/components/ProductTagSelector.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { useTags } from '~/queries/tags';
99
const supabase = useSupabaseClient();
1010
const statusToaster = new StatusToaster('Filament Tags');
1111
12-
// Just used for type for prop.
12+
// Just used for type for prop. TODO replace.
1313
// eslint-disable-next-line @typescript-eslint/no-unused-vars
1414
const productWithTagsQuery = supabase
1515
.from('products')

app/utils/utils.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,19 @@ export const getTagsForProductTypeByCategory = (
7070
categoryName: tc[0],
7171
tags: tc[1]
7272
?.toSorted((a, b) => a.name.localeCompare(b.name))
73-
// Add a selected boolean for use by components.
74-
.map((t) => ({ ...t, selected: false })),
73+
// Add selected and selectedCount fields for use by components.
74+
.map(
75+
(
76+
t,
77+
): TagWithCategory & {
78+
selected: boolean | 'indeterminate';
79+
selectedCount: number;
80+
} => ({
81+
...t,
82+
selected: false,
83+
selectedCount: 0, // Used for bulk editing.
84+
}),
85+
),
7586
}));
7687

7788
// Sort alphabetically by categoryName, putting 'None' last.

0 commit comments

Comments
 (0)