feature/category-tag-crud (#354)

* update tag route

* search.and

* offset for mobile

* relative imports

* get settings

* new page

* category/tag CRUD

* bulk assign frontend

* Bulk assign

* debounce search

* remove dev data

* recipe store refactor

* fix mobile view

* fix failing tests

* commit test data

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden
2021-04-27 11:17:00 -08:00
committed by GitHub
parent f748bbba68
commit 846d1eda5b
40 changed files with 1028 additions and 145 deletions

View File

@@ -0,0 +1,171 @@
<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">
{{ $t("general.cancel") }}
</v-btn>
<v-spacer></v-spacer>
<v-btn
color="success"
@click="assignAll"
:loading="loading"
:disabled="results.length < 1"
>
{{ $t("settings.toolbox.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">
{{ $t("settings.toolbox.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";
import { api } from "@/api";
export default {
props: {
isTags: {
default: true,
},
},
components: {
CardSection,
BaseDialog,
CategoryTagSelector,
},
data() {
return {
results: [],
search: "",
loading: false,
assignTargetRecipes: [],
catsToAssign: [],
tagsToAssign: [],
};
},
mounted() {
this.$store.dispatch("requestAllRecipes");
},
watch: {
search() {
this.getResults();
},
},
computed: {
allRecipes() {
return this.$store.getters.getAllRecipes;
},
// 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() {
this.loading = true;
this.results.forEach(async element => {
element.recipeCategory = element.recipeCategory.concat(
this.catsToAssign
);
element.tags = element.tags.concat(this.tagsToAssign);
await api.recipes.patch(element);
});
this.loading = false;
this.closeDialog();
},
closeDialog() {
this.$refs.assignDialog.close();
},
async openDialog() {
this.$refs.assignDialog.open();
this.reset();
},
getResults() {
this.loading = true;
// cancel pending call
clearTimeout(this._timerId);
this._timerId = setTimeout(() => {
this.results = this.filterResults();
}, 300);
this.loading = false;
// delay new call 500ms
},
filterResults() {
if (this.search === null || this.search === "") {
return [];
}
return this.allRecipes.filter(x => {
return (
this.checkForKeywords(x.name) || this.checkForKeywords(x.description)
);
});
},
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,100 @@
<template>
<div>
<base-dialog
ref="deleteDialog"
title-icon="mdi-tag"
color="error"
:title="
$t('general.delete') +
' ' +
(isTags ? $t('recipe.tags') : $t('recipe.categories'))
"
: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">
{{ $t("settings.toolbox.no-unused-items") }}
</v-card-text>
<template slot="card-actions">
<v-btn text color="grey" @click="closeDialog">
{{ $t("general.cancel") }}
</v-btn>
<v-spacer></v-spacer>
<v-btn
color="error"
@click="deleteUnused"
:loading="loading"
:disabled="deleteList.length < 1"
>
{{ $t("general.delete") }}
</v-btn>
</template>
</base-dialog>
<v-btn @click="openDialog" small color="error" class="mr-1">
{{ $t("settings.toolbox.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,
};
},
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

@@ -0,0 +1,244 @@
<template>
<v-card outlined class="mt-n1">
<base-dialog
ref="renameDialog"
title-icon="mdi-tag"
:title="renameTarget.title"
modal-width="800"
@submit="renameFromDialog(renameTarget.slug, renameTarget.newName)"
>
<v-form ref="renameForm">
<v-card-text>
<v-text-field
:placeholder="$t('settings.toolbox.new-name')"
:rules="[existsRule]"
v-model="renameTarget.newName"
></v-text-field>
</v-card-text>
</v-form>
<template slot="below-actions">
<v-card-title class="headline">
{{ renameTarget.recipes.length || 0 }}
{{ $t("settings.toolbox.recipes-effected") }}
</v-card-title>
<MobileRecipeCard
class="ml-2 mr-2 mt-2 mb-2"
v-for="recipe in renameTarget.recipes"
:key="recipe.slug"
:slug="recipe.slug"
:name="recipe.name"
:description="recipe.description"
:rating="recipe.rating"
:route="false"
:tags="recipe.tags"
/>
</template>
</base-dialog>
<div class="d-flex justify-center align-center pa-2 flex-wrap">
<new-category-tag-dialog ref="newDialog" :tag-dialog="isTags">
<v-btn @click="openNewDialog" small color="success" class="mr-1 mb-1">
{{ $t("general.create") }}
</v-btn>
</new-category-tag-dialog>
<BulkAssign isTags="isTags" class="mr-1 mb-1" />
<v-btn
@click="titleCaseAll"
small
color="success"
class="mr-1 mb-1"
:loading="loadingTitleCase"
>
{{ $t("settings.toolbox.title-case-all") }}
</v-btn>
<RemoveUnused :isTags="isTags" class="mb-1" />
<v-spacer v-if="!isMobile"> </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>
</div>
<v-divider></v-divider>
<v-card-text>
<v-row>
<v-col
cols="12"
:sm="12"
:md="6"
:lg="4"
:xl="3"
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="openEditDialog(item)">
Edit
</v-btn>
<v-btn small text color="error" @click="deleteItem(item.slug)"
>Delete
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
</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: {
default: true,
},
},
data() {
return {
loadingTitleCase: false,
searchString: "",
searchResults: [],
renameTarget: {
title: "",
name: "",
slug: "",
newName: "",
recipes: [],
},
};
},
computed: {
isMobile() {
return this.$vuetify.breakpoint.name === "xs";
},
allItems() {
return this.isTags
? this.$store.getters.getAllTags
: this.$store.getters.getAllCategories;
},
results() {
if (this.searchString != null && this.searchString.length >= 1) {
return this.searchResults;
}
return this.allItems;
},
},
methods: {
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);
} else {
fromAPI = await api.categories.getRecipesInCategory(item.slug);
}
this.renameTarget = {
title: `Rename ${item.name}`,
name: item.name,
slug: item.slug,
newName: "",
recipes: fromAPI.recipes,
};
this.$refs.renameDialog.open();
},
async deleteItem(name) {
if (this.isTags) {
await api.tags.delete(name);
} else {
await api.categories.delete(name);
}
},
async renameFromDialog(name, newName) {
if (this.$refs.renameForm.validate()) {
await this.rename(name, newName);
}
this.$refs.renameDialog.close();
},
async rename(name, newName) {
if (this.isTags) {
await api.tags.update(name, newName);
} else {
await api.categories.update(name, newName);
}
},
titleCase(lowerName) {
return lowerName.replace(/(?:^|\s|-)\S/g, x => x.toUpperCase());
},
async titleCaseAll() {
this.loadingTitleCase = true;
const renameList = this.allItems.map(x => ({
slug: x.slug,
name: x.name,
newName: this.titleCase(x.name),
}));
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");
}
this.loadingTitleCase = false;
},
},
};
</script>
<style>
.overflow-fix .v-toolbar__content {
height: auto !important;
flex-wrap: wrap;
}
</style>

View File

@@ -0,0 +1,47 @@
<template>
<div>
<v-card flat>
<v-tabs
v-model="tab"
background-color="primary"
centered
dark
icons-and-text
>
<v-tabs-slider></v-tabs-slider>
<v-tab>
{{ $t("recipe.categories") }}
<v-icon>mdi-tag-multiple-outline</v-icon>
</v-tab>
<v-tab>
{{ $t("recipe.tags") }}
<v-icon>mdi-tag-multiple-outline</v-icon>
</v-tab>
</v-tabs>
<v-tabs-items v-model="tab">
<v-tab-item><CategoryTagEditor :is-tags="false"/></v-tab-item>
<v-tab-item><CategoryTagEditor :is-tags="true" /> </v-tab-item>
</v-tabs-items>
</v-card>
</div>
</template>
<script>
import CategoryTagEditor from "./CategoryTagEditor";
export default {
components: {
CategoryTagEditor,
},
data() {
return {
tab: 0,
};
},
};
</script>
<style>
</style>