bulk assign frontend

This commit is contained in:
hay-kot 2021-04-26 13:56:34 -08:00
commit f4485827d8
8 changed files with 467 additions and 39 deletions

View file

@ -6,6 +6,7 @@ const prefix = baseURL + "categories";
const categoryURLs = {
getAll: `${prefix}`,
getEmpty: `${prefix}/empty`,
getCategory: category => `${prefix}/${category}`,
deleteCategory: category => `${prefix}/${category}`,
updateCategory: category => `${prefix}/${category}`,
@ -16,6 +17,10 @@ export const categoryAPI = {
let response = await apiReq.get(categoryURLs.getAll);
return response.data;
},
async getEmpty() {
let response = await apiReq.get(categoryURLs.getEmpty);
return response.data;
},
async create(name) {
let response = await apiReq.post(categoryURLs.getAll, { name: name });
store.dispatch("requestCategories");
@ -34,9 +39,11 @@ export const categoryAPI = {
}
return response.data;
},
async delete(category) {
async delete(category, overrideRequest = false) {
let response = await apiReq.delete(categoryURLs.deleteCategory(category));
store.dispatch("requestCategories");
if (!overrideRequest) {
store.dispatch("requestCategories");
}
return response.data;
},
};
@ -45,6 +52,7 @@ const tagPrefix = baseURL + "tags";
const tagURLs = {
getAll: `${tagPrefix}`,
getEmpty: `${tagPrefix}/empty`,
getTag: tag => `${tagPrefix}/${tag}`,
deleteTag: tag => `${tagPrefix}/${tag}`,
updateTag: tag => `${tagPrefix}/${tag}`,
@ -55,6 +63,10 @@ export const tagAPI = {
let response = await apiReq.get(tagURLs.getAll);
return response.data;
},
async getEmpty() {
let response = await apiReq.get(tagURLs.getEmpty);
return response.data;
},
async create(name) {
let response = await apiReq.post(tagURLs.getAll, { name: name });
store.dispatch("requestTags");
@ -73,9 +85,11 @@ export const tagAPI = {
return response.data;
},
async delete(tag) {
async delete(tag, overrideRequest = false) {
let response = await apiReq.delete(tagURLs.deleteTag(tag));
store.dispatch("requestTags");
if (!overrideRequest) {
store.dispatch("requestTags");
}
return response.data;
},
};

View file

@ -60,10 +60,10 @@
<v-row v-else dense>
<v-col
cols="12"
sm="12"
md="6"
lg="4"
xl="3"
:sm="singleColumn ? '12' : '12'"
:md="singleColumn ? '12' : '6'"
:lg="singleColumn ? '12' : '4'"
:xl="singleColumn ? '12' : '3'"
v-for="recipe in recipes.slice(0, cardLimit)"
:key="recipe.name"
>
@ -79,14 +79,16 @@
</v-row>
</div>
<div v-intersect="bumpList" class="d-flex">
<v-progress-circular
v-if="loading"
class="mx-auto mt-1"
:size="50"
:width="7"
color="primary"
indeterminate
></v-progress-circular>
<v-expand-x-transition>
<v-progress-circular
v-if="loading"
class="mx-auto mt-1"
:size="50"
:width="7"
color="primary"
indeterminate
></v-progress-circular>
</v-expand-x-transition>
</div>
</div>
</template>
@ -109,6 +111,12 @@ export default {
hardLimit: {
default: 99999,
},
mobileCards: {
default: false,
},
singleColumn: {
defualt: false,
},
recipes: Array,
},
data() {
@ -117,8 +125,14 @@ export default {
loading: false,
};
},
watch: {
recipes() {
this.bumpList();
},
},
computed: {
viewScale() {
if (this.mobileCards) return true;
switch (this.$vuetify.breakpoint.name) {
case "xs":
return true;
@ -128,10 +142,16 @@ export default {
return false;
}
},
effectiveHardLimit() {
return Math.min(this.hardLimit, this.recipes.length);
},
},
methods: {
bumpList() {
const newCardLimit = Math.min(this.cardLimit + 20, this.hardLimit);
const newCardLimit = Math.min(
this.cardLimit + 20,
this.effectiveHardLimit
);
if (this.loading === false && newCardLimit > this.cardLimit) {
this.setLoader();
@ -141,7 +161,7 @@ export default {
},
async setLoader() {
this.loading = true;
await new Promise(r => setTimeout(r, 3000));
await new Promise(r => setTimeout(r, 1000));
this.loading = false;
},
},

View file

@ -1,7 +1,11 @@
<template>
<div>
<v-dialog v-model="dialog" :width="modalWidth + 'px'">
<v-card class="pb-2">
<v-dialog
v-model="dialog"
:width="modalWidth + 'px'"
:content-class="top ? 'top-dialog' : undefined"
>
<v-card class="pb-2" :loading="loading">
<v-app-bar dark :color="color" class="mt-n1 mb-2">
<v-icon large left v-if="!loading">
{{ titleIcon }}
@ -19,13 +23,15 @@
</v-app-bar>
<slot> </slot>
<v-card-actions>
<v-btn text color="grey" @click="dialog = false">
Cancel
</v-btn>
<v-spacer></v-spacer>
<v-btn color="success" @click="$emit('submit')">
Submit
</v-btn>
<slot name="card-actions">
<v-btn text color="grey" @click="dialog = false">
Cancel
</v-btn>
<v-spacer></v-spacer>
<v-btn color="success" @click="$emit('submit')">
Submit
</v-btn>
</slot>
</v-card-actions>
<slot name="below-actions"> </slot>
</v-card>
@ -48,20 +54,31 @@ export default {
modalWidth: {
default: "500",
},
loading: {
default: false,
},
top: {
default: false,
},
},
data() {
return {
dialog: false,
loading: false,
};
},
methods: {
open() {
this.dialog = true;
},
close() {
this.dialog = false;
},
},
};
</script>
<style scoped>
.top-dialog {
align-self: flex-start;
}
</style>

View file

@ -1,8 +1,10 @@
<template>
<div>
<v-btn icon @click="dialog = true" class="mt-n1">
<v-icon :color="color">mdi-plus</v-icon>
</v-btn>
<slot>
<v-btn icon @click="dialog = true" class="mt-n1">
<v-icon :color="color">mdi-plus</v-icon>
</v-btn>
</slot>
<v-dialog v-model="dialog" width="500">
<v-card>
<v-app-bar dense dark color="primary mb-2">
@ -80,6 +82,9 @@ export default {
},
methods: {
open() {
this.dialog = true;
},
async select() {
const newItem = await (async () => {
if (this.tagDialog) {

View file

@ -0,0 +1,79 @@
<template>
<div>
<slot> </slot>
</div>
</template>
<script>
const RESULTS_EVENT = "results";
import Fuse from "fuse.js";
export default {
props: {
search: {
default: "",
},
rawData: {
default: true,
},
/** Defaults to Show All Results */
showAll: {
default: true,
},
keys: {
type: Array,
default: () => ["name"],
},
defaultOptions: {
default: () => ({
shouldSort: true,
threshold: 0.6,
location: 0,
distance: 100,
findAllMatches: true,
maxPatternLength: 32,
minMatchCharLength: 2,
}),
},
},
data() {
return {
results: [],
fuseResults: [],
};
},
computed: {
options() {
return { ...this.defaultOptions, ...{ keys: this.keys } };
},
autoResults() {
return this.fuseResults.length > 1 ? this.fuseResults : this.results;
},
fuse() {
return new Fuse(this.rawData, this.options);
},
isSearching() {
return this.search && this.search.length > 0;
},
},
watch: {
search() {
try {
this.results = this.fuse.search(this.search.trim());
} catch {
this.results = this.rawData
.map(x => ({ item: x }))
.sort((a, b) => (a.name > b.name ? 1 : -1));
}
this.$emit(RESULTS_EVENT, this.results);
if (this.showResults === true) {
this.fuseResults = this.results;
}
},
},
};
</script>
<style scoped>
</style>

View file

@ -0,0 +1,136 @@
<template>
<div>
<base-dialog
ref="assignDialog"
title-icon="mdi-tag"
color="primary"
title="Bulk Assign"
:loading="loading"
modal-width="700"
:top="true"
>
<v-card-text>
<v-text-field
v-model="search"
autocomplete="off"
label="Keyword"
></v-text-field>
<CategoryTagSelector
:tag-selector="false"
v-model="catsToAssign"
:return-object="false"
/>
<CategoryTagSelector
:tag-selector="true"
v-model="tagsToAssign"
:return-object="false"
/>
</v-card-text>
<template slot="card-actions">
<v-btn text color="grey" @click="closeDialog">
Cancel
</v-btn>
<v-spacer></v-spacer>
<v-btn
color="success"
@click="assignAll"
:loading="loading"
:disabled="results.length < 1"
>
Assign All
</v-btn>
</template>
<template slot="below-actions">
<v-card-title class="headline"> </v-card-title>
<CardSection
class="px-2 pb-2"
:title="`${results.length || 0} Recipes Effected`"
:mobile-cards="true"
:recipes="results"
:single-column="true"
/>
</template>
</base-dialog>
<v-btn @click="openDialog" small color="success" class="mr-1">
Bulk Assign
</v-btn>
</div>
</template>
<script>
import CardSection from "@/components/UI/CardSection";
import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector";
import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
export default {
props: {
isTags: {
default: true,
},
},
components: {
CardSection,
BaseDialog,
CategoryTagSelector,
},
data() {
return {
search: "",
loading: false,
assignTargetRecipes: [],
catsToAssign: [],
tagsToAssign: [],
};
},
mounted() {
this.$store.dispatch("requestAllRecipes");
},
computed: {
allRecipes() {
return this.$store.getters.getRecentRecipes;
},
results() {
if (this.search === null || this.search === "") {
return [];
}
return this.allRecipes.filter(x => {
return (
this.checkForKeywords(x.name) || this.checkForKeywords(x.description)
);
});
},
keywords() {
const lowered = this.search.toLowerCase();
return lowered.split(" ");
},
},
methods: {
reset() {
this.search = "";
this.loading = false;
this.assignTargetRecipes = [];
this.catsToAssign = [];
this.tagsToAssign = [];
},
assignAll() {
console.log("Categories", this.catsToAssign);
console.log("Tags", this.tagsToAssign);
console.log("results", this.results);
},
closeDialog() {
this.$refs.assignDialog.close();
},
async openDialog() {
this.$refs.assignDialog.open();
this.reset();
},
checkForKeywords(str) {
const searchStr = str.toLowerCase();
return this.keywords.some(x => searchStr.includes(x));
},
},
};
</script>
<style lang="scss" scoped>
</style>

View file

@ -0,0 +1,101 @@
<template>
<div>
<base-dialog
ref="deleteDialog"
title-icon="mdi-tag"
color="error"
:title="title"
:loading="loading"
modal-width="400"
>
<v-list v-if="deleteList.length > 0">
<v-list-item v-for="item in deleteList" :key="item.slug">
<v-list-item-content>
{{ item.name }}
</v-list-item-content>
</v-list-item>
</v-list>
<v-card-text v-else class=" mt-4 text-center">
No Unused Items
</v-card-text>
<template slot="card-actions">
<v-btn text color="grey" @click="closeDialog">
Cancel
</v-btn>
<v-spacer></v-spacer>
<v-btn
color="error"
@click="deleteUnused"
:loading="loading"
:disabled="deleteList.length < 1"
>
Delete
</v-btn>
</template>
</base-dialog>
<v-btn @click="openDialog" small color="error" class="mr-1">
Remove Unused
</v-btn>
</div>
</template>
<script>
import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
import { api } from "@/api";
export default {
props: {
isTags: {
default: true,
},
},
components: {
BaseDialog,
},
data() {
return {
deleteList: [],
loading: false,
};
},
computed: {
title() {
return this.isTags ? "Delete Tags" : "Delete Categories";
},
},
methods: {
closeDialog() {
this.$refs.deleteDialog.close();
},
async openDialog() {
this.$refs.deleteDialog.open();
console.log(this.isTags);
if (this.isTags) {
this.deleteList = await api.tags.getEmpty();
} else {
this.deleteList = await api.categories.getEmpty();
}
},
async deleteUnused() {
this.loading = true;
if (this.isTags) {
this.deleteList.forEach(async element => {
await api.tags.delete(element.slug, true);
});
this.$store.dispatch("requestTags");
} else {
this.deleteList.forEach(async element => {
await api.categories.delete(element.slug, true);
});
this.$store.dispatch("requestCategories");
}
this.loading = false;
this.closeDialog();
},
},
};
</script>
<style lang="scss" scoped>
</style>

View file

@ -33,12 +33,44 @@
/>
</template>
</base-dialog>
<v-app-bar flat>
<v-spacer> </v-spacer>
<v-btn @click="titleCaseAll" small color="success">
<v-app-bar flat color="white">
<new-category-tag-dialog
ref="newDialog"
:tag-dialog="isTags"
class="mr-1"
>
<v-btn @click="openNewDialog" small color="success">
New
</v-btn>
</new-category-tag-dialog>
<BulkAssign isTags="isTags" />
<v-btn @click="titleCaseAll" class="mr-1" small color="success">
Title Case All
</v-btn>
<RemoveUnused :isTags="isTags" />
<v-spacer> </v-spacer>
<fuse-search-bar
:raw-data="allItems"
@results="filterItems"
:search="searchString"
>
<v-text-field
v-model="searchString"
clearable
solo
dense
class="mx-2"
hide-details
single-line
:placeholder="$t('search.search')"
prepend-inner-icon="mdi-magnify"
>
</v-text-field>
</fuse-search-bar>
</v-app-bar>
<v-divider></v-divider>
<v-card-text>
<v-row>
<v-col
@ -46,16 +78,16 @@
:md="6"
:lg="4"
:xl="3"
v-for="item in allItems"
v-for="item in results"
:key="item.id"
>
<v-card>
<v-card-actions>
<v-card-title class="py-1">{{ item.name }}</v-card-title>
<v-spacer></v-spacer>
<v-btn small text color="info" @click="openRename(item)"
>Rename</v-btn
>
<v-btn small text color="info" @click="openEditDialog(item)">
Edit
</v-btn>
<v-btn small text color="error" @click="deleteItem(item.slug)"
>Delete
</v-btn>
@ -68,15 +100,23 @@
</template>
<script>
import FuseSearchBar from "@/components/UI/Search/FuseSearchBar";
import MobileRecipeCard from "@/components/Recipe/MobileRecipeCard";
import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
import { api } from "@/api";
import { validators } from "@/mixins/validators";
import RemoveUnused from "./RemoveUnused";
import BulkAssign from "./BulkAssign";
import NewCategoryTagDialog from "@/components/UI/Dialogs/NewCategoryTagDialog";
export default {
mixins: [validators],
components: {
BaseDialog,
MobileRecipeCard,
FuseSearchBar,
RemoveUnused,
NewCategoryTagDialog,
BulkAssign,
},
props: {
isTags: {
@ -85,6 +125,8 @@ export default {
},
data() {
return {
searchString: "",
searchResults: [],
renameTarget: {
title: "",
name: "",
@ -100,9 +142,21 @@ export default {
? this.$store.getters.getAllTags
: this.$store.getters.getAllCategories;
},
results() {
if (this.searchString != null && this.searchString.length >= 1) {
return this.searchResults;
}
return this.allItems;
},
},
methods: {
async openRename(item) {
filterItems(val) {
this.searchResults = val.map(x => x.item);
},
openNewDialog() {
this.$refs.newDialog.open();
},
async openEditDialog(item) {
let fromAPI = {};
if (this.isTags) {
fromAPI = await api.tags.getRecipesInTag(item.slug);
@ -151,11 +205,13 @@ export default {
if (this.isTags) {
renameList.forEach(async element => {
if (element.name === element.newName) return;
await api.tags.update(element.slug, element.newName, true);
});
this.$store.dispatch("requestTags");
} else {
renameList.forEach(async element => {
if (element.name === element.newName) return;
await api.categories.update(element.slug, element.newName, true);
});
this.$store.dispatch("requestCategories");