mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-11-09 13:32:19 -05:00
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:
@@ -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>
|
||||
@@ -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>
|
||||
244
frontend/src/pages/Admin/ToolBox/CategoryTagEditor/index.vue
Normal file
244
frontend/src/pages/Admin/ToolBox/CategoryTagEditor/index.vue
Normal 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>
|
||||
47
frontend/src/pages/Admin/ToolBox/index.vue
Normal file
47
frontend/src/pages/Admin/ToolBox/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user