Merge pull request #1086 from smilerz/generic_modal_v2

generic modal refactored
This commit is contained in:
vabene1111 2021-11-30 17:23:33 +01:00 committed by GitHub
commit adc65baf9c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1715 additions and 1566 deletions

View File

@ -2,25 +2,30 @@ import operator
import pathlib
import re
import uuid
from collections import OrderedDict
from datetime import date, timedelta
from decimal import Decimal
from annoying.fields import AutoOneToOneField
from django.contrib import auth
from django.contrib.auth.models import Group, User
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField
from django.core.files.uploadedfile import UploadedFile, InMemoryUploadedFile
from django.core.files.uploadedfile import InMemoryUploadedFile, UploadedFile
from django.core.validators import MinLengthValidator
from django.db import models, IntegrityError
from django.db.models import Index, ProtectedError
from django.db import IntegrityError, models
from django.db.models import Index, ProtectedError, Q, Subquery
from django.db.models.fields.related import ManyToManyField
from django.db.models.functions import Substr
from django.db.transaction import atomic
from django.utils import timezone
from django.utils.translation import gettext as _
from treebeard.mp_tree import MP_Node, MP_NodeManager
from django_scopes import ScopedManager, scopes_disabled
from django_prometheus.models import ExportModelOperationsMixin
from recipes.settings import (COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT,
KJ_PREF_DEFAULT, STICKY_NAV_PREF_DEFAULT,
SORT_TREE_BY_NAME)
from django_scopes import ScopedManager, scopes_disabled
from treebeard.mp_tree import MP_Node, MP_NodeManager
from recipes.settings import (COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT, KJ_PREF_DEFAULT,
SORT_TREE_BY_NAME, STICKY_NAV_PREF_DEFAULT)
def get_user_name(self):
@ -38,15 +43,26 @@ def get_model_name(model):
class TreeManager(MP_NodeManager):
def create(self, *args, **kwargs):
return self.get_or_create(*args, **kwargs)[0]
# model.Manager get_or_create() is not compatible with MP_Tree
def get_or_create(self, **kwargs):
def get_or_create(self, *args, **kwargs):
kwargs['name'] = kwargs['name'].strip()
try:
return self.get(name__exact=kwargs['name'], space=kwargs['space']), False
except self.model.DoesNotExist:
with scopes_disabled():
try:
return self.model.add_root(**kwargs), True
# ManyToMany fields can't be set this way, so pop them out to save for later
fields = [field.name for field in self.model._meta.get_fields() if issubclass(type(field), ManyToManyField)]
many_to_many = {field: kwargs.pop(field) for field in list(kwargs) if field in fields}
obj = self.model.add_root(**kwargs)
for field in many_to_many:
field_model = getattr(obj, field).model
for related_obj in many_to_many[field]:
getattr(obj, field).add(field_model.objects.get(**dict(related_obj)))
return obj, True
except IntegrityError as e:
if 'Key (path)' in e.args[0]:
self.model.fix_tree(fix_paths=True)

View File

@ -2,78 +2,29 @@ from rest_framework.schemas.openapi import AutoSchema
from rest_framework.schemas.utils import is_list_view
# TODO move to separate class to cleanup
class RecipeSchema(AutoSchema):
class QueryParam(object):
def __init__(self, name, description=None, qtype='string', required=False):
self.name = name
self.description = description
self.qtype = qtype
self.required = required
def __str__(self):
return f'{self.name}, {self.qtype}, {self.description}'
class QueryParamAutoSchema(AutoSchema):
def get_path_parameters(self, path, method):
if not is_list_view(path, method, self.view):
return super(RecipeSchema, self).get_path_parameters(path, method)
return super().get_path_parameters(path, method)
parameters = super().get_path_parameters(path, method)
parameters.append({
"name": 'query', "in": "query", "required": False,
"description": 'Query string matched (fuzzy) against recipe name. In the future also fulltext search.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'keywords', "in": "query", "required": False,
"description": 'Id of keyword a recipe should have. For multiple repeat parameter.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'foods', "in": "query", "required": False,
"description": 'Id of food a recipe should have. For multiple repeat parameter.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'units', "in": "query", "required": False,
"description": 'Id of unit a recipe should have.',
'schema': {'type': 'int', },
})
parameters.append({
"name": 'rating', "in": "query", "required": False,
"description": 'Id of unit a recipe should have.',
'schema': {'type': 'int', },
})
parameters.append({
"name": 'books', "in": "query", "required": False,
"description": 'Id of book a recipe should have. For multiple repeat parameter.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'steps', "in": "query", "required": False,
"description": 'Id of a step a recipe should have. For multiple repeat parameter.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'keywords_or', "in": "query", "required": False,
"description": 'If recipe should have all (AND) or any (OR) of the provided keywords.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'foods_or', "in": "query", "required": False,
"description": 'If recipe should have all (AND) or any (OR) any of the provided foods.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'books_or', "in": "query", "required": False,
"description": 'If recipe should be in all (AND) or any (OR) any of the provided books.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'internal', "in": "query", "required": False,
"description": 'true or false. If only internal recipes should be returned or not.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'random', "in": "query", "required": False,
"description": 'true or false. returns the results in randomized order.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'new', "in": "query", "required": False,
"description": 'true or false. returns new results first in search results',
'schema': {'type': 'string', },
})
for q in self.view.query_params:
parameters.append({
"name": q.name, "in": "query", "required": q.required,
"description": q.description,
'schema': {'type': q.qtype, },
})
return parameters
@ -118,15 +69,15 @@ class FilterSchema(AutoSchema):
return parameters
class QueryOnlySchema(AutoSchema):
def get_path_parameters(self, path, method):
if not is_list_view(path, method, self.view):
return super(QueryOnlySchema, self).get_path_parameters(path, method)
# class QueryOnlySchema(AutoSchema):
# def get_path_parameters(self, path, method):
# if not is_list_view(path, method, self.view):
# return super(QueryOnlySchema, self).get_path_parameters(path, method)
parameters = super().get_path_parameters(path, method)
parameters.append({
"name": 'query', "in": "query", "required": False,
"description": 'Query string matched (fuzzy) against object name.',
'schema': {'type': 'string', },
})
return parameters
# parameters = super().get_path_parameters(path, method)
# parameters.append({
# "name": 'query', "in": "query", "required": False,
# "description": 'Query string matched (fuzzy) against object name.',
# 'schema': {'type': 'string', },
# })
# return parameters

View File

@ -336,6 +336,10 @@
{% block content_fluid %}
{% endblock %}
{% user_prefs request as prefs%}
{{ prefs|json_script:'user_preference' }}
</div>
{% block script %}

View File

@ -28,10 +28,10 @@
{% trans 'Account' %}</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link {% if active_tab == 'prefernces' %} active {% endif %}" id="preferences-tab"
<a class="nav-link {% if active_tab == 'preferences' %} active {% endif %}" id="preferences-tab"
data-toggle="tab" href="#preferences" role="tab"
aria-controls="preferences"
aria-selected="{% if active_tab == 'prefernces' %} 'true' {% else %} 'false' {% endif %}">
aria-selected="{% if active_tab == 'preferences' %} 'true' {% else %} 'false' {% endif %}">
{% trans 'Preferences' %}</a>
</li>
<li class="nav-item" role="presentation">
@ -225,4 +225,4 @@
window.location.hash = e.target.hash;
})
</script>
{% endblock %}
{% endblock %}

View File

@ -28,13 +28,6 @@
<span class="col col-md-9">
<h2>{% trans 'Shopping List' %}</h2>
</span>
<span class="col-md-3">
<a href="{% url 'view_shopping_new' %}" class="float-right">
<button class="btn btn-outline-secondary shadow-none">
<i class="fas fa-star"></i> {% trans 'Try the new shopping list' %}
</button>
</a>
</span>
<div class="col col-mdd-3 text-right">
<b-form-checkbox switch size="lg" v-model="edit_mode"
@change="$forceUpdate()">{% trans 'Edit' %}</b-form-checkbox>
@ -977,4 +970,4 @@
});
</script>
{% endblock %}
{% endblock %}

View File

@ -1,4 +1,5 @@
{% extends "base.html" %}
{% comment %} TODO: refactor to be Vue app {% endcomment %}
{% load i18n %}
{% load static %}
{% load custom_tags %}

View File

@ -1,17 +1,19 @@
import re
from gettext import gettext as _
import bleach
import markdown as md
import re
from bleach_allowlist import markdown_attrs, markdown_tags
from cookbook.helper.mdx_attributes import MarkdownFormatExtension
from cookbook.helper.mdx_urlize import UrlizeExtension
from cookbook.models import Space, get_model_name
from django import template
from django.db.models import Avg
from django.templatetags.static import static
from django.urls import NoReverseMatch, reverse
from recipes import settings
from rest_framework.authtoken.models import Token
from gettext import gettext as _
from cookbook.helper.mdx_attributes import MarkdownFormatExtension
from cookbook.helper.mdx_urlize import UrlizeExtension
from cookbook.models import Space, get_model_name
from recipes import settings
register = template.Library()
@ -124,10 +126,10 @@ def markdown_link():
@register.simple_tag
def bookmarklet(request):
if request.is_secure():
prefix = "https://"
protocol = "https://"
else:
prefix = "http://"
server = prefix + request.get_host()
protocol = "http://"
server = protocol + request.get_host()
prefix = settings.JS_REVERSE_SCRIPT_PREFIX
# TODO is it safe to store the token in clear text in a bookmark?
if (api_token := Token.objects.filter(user=request.user).first()) is None:
@ -155,3 +157,13 @@ def base_path(request, path_type):
return request.META.get('HTTP_X_SCRIPT_NAME', '')
elif path_type == 'static_base':
return static('vue/manifest.json').replace('vue/manifest.json', '')
@register.simple_tag
def user_prefs(request):
from cookbook.serializer import \
UserPreferenceSerializer # putting it with imports caused circular execution
try:
return UserPreferenceSerializer(request.user.userpreference).data
except AttributeError:
pass

View File

@ -1,11 +1,11 @@
import json
import pytest
from django.db.models import Subquery, OuterRef
from django.db.models import OuterRef, Subquery
from django.urls import reverse
from django_scopes import scopes_disabled
from cookbook.models import Step, Ingredient
from cookbook.models import Ingredient, Step
LIST_URL = 'api:step-list'
DETAIL_URL = 'api:step-detail'
@ -34,7 +34,7 @@ def test_list_space(recipe_1_s1, u1_s1, u1_s2, space_2):
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)['results']) == 0
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)['results']) == 2
@pytest.mark.parametrize("arg", [
['a_u', 403],

View File

@ -36,7 +36,7 @@ from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest, Cus
CustomIsShare, CustomIsShared, CustomIsUser,
group_required)
from cookbook.helper.recipe_html_import import get_recipe_from_source
from cookbook.helper.recipe_search import get_facet, old_search, search_recipes
from cookbook.helper.recipe_search import get_facet, search_recipes
from cookbook.helper.recipe_url_import import get_from_scraper
from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, ImportLog, Ingredient,
Keyword, MealPlan, MealType, Recipe, RecipeBook, RecipeBookEntry,
@ -46,7 +46,9 @@ from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, Impor
from cookbook.provider.dropbox import Dropbox
from cookbook.provider.local import Local
from cookbook.provider.nextcloud import Nextcloud
from cookbook.schemas import FilterSchema, QueryOnlySchema, RecipeSchema, TreeSchema
from cookbook.schemas import FilterSchema, QueryOnlySchema, RecipeSchema, TreeSchema,QueryParamAutoSchema
from cookbook.serializer import (AutomationSerializer, BookmarkletImportSerializer,
CookLogSerializer, FoodSerializer, ImportLogSerializer,
IngredientSerializer, KeywordSerializer, MealPlanSerializer,
@ -216,7 +218,7 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin):
if root.isnumeric():
try:
root = int(root)
except self.model.DoesNotExist:
except ValueError:
self.queryset = self.model.objects.none()
if root == 0:
self.queryset = self.model.get_root_nodes()
@ -244,7 +246,7 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin):
try:
child = self.model.objects.get(pk=pk, space=self.request.space)
except (self.model.DoesNotExist):
content = {'error': True, 'msg': _(f'No {self.basename} with id {child} exists')}
content = {'error': True, 'msg': _(f'No {self.basename} with id {pk} exists')}
return Response(content, status=status.HTTP_404_NOT_FOUND)
parent = int(parent)
@ -273,7 +275,7 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin):
child.move(parent, f'{node_location}-child')
content = {'msg': _(f'{child.name} was moved successfully to parent {parent.name}')}
return Response(content, status=status.HTTP_200_OK)
except (PathOverflow, InvalidMoveToDescendant, InvalidPosition):
except (PathOverflow, InvalidMoveToDescendant, InvalidPosition) as e:
content = {'error': True, 'msg': _('An error occurred attempting to move ') + child.name}
return Response(content, status=status.HTTP_400_BAD_REQUEST)
@ -497,15 +499,20 @@ class StepViewSet(viewsets.ModelViewSet):
serializer_class = StepSerializer
permission_classes = [CustomIsUser]
pagination_class = DefaultPagination
schema = QueryOnlySchema()
query_params = [
QueryParam(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'), qtype='int'),
QueryParam(name='query', description=_('Query string matched (fuzzy) against object name.'), qtype='string'),
]
schema = QueryParamAutoSchema()
def get_queryset(self):
queryset = self.queryset.filter(recipe__space=self.request.space)
recipes = self.request.query_params.getlist('recipe', [])
query = self.request.query_params.get('query', None)
if len(recipes) > 0:
self.queryset = self.queryset.filter(recipe__in=recipes)
if query is not None:
queryset = queryset.filter(Q(name__icontains=query) | Q(recipe__name__icontains=query))
return queryset
return self.queryset.filter(recipe__space=self.request.space)
class RecipePagination(PageNumberPagination):
@ -533,8 +540,22 @@ class RecipeViewSet(viewsets.ModelViewSet):
# TODO split read and write permission for meal plan guest
permission_classes = [CustomIsShare | CustomIsGuest]
pagination_class = RecipePagination
schema = RecipeSchema()
# TODO the boolean params below (keywords_or through new) should be updated to boolean types with front end refactored accordingly
query_params = [
QueryParam(name='query', description=_('Query string matched (fuzzy) against recipe name. In the future also fulltext search.')),
QueryParam(name='keywords', description=_('ID of keyword a recipe should have. For multiple repeat parameter.'), qtype='int'),
QueryParam(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'), qtype='int'),
QueryParam(name='units', description=_('ID of unit a recipe should have.'), qtype='int'),
QueryParam(name='rating', description=_('Rating a recipe should have. [0 - 5]'), qtype='int'),
QueryParam(name='books', description=_('ID of book a recipe should be in. For multiple repeat parameter.')),
QueryParam(name='keywords_or', description=_('If recipe should have all (AND=''false'') or any (OR=''<b>true</b>'') of the provided keywords.')),
QueryParam(name='foods_or', description=_('If recipe should have all (AND=''false'') or any (OR=''<b>true</b>'') of the provided foods.')),
QueryParam(name='books_or', description=_('If recipe should be in all (AND=''false'') or any (OR=''<b>true</b>'') of the provided books.')),
QueryParam(name='internal', description=_('If only internal recipes should be returned. [''true''/''<b>false</b>'']')),
QueryParam(name='random', description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')),
QueryParam(name='new', description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')),
]
schema = QueryParamAutoSchema()
def get_queryset(self):
share = self.request.query_params.get('share', None)
@ -606,6 +627,15 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet):
queryset = ShoppingListEntry.objects
serializer_class = ShoppingListEntrySerializer
permission_classes = [CustomIsOwner | CustomIsShared]
query_params = [
QueryParam(name='id', description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'), qtype='int'),
QueryParam(
name='checked',
description=_('Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''<b>recent</b>'']<br> - ''recent'' includes unchecked items and recently completed items.')
),
QueryParam(name='supermarket', description=_('Returns the shopping list entries sorted by supermarket category order.'), qtype='int'),
]
schema = QueryParamAutoSchema()
def get_queryset(self):
return self.queryset.filter(
@ -646,7 +676,7 @@ class ViewLogViewSet(viewsets.ModelViewSet):
class CookLogViewSet(viewsets.ModelViewSet):
queryset = CookLog.objects
serializer_class = CookLogSerializer
permission_classes = [CustomIsOwner] # CustomIsShared? since ratings are in the cooklog?
permission_classes = [CustomIsOwner]
pagination_class = DefaultPagination
def get_queryset(self):

View File

@ -1,479 +1,477 @@
<template>
<div id="app" style="margin-bottom: 4vh" v-if="this_model">
<generic-modal-form v-if="this_model"
:model="this_model"
:action="this_action"
:item1="this_item"
:item2="this_target"
:show="show_modal"
@finish-action="finishAction"/>
<div id="app" style="margin-bottom: 4vh" v-if="this_model">
<generic-modal-form v-if="this_model" :model="this_model" :action="this_action" :item1="this_item" :item2="this_target" :show="show_modal" @finish-action="finishAction" />
<div class="row">
<div class="col-md-2 d-none d-md-block"></div>
<div class="col-xl-8 col-12">
<div class="container-fluid d-flex flex-column flex-grow-1">
<!-- dynamically loaded header components -->
<div class="row" v-if="header_component_name !== ''">
<div class="col-md-12">
<component :is="headerComponent"></component>
</div>
</div>
<div class="row">
<div class="col-md-2 d-none d-md-block">
</div>
<div class="col-xl-8 col-12">
<div class="container-fluid d-flex flex-column flex-grow-1">
<div class="row">
<div class="col-md-9" style="margin-top: 1vh">
<h3>
<!-- <span><b-button variant="link" size="sm" class="text-dark shadow-none"><i class="fas fa-chevron-down"></i></b-button></span> -->
<model-menu />
<span>{{ this.this_model.name }}</span>
<span v-if="this_model.name !== 'Step'"
><b-button variant="link" @click="startAction({ action: 'new' })"><i class="fas fa-plus-circle fa-2x"></i></b-button></span
><!-- TODO add proper field to model config to determine if create should be available or not -->
</h3>
</div>
<div class="col-md-3" style="position: relative; margin-top: 1vh">
<b-form-checkbox
v-model="show_split"
name="check-button"
v-if="paginated"
class="shadow-none"
style="position: relative; top: 50%; transform: translateY(-50%)"
switch
>
{{ $t("show_split_screen") }}
</b-form-checkbox>
</div>
</div>
<!-- dynamically loaded header components -->
<div class="row" v-if="header_component_name !== ''">
<div class="col-md-12">
<component :is="headerComponent"></component>
<div class="row">
<div class="col" :class="{ 'col-md-6': show_split }">
<!-- model isn't paginated and loads in one API call -->
<div v-if="!paginated">
<generic-horizontal-card
v-for="i in items_left"
v-bind:key="i.id"
:item="i"
:model="this_model"
@item-action="startAction($event, 'left')"
@finish-action="finishAction"
/>
</div>
<!-- model is paginated and needs managed -->
<generic-infinite-cards v-if="paginated" :card_counts="left_counts" :scroll="show_split" @search="getItems($event, 'left')" @reset="resetList('left')">
<template v-slot:cards>
<generic-horizontal-card
v-for="i in items_left"
v-bind:key="i.id"
:item="i"
:model="this_model"
@item-action="startAction($event, 'left')"
@finish-action="finishAction"
/>
</template>
</generic-infinite-cards>
</div>
<div class="col col-md-6" v-if="show_split">
<generic-infinite-cards
v-if="this_model"
:card_counts="right_counts"
:scroll="show_split"
@search="getItems($event, 'right')"
@reset="resetList('right')"
>
<template v-slot:cards>
<generic-horizontal-card
v-for="i in items_right"
v-bind:key="i.id"
:item="i"
:model="this_model"
@item-action="startAction($event, 'right')"
@finish-action="finishAction"
/>
</template>
</generic-infinite-cards>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-9" style="margin-top: 1vh">
<h3>
<!-- <span><b-button variant="link" size="sm" class="text-dark shadow-none"><i class="fas fa-chevron-down"></i></b-button></span> -->
<model-menu/>
<span>{{ this.this_model.name }}</span>
<span v-if="this_model.name !== 'Step'"><b-button variant="link" @click="startAction({'action':'new'})"><i
class="fas fa-plus-circle fa-2x"></i></b-button></span><!-- TODO add proper field to model config to determine if create should be available or not -->
</h3>
</div>
<div class="col-md-3" style="position: relative; margin-top: 1vh">
<b-form-checkbox v-model="show_split" name="check-button" v-if="paginated"
class="shadow-none"
style="position:relative;top: 50%; transform: translateY(-50%);" switch>
{{ $t('show_split_screen') }}
</b-form-checkbox>
</div>
</div>
<div class="row">
<div class="col" :class="{'col-md-6' : show_split}">
<!-- model isn't paginated and loads in one API call -->
<div v-if="!paginated">
<generic-horizontal-card v-for="i in items_left" v-bind:key="i.id"
:item=i
:model="this_model"
@item-action="startAction($event, 'left')"
@finish-action="finishAction"/>
</div>
<!-- model is paginated and needs managed -->
<generic-infinite-cards v-if="paginated"
:card_counts="left_counts"
:scroll="show_split"
@search="getItems($event, 'left')"
@reset="resetList('left')">
<template v-slot:cards>
<generic-horizontal-card
v-for="i in items_left" v-bind:key="i.id"
:item=i
:model="this_model"
@item-action="startAction($event, 'left')"
@finish-action="finishAction"/>
</template>
</generic-infinite-cards>
</div>
<div class="col col-md-6" v-if="show_split">
<generic-infinite-cards v-if="this_model"
:card_counts="right_counts"
:scroll="show_split"
@search="getItems($event, 'right')"
@reset="resetList('right')">
<template v-slot:cards>
<generic-horizontal-card
v-for="i in items_right" v-bind:key="i.id"
:item=i
:model="this_model"
@item-action="startAction($event, 'right')"
@finish-action="finishAction"/>
</template>
</generic-infinite-cards>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue"
import Vue from 'vue'
import {BootstrapVue} from 'bootstrap-vue'
import "bootstrap-vue/dist/bootstrap-vue.css"
import 'bootstrap-vue/dist/bootstrap-vue.css'
import { CardMixin, ApiMixin, getConfig } from "@/utils/utils"
import { StandardToasts, ToastMixin } from "@/utils/utils"
import {CardMixin, ApiMixin, getConfig} from "@/utils/utils";
import {StandardToasts, ToastMixin} from "@/utils/utils";
import GenericInfiniteCards from "@/components/GenericInfiniteCards";
import GenericHorizontalCard from "@/components/GenericHorizontalCard";
import GenericModalForm from "@/components/Modals/GenericModalForm";
import ModelMenu from "@/components/ModelMenu";
import {ApiApiFactory} from "@/utils/openapi/api";
import GenericInfiniteCards from "@/components/GenericInfiniteCards"
import GenericHorizontalCard from "@/components/GenericHorizontalCard"
import GenericModalForm from "@/components/Modals/GenericModalForm"
import ModelMenu from "@/components/ModelMenu"
import { ApiApiFactory } from "@/utils/openapi/api"
//import StorageQuota from "@/components/StorageQuota";
Vue.use(BootstrapVue)
export default {
// TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available
// or i'm capturing it incorrectly
name: 'ModelListView',
mixins: [CardMixin, ApiMixin, ToastMixin],
components: {
GenericHorizontalCard, GenericModalForm, GenericInfiniteCards, ModelMenu,
},
data() {
return {
// this.Models and this.Actions inherited from ApiMixin
items_left: [],
items_right: [],
right_counts: {'max': 9999, 'current': 0},
left_counts: {'max': 9999, 'current': 0},
this_model: undefined,
model_menu: undefined,
this_action: undefined,
this_recipe_param: undefined,
this_item: {},
this_target: {},
show_modal: false,
show_split: false,
paginated: false,
header_component_name: undefined,
}
},
computed: {
headerComponent() {
// TODO this leads webpack to create one .js file for each component in this folder because at runtime any one of them could be requested
// TODO this is not necessarily bad but maybe there are better options to do this
return () => import(/* webpackChunkName: "header-component" */ `@/components/${this.header_component_name}`)
}
},
mounted() {
// value is passed from lists.py
let model_config = JSON.parse(document.getElementById('model_config').textContent)
this.this_model = this.Models[model_config?.model]
this.this_recipe_param = model_config?.recipe_param
this.paginated = this.this_model?.paginated ?? false
this.header_component_name = this.this_model?.list?.header_component?.name ?? undefined
this.$nextTick(() => {
if (!this.paginated) {
this.getItems({page:1},'left')
}
})
this.$i18n.locale = window.CUSTOM_LOCALE
},
methods: {
// this.genericAPI inherited from ApiMixin
resetList: function (e) {
this['items_' + e] = []
this[e + '_counts'].max = 9999 + Math.random()
this[e + '_counts'].current = 0
// TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available
// or i'm capturing it incorrectly
name: "ModelListView",
mixins: [CardMixin, ApiMixin, ToastMixin],
components: {
GenericHorizontalCard,
GenericModalForm,
GenericInfiniteCards,
ModelMenu,
},
startAction: function (e, param) {
let source = e?.source ?? {}
let target = e?.target ?? undefined
this.this_item = source
this.this_target = target
switch (e.action) {
case 'delete':
this.this_action = this.Actions.DELETE
this.show_modal = true
break;
case 'new':
this.this_action = this.Actions.CREATE
this.show_modal = true
break;
case 'edit':
this.this_item = e.source
this.this_action = this.Actions.UPDATE
this.show_modal = true
break;
case 'move':
if (target == null) {
this.this_item = e.source
this.this_action = this.Actions.MOVE
this.show_modal = true
} else {
this.moveThis(source.id, target.id)
}
break;
case 'merge':
if (target == null) {
this.this_item = e.source
this.this_action = this.Actions.MERGE
this.show_modal = true
} else {
this.mergeThis(e.source, e.target, false)
}
break;
case 'merge-automate':
if (target == null) {
this.this_item = e.source
this.this_action = this.Actions.MERGE
this.show_modal = true
} else {
this.mergeThis(e.source, e.target, true)
}
break
case 'get-children':
if (source.show_children) {
Vue.set(source, 'show_children', false)
} else {
this.getChildren(param, source)
}
break;
case 'get-recipes':
if (source.show_recipes) {
Vue.set(source, 'show_recipes', false)
} else {
this.getRecipes(param, source)
}
break;
}
},
finishAction: function (e) {
let update = undefined
switch (e?.action) {
case 'save':
this.saveThis(e.form_data)
break;
}
if (e !== 'cancel') {
switch (this.this_action) {
case this.Actions.DELETE:
this.deleteThis(this.this_item.id)
break;
case this.Actions.CREATE:
this.saveThis(e.form_data)
break;
case this.Actions.UPDATE:
update = e.form_data
update.id = this.this_item.id
this.saveThis(update)
break;
case this.Actions.MERGE:
this.mergeThis(this.this_item, e.form_data.target, false)
break;
case this.Actions.MOVE:
this.moveThis(this.this_item.id, e.form_data.target.id)
break;
data() {
return {
// this.Models and this.Actions inherited from ApiMixin
items_left: [],
items_right: [],
right_counts: { max: 9999, current: 0 },
left_counts: { max: 9999, current: 0 },
this_model: undefined,
model_menu: undefined,
this_action: undefined,
this_recipe_param: undefined,
this_item: {},
this_target: {},
show_modal: false,
show_split: false,
paginated: false,
header_component_name: undefined,
}
}
this.clearState()
},
getItems: function (params, col) {
let column = col || 'left'
params.options = {'query':{'extended': 1}} // returns extended values in API response
this.genericAPI(this.this_model, this.Actions.LIST, params).then((result) => {
let results = result.data?.results ?? result.data
if (results?.length) {
// let secondaryRequest = undefined;
// if (this['items_' + column]?.length < getConfig(this.this_model, this.Actions.LIST).config.pageSize.default * (params.page - 1)) {
// // the item list is smaller than it should be based on the site the user is own
// // this happens when an item is deleted (or merged)
// // to prevent issues insert the last item of the previous search page before loading the new results
// params.page = params.page - 1
// secondaryRequest = this.genericAPI(this.this_model, this.Actions.LIST, params).then((result) => {
// let prev_page_results = result.data?.results ?? result.data
// if (prev_page_results?.length) {
// results = [prev_page_results[prev_page_results.length]].concat(results)
//
// this['items_' + column] = this['items_' + column].concat(results) //TODO duplicate code, find some elegant workaround
// this[column + '_counts']['current'] = getConfig(this.this_model, this.Actions.LIST).config.pageSize.default * (params.page - 1) + results.length
// this[column + '_counts']['max'] = result.data?.count ?? 0
// }
// })
// } else {
//
// }
this['items_' + column] = this['items_' + column].concat(results)
this[column + '_counts']['current'] = getConfig(this.this_model, this.Actions.LIST).config.pageSize.default * (params.page - 1) + results.length
this[column + '_counts']['max'] = result.data?.count ?? 0
} else {
this[column + '_counts']['max'] = 0
this[column + '_counts']['current'] = 0
console.log('no data returned')
}
}).catch((err) => {
console.log(err, Object.keys(err))
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
})
computed: {
headerComponent() {
// TODO this leads webpack to create one .js file for each component in this folder because at runtime any one of them could be requested
// TODO this is not necessarily bad but maybe there are better options to do this
return () => import(/* webpackChunkName: "header-component" */ `@/components/${this.header_component_name}`)
},
},
getThis: function (id, callback) {
return this.genericAPI(this.this_model, this.Actions.FETCH, {'id': id})
},
saveThis: function (thisItem) {
if (!thisItem?.id) { // if there is no item id assume it's a new item
this.genericAPI(this.this_model, this.Actions.CREATE, thisItem).then((result) => {
// look for and destroy any existing cards to prevent duplicates in the GET case of get_or_create
// then place all new items at the top of the list - could sort instead
this.items_left = [result.data].concat(this.destroyCard(result?.data?.id, this.items_left))
// this creates a deep copy to make sure that columns stay independent
this.items_right = [{...result.data}].concat(this.destroyCard(result?.data?.id, this.items_right))
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
}).catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
mounted() {
// value is passed from lists.py
let model_config = JSON.parse(document.getElementById("model_config").textContent)
this.this_model = this.Models[model_config?.model]
this.this_recipe_param = model_config?.recipe_param
this.paginated = this.this_model?.paginated ?? false
this.header_component_name = this.this_model?.list?.header_component?.name ?? undefined
this.$nextTick(() => {
if (!this.paginated) {
this.getItems({ page: 1 }, "left")
}
})
} else {
this.genericAPI(this.this_model, this.Actions.UPDATE, thisItem).then((result) => {
this.refreshThis(thisItem.id)
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
}).catch((err) => {
console.log(err, err.response)
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
})
}
this.$i18n.locale = window.CUSTOM_LOCALE
},
moveThis: function (source_id, target_id) {
if (source_id === target_id) {
this.makeToast(this.$t('Error'), this.$t('Cannot move item to itself'), 'danger')
this.clearState()
return
}
let item = this.findCard(source_id, this.items_left) || this.findCard(source_id, this.items_right)
if (source_id === undefined || target_id === undefined || item?.parent == target_id) {
this.makeToast(this.$t('Warning'), this.$t('Nothing to do'), 'warning')
this.clearState()
return
}
this.genericAPI(this.this_model, this.Actions.MOVE, {'source': source_id, 'target': target_id}).then((result) => {
if (target_id === 0) {
this.items_left = [item].concat(this.destroyCard(source_id, this.items_left)) // order matters, destroy old card before adding it back in at root
this.items_right = [...[item]].concat(this.destroyCard(source_id, this.items_right)) // order matters, destroy old card before adding it back in at root
item.parent = null
} else {
this.items_left = this.destroyCard(source_id, this.items_left)
this.items_right = this.destroyCard(source_id, this.items_right)
this.refreshThis(target_id)
}
// TODO make standard toast
this.makeToast(this.$t('Success'), 'Succesfully moved resource', 'success')
}).catch((err) => {
console.log(err)
this.makeToast(this.$t('Error'), err.bodyText, 'danger')
})
},
mergeThis: function (source, target, automate) {
let source_id = source.id
let target_id = target.id
if (source_id === target_id) {
this.makeToast(this.$t('Error'), this.$t('Cannot merge item with itself'), 'danger')
this.clearState()
return
}
if (!source_id || !target_id) {
this.makeToast(this.$t('Warning'), this.$t('Nothing to do'), 'warning')
this.clearState()
return
}
this.genericAPI(this.this_model, this.Actions.MERGE, {
'source': source_id,
'target': target_id
}).then((result) => {
this.items_left = this.destroyCard(source_id, this.items_left)
this.items_right = this.destroyCard(source_id, this.items_right)
this.refreshThis(target_id)
// TODO make standard toast
this.makeToast(this.$t('Success'), 'Succesfully merged resource', 'success')
}).catch((err) => {
//TODO error checking not working with OpenAPI methods
console.log('Error', err)
this.makeToast(this.$t('Error'), err.bodyText, 'danger')
})
methods: {
// this.genericAPI inherited from ApiMixin
resetList: function (e) {
this["items_" + e] = []
this[e + "_counts"].max = 9999 + Math.random()
this[e + "_counts"].current = 0
},
startAction: function (e, param) {
let source = e?.source ?? {}
let target = e?.target ?? undefined
this.this_item = source
this.this_target = target
if (automate) {
let apiClient = new ApiApiFactory()
switch (e.action) {
case "delete":
this.this_action = this.Actions.DELETE
this.show_modal = true
break
case "new":
this.this_action = this.Actions.CREATE
this.show_modal = true
break
case "edit":
this.this_item = e.source
this.this_action = this.Actions.UPDATE
this.show_modal = true
break
case "move":
if (target == null) {
this.this_item = e.source
this.this_action = this.Actions.MOVE
this.show_modal = true
} else {
// this is redundant - function also exists in GenericModal
this.moveThis(source.id, target.id)
}
break
case "merge":
if (target == null) {
this.this_item = e.source
this.this_action = this.Actions.MERGE
this.show_modal = true
} else {
// this is redundant - function also exists in GenericModal
this.mergeThis(e.source, e.target, false)
}
break
case "merge-automate":
if (target == null) {
this.this_item = e.source
this.this_action = this.Actions.MERGE
this.this_item.automate = true
this.show_modal = true
} else {
// this is redundant - function also exists in GenericModal
this.mergeThis(e.source, e.target, true)
}
break
case "get-children":
if (source.show_children) {
Vue.set(source, "show_children", false)
} else {
this.getChildren(param, source)
}
break
case "get-recipes":
if (source.show_recipes) {
Vue.set(source, "show_recipes", false)
} else {
this.getRecipes(param, source)
}
break
}
},
finishAction: function (e) {
switch (e?.action) {
case "save":
this.saveThis(e.form_data)
break
}
if (e !== "cancel") {
switch (this.this_action) {
case this.Actions.DELETE:
console.log("delete")
this.deleteThis(this.this_item.id)
break
case this.Actions.CREATE:
this.saveThis(e.item)
break
case this.Actions.UPDATE:
this.updateThis(this.this_item)
break
case this.Actions.MERGE:
this.mergeUpdateItem(this.this_item.id, e.target)
break
case this.Actions.MOVE:
this.moveUpdateItem(this.this_item.id, e.target)
break
}
}
this.clearState()
},
getItems: function (params, col) {
let column = col || "left"
params.options = { query: { extended: 1 } } // returns extended values in API response
this.genericAPI(this.this_model, this.Actions.LIST, params)
.then((result) => {
let results = result.data?.results ?? result.data
let automation = {
name: `Merge ${source.name} with ${target.name}`,
param_1: source.name,
param_2: target.name
}
if (results?.length) {
// let secondaryRequest = undefined;
// if (this['items_' + column]?.length < getConfig(this.this_model, this.Actions.LIST).config.pageSize.default * (params.page - 1)) {
// // the item list is smaller than it should be based on the site the user is own
// // this happens when an item is deleted (or merged)
// // to prevent issues insert the last item of the previous search page before loading the new results
// params.page = params.page - 1
// secondaryRequest = this.genericAPI(this.this_model, this.Actions.LIST, params).then((result) => {
// let prev_page_results = result.data?.results ?? result.data
// if (prev_page_results?.length) {
// results = [prev_page_results[prev_page_results.length]].concat(results)
//
// this['items_' + column] = this['items_' + column].concat(results) //TODO duplicate code, find some elegant workaround
// this[column + '_counts']['current'] = getConfig(this.this_model, this.Actions.LIST).config.pageSize.default * (params.page - 1) + results.length
// this[column + '_counts']['max'] = result.data?.count ?? 0
// }
// })
// } else {
//
// }
if (this.this_model === this.Models.FOOD) {
automation.type = 'FOOD_ALIAS'
}
if (this.this_model === this.Models.UNIT) {
automation.type = 'UNIT_ALIAS'
}
if (this.this_model === this.Models.KEYWORD) {
automation.type = 'KEYWORD_ALIAS'
}
this["items_" + column] = this["items_" + column].concat(results)
this[column + "_counts"]["current"] = getConfig(this.this_model, this.Actions.LIST).config.pageSize.default * (params.page - 1) + results.length
this[column + "_counts"]["max"] = result.data?.count ?? 0
} else {
this[column + "_counts"]["max"] = 0
this[column + "_counts"]["current"] = 0
console.log("no data returned")
}
})
.catch((err) => {
console.log(err, Object.keys(err))
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
})
},
getThis: function (id, callback) {
return this.genericAPI(this.this_model, this.Actions.FETCH, { id: id })
},
saveThis: function (item) {
// look for and destroy any existing cards to prevent duplicates in the GET case of get_or_create
// then place all new items at the top of the list - could sort instead
this.items_left = [item].concat(this.destroyCard(item?.id, this.items_left))
// this creates a deep copy to make sure that columns stay independent
this.items_right = [{ ...item }].concat(this.destroyCard(item?.id, this.items_right))
},
updateThis: function (item) {
this.refreshThis(item.id)
},
moveThis: function (source_id, target_id) {
// TODO: this function is almost 100% duplicated in GenericModalForm and only exists to enable drag and drop
if (source_id === target_id) {
this.makeToast(this.$t("Error"), this.$t("err_move_self"), "danger")
this.clearState()
return
}
let item = this.findCard(source_id, this.items_left) || this.findCard(source_id, this.items_right)
if (source_id === undefined || target_id === undefined || item?.parent == target_id) {
this.makeToast(this.$t("Warning"), this.$t("nothing"), "warning")
this.clearState()
return
}
this.genericAPI(this.this_model, this.Actions.MOVE, { source: source_id, target: target_id })
.then((result) => {
this.moveUpdateItem(source_id, target_id)
// TODO make standard toast
this.makeToast(this.$t("Success"), "Succesfully moved resource", "success")
})
.catch((err) => {
console.log(err)
this.makeToast(this.$t("Error"), err.bodyText, "danger")
})
},
moveUpdateItem: function (source_id, target_id) {
let item = this.findCard(source_id, this.items_left) || this.findCard(source_id, this.items_right)
if (target_id === 0) {
this.items_left = [item].concat(this.destroyCard(source_id, this.items_left)) // order matters, destroy old card before adding it back in at root
this.items_right = [...[item]].concat(this.destroyCard(source_id, this.items_right)) // order matters, destroy old card before adding it back in at root
item.parent = null
} else {
this.items_left = this.destroyCard(source_id, this.items_left)
this.items_right = this.destroyCard(source_id, this.items_right)
this.refreshThis(target_id)
}
},
mergeThis: function (source, target, automate) {
// TODO: this function is almost 100% duplicated in GenericModalForm and only exists to enable drag and drop
let source_id = source.id
let target_id = target.id
if (source_id === target_id) {
this.makeToast(this.$t("Error"), this.$t("err_merge_self"), "danger")
this.clearState()
return
}
if (!source_id || !target_id) {
this.makeToast(this.$t("Warning"), this.$t("nothing"), "warning")
this.clearState()
return
}
this.genericAPI(this.this_model, this.Actions.MERGE, {
source: source_id,
target: target_id,
})
.then((result) => {
this.mergeUpdateItem(source_id, target_id)
// TODO make standard toast
this.makeToast(this.$t("Success"), "Succesfully merged resource", "success")
})
.catch((err) => {
//TODO error checking not working with OpenAPI methods
console.log("Error", err)
this.makeToast(this.$t("Error"), err.bodyText, "danger")
})
apiClient.createAutomation(automation)
}
if (automate) {
let apiClient = new ApiApiFactory()
},
getChildren: function (col, item) {
let parent = {}
let params = {
'root': item.id,
'pageSize': 200,
'query': {'extended': 1},
'options': {'query':{'extended': 1}}
}
this.genericAPI(this.this_model, this.Actions.LIST, params).then((result) => {
parent = this.findCard(item.id, this['items_' + col])
if (parent) {
Vue.set(parent, 'children', result.data.results)
Vue.set(parent, 'show_children', true)
Vue.set(parent, 'show_recipes', false)
}
}).catch((err) => {
console.log(err)
this.makeToast(this.$t('Error'), err.bodyText, 'danger')
})
},
getRecipes: function (col, item) {
let parent = {}
// TODO: make this generic
let params = {'pageSize': 50}
params[this.this_recipe_param] = item.id
console.log('RECIPE PARAM', this.this_recipe_param, params, item.id)
this.genericAPI(this.Models.RECIPE, this.Actions.LIST, params).then((result) => {
parent = this.findCard(item.id, this['items_' + col])
if (parent) {
Vue.set(parent, 'recipes', result.data.results)
Vue.set(parent, 'show_recipes', true)
Vue.set(parent, 'show_children', false)
}
let automation = {
name: `Merge ${source.name} with ${target.name}`,
param_1: source.name,
param_2: target.name,
}
}).catch((err) => {
console.log(err)
this.makeToast(this.$t('Error'), err.bodyText, 'danger')
})
if (this.this_model === this.Models.FOOD) {
automation.type = "FOOD_ALIAS"
}
if (this.this_model === this.Models.UNIT) {
automation.type = "UNIT_ALIAS"
}
if (this.this_model === this.Models.KEYWORD) {
automation.type = "KEYWORD_ALIAS"
}
apiClient.createAutomation(automation)
}
},
mergeUpdateItem: function (source, target, automate) {
this.items_left = this.destroyCard(source, this.items_left)
this.items_right = this.destroyCard(source, this.items_right)
this.refreshThis(target)
},
getChildren: function (col, item) {
let parent = {}
let params = {
root: item.id,
pageSize: 200,
query: { extended: 1 },
options: { query: { extended: 1 } },
}
this.genericAPI(this.this_model, this.Actions.LIST, params)
.then((result) => {
parent = this.findCard(item.id, this["items_" + col])
if (parent) {
Vue.set(parent, "children", result.data.results)
Vue.set(parent, "show_children", true)
Vue.set(parent, "show_recipes", false)
}
})
.catch((err) => {
console.log(err)
this.makeToast(this.$t("Error"), err.bodyText, "danger")
})
},
getRecipes: function (col, item) {
let parent = {}
// TODO: make this generic
let params = { pageSize: 50 }
params[this.this_recipe_param] = item.id
console.log("RECIPE PARAM", this.this_recipe_param, params, item.id)
this.genericAPI(this.Models.RECIPE, this.Actions.LIST, params)
.then((result) => {
parent = this.findCard(item.id, this["items_" + col])
if (parent) {
Vue.set(parent, "recipes", result.data.results)
Vue.set(parent, "show_recipes", true)
Vue.set(parent, "show_children", false)
}
})
.catch((err) => {
console.log(err)
this.makeToast(this.$t("Error"), err.bodyText, "danger")
})
},
refreshThis: function (id) {
this.getThis(id).then((result) => {
this.refreshCard(result.data, this.items_left)
this.refreshCard({ ...result.data }, this.items_right)
})
},
deleteThis: function (id) {
this.items_left = this.destroyCard(id, this.items_left)
this.items_right = this.destroyCard(id, this.items_right)
},
clearState: function () {
this.show_modal = false
this.this_action = undefined
this.this_item = undefined
this.this_target = undefined
},
},
refreshThis: function (id) {
this.getThis(id).then(result => {
this.refreshCard(result.data, this.items_left)
this.refreshCard({...result.data}, this.items_right)
})
},
deleteThis: function (id) {
this.genericAPI(this.this_model, this.Actions.DELETE, {'id': id}).then((result) => {
this.items_left = this.destroyCard(id, this.items_left)
this.items_right = this.destroyCard(id, this.items_right)
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
}).catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
})
},
clearState: function () {
this.show_modal = false
this.this_action = undefined
this.this_item = undefined
this.this_target = undefined
}
}
}
</script>
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
<style>
</style>
<style></style>

View File

@ -1,44 +1,45 @@
<template>
<div v-if="itemList">
<span :key="k.id" v-for="k in itemList" class="pl-1">
<b-badge pill :variant="color">{{thisLabel(k)}}</b-badge>
</span>
<span :key="k.id" v-for="k in itemList" class="pl-1">
<b-badge pill :variant="color">{{ thisLabel(k) }}</b-badge>
</span>
</div>
</template>
<script>
export default {
name: 'GenericPill',
props: {
item_list: {required: true, type: Array},
label: {type: String, default: 'name'},
color: {type: String, default: 'light'}
},
computed: {
itemList: function() {
if(Array.isArray(this.item_list)) {
return this.item_list
} else if (!this.item_list?.id) {
return false
} else {
return [this.item_list]
}
name: "GenericPill",
props: {
item_list: {
type: Array,
default() {
return []
},
},
label: { type: String, default: "name" },
color: { type: String, default: "light" },
},
computed: {
itemList: function () {
if (Array.isArray(this.item_list)) {
return this.item_list
} else if (!this.item_list?.id) {
return false
} else {
return [this.item_list]
}
},
},
mounted() {},
methods: {
thisLabel: function (item) {
let fields = this.label.split("::")
let value = item
fields.forEach((x) => {
value = value[x]
})
return value
},
},
},
mounted() {
},
methods: {
thisLabel: function (item) {
let fields = this.label.split('::')
let value = item
fields.forEach(x => {
value = value[x]
});
return value
}
}
}
</script>

View File

@ -1,143 +1,250 @@
<template>
<div>
<b-modal :id="'modal_'+id" @hidden="cancelAction">
<template v-slot:modal-title><h4>{{ form.title }}</h4></template>
<div v-for="(f, i) in form.fields" v-bind:key=i>
<p v-if="f.type=='instruction'">{{ f.label }}</p>
<!-- this lookup is single selection -->
<lookup-input v-if="f.type=='lookup'"
:form="f"
:model="listModel(f.list)"
@change="storeValue"/> <!-- TODO add ability to create new items associated with lookup -->
<!-- TODO: add multi-selection input list -->
<checkbox-input v-if="f.type=='checkbox'"
:label="f.label"
:value="f.value"
:field="f.field"/>
<text-input v-if="f.type=='text'"
:label="f.label"
:value="f.value"
:field="f.field"
:placeholder="f.placeholder"/>
<choice-input v-if="f.type=='choice'"
:label="f.label"
:value="f.value"
:field="f.field"
:options="f.options"
:placeholder="f.placeholder"/>
<emoji-input v-if="f.type=='emoji'"
:label="f.label"
:value="f.value"
:field="f.field"
@change="storeValue"/>
<file-input v-if="f.type=='file'"
:label="f.label"
:value="f.value"
:field="f.field"
@change="storeValue"/>
</div>
<div>
<b-modal :id="'modal_' + id" @hidden="cancelAction">
<template v-slot:modal-title
><h4>{{ form.title }}</h4></template
>
<div v-for="(f, i) in form.fields" v-bind:key="i">
<p v-if="f.type == 'instruction'">{{ f.label }}</p>
<!-- this lookup is single selection -->
<lookup-input v-if="f.type == 'lookup'" :form="f" :model="listModel(f.list)" @change="storeValue" />
<!-- TODO add ability to create new items associated with lookup -->
<!-- TODO: add multi-selection input list -->
<checkbox-input v-if="f.type == 'checkbox'" :label="f.label" :value="f.value" :field="f.field" />
<text-input v-if="f.type == 'text'" :label="f.label" :value="f.value" :field="f.field" :placeholder="f.placeholder" />
<choice-input v-if="f.type == 'choice'" :label="f.label" :value="f.value" :field="f.field" :options="f.options" :placeholder="f.placeholder" />
<emoji-input v-if="f.type == 'emoji'" :label="f.label" :value="f.value" :field="f.field" @change="storeValue" />
<file-input v-if="f.type == 'file'" :label="f.label" :value="f.value" :field="f.field" @change="storeValue" />
</div>
<template v-slot:modal-footer>
<b-button class="float-right mx-1" variant="secondary" v-on:click="cancelAction">{{ $t('Cancel') }}</b-button>
<b-button class="float-right mx-1" variant="primary" v-on:click="doAction">{{ form.ok_label }}</b-button>
</template>
</b-modal>
</div>
<template v-slot:modal-footer>
<b-button class="float-right mx-1" variant="secondary" v-on:click="cancelAction">{{ $t("Cancel") }}</b-button>
<b-button class="float-right mx-1" variant="primary" v-on:click="doAction">{{ form.ok_label }}</b-button>
</template>
</b-modal>
</div>
</template>
<script>
import Vue from 'vue'
import {BootstrapVue} from 'bootstrap-vue'
import {getForm} from "@/utils/utils";
import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue"
import { getForm } from "@/utils/utils"
Vue.use(BootstrapVue)
import {Models} from "@/utils/models";
import CheckboxInput from "@/components/Modals/CheckboxInput";
import LookupInput from "@/components/Modals/LookupInput";
import TextInput from "@/components/Modals/TextInput";
import EmojiInput from "@/components/Modals/EmojiInput";
import ChoiceInput from "@/components/Modals/ChoiceInput";
import FileInput from "@/components/Modals/FileInput";
import { ApiApiFactory } from "@/utils/openapi/api"
import { ApiMixin, StandardToasts, ToastMixin } from "@/utils/utils"
import CheckboxInput from "@/components/Modals/CheckboxInput"
import LookupInput from "@/components/Modals/LookupInput"
import TextInput from "@/components/Modals/TextInput"
import EmojiInput from "@/components/Modals/EmojiInput"
import ChoiceInput from "@/components/Modals/ChoiceInput"
import FileInput from "@/components/Modals/FileInput"
export default {
name: 'GenericModalForm',
components: {FileInput, CheckboxInput, LookupInput, TextInput, EmojiInput, ChoiceInput},
props: {
model: {required: true, type: Object},
action: {required: true, type: Object},
item1: {
type: Object, default() {
return undefined
}
name: "GenericModalForm",
components: { FileInput, CheckboxInput, LookupInput, TextInput, EmojiInput, ChoiceInput },
mixins: [ApiMixin, ToastMixin],
props: {
model: { required: true, type: Object },
action: { type: Object },
item1: {
type: Object,
default() {
return {}
},
},
item2: {
type: Object,
default() {
return {}
},
},
show: { required: true, type: Boolean, default: false },
},
item2: {
type: Object, default() {
return undefined
}
},
show: {required: true, type: Boolean, default: false},
},
data() {
return {
id: undefined,
form_data: {},
form: {},
dirty: false,
special_handling: false
}
},
mounted() {
this.id = Math.random()
this.$root.$on('change', this.storeValue); // boostrap modal placed at document so have to listen at root of component
},
computed: {
buttonLabel() {
return this.buttons[this.action].label;
},
},
watch: {
'show': function () {
if (this.show) {
this.form = getForm(this.model, this.action, this.item1, this.item2)
this.dirty = true
this.$bvModal.show('modal_' + this.id)
} else {
this.$bvModal.hide('modal_' + this.id)
this.form_data = {}
}
},
},
methods: {
doAction: function () {
this.dirty = false
this.$emit('finish-action', {'form_data': this.detectOverride(this.form_data)})
},
cancelAction: function () {
if (this.dirty) {
this.dirty = false
this.$emit('finish-action', 'cancel')
}
},
storeValue: function (field, value) {
this.form_data[field] = value
},
listModel: function (m) {
if (m === 'self') {
return this.model
} else {
return Models[m]
}
},
detectOverride: function (form) {
for (const [k, v] of Object.entries(form)) {
if (form[k].__override__) {
form[k] = form[k].__override__
data() {
return {
id: undefined,
form_data: {},
form: {},
dirty: false,
special_handling: false,
}
}
return form
}
}
},
mounted() {
this.id = Math.random()
this.$root.$on("change", this.storeValue) // boostrap modal placed at document so have to listen at root of component
},
computed: {
buttonLabel() {
return this.buttons[this.action].label
},
},
watch: {
show: function () {
if (this.show) {
this.form = getForm(this.model, this.action, this.item1, this.item2)
this.dirty = true
this.$bvModal.show("modal_" + this.id)
} else {
this.$bvModal.hide("modal_" + this.id)
this.form_data = {}
}
},
},
methods: {
doAction: function () {
this.dirty = false
switch (this.action) {
case this.Actions.DELETE:
this.delete()
break
case this.Actions.CREATE:
this.save()
break
case this.Actions.UPDATE:
this.form_data.id = this.item1.id
this.save()
break
case this.Actions.MERGE:
this.merge(this.item1, this.form_data.target.id, this.item1?.automate ?? false)
break
case this.Actions.MOVE:
this.move(this.item1.id, this.form_data.target.id)
break
}
},
cancelAction: function () {
if (this.dirty) {
this.dirty = false
this.$emit("finish-action", "cancel")
}
},
storeValue: function (field, value) {
this.form_data[field] = value
},
listModel: function (m) {
if (m === "self") {
return this.model
} else {
return this.Models[m]
}
},
detectOverride: function (form) {
for (const [k, v] of Object.entries(form)) {
if (form[k].__override__) {
form[k] = form[k].__override__
}
}
return form
},
delete: function () {
this.genericAPI(this.model, this.Actions.DELETE, { id: this.item1.id })
.then((result) => {
this.$emit("finish-action")
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
})
.catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
this.$emit("finish-action", "cancel")
})
},
save: function () {
if (!this.item1?.id) {
// if there is no item id assume it's a new item
this.genericAPI(this.model, this.Actions.CREATE, this.form_data)
.then((result) => {
this.$emit("finish-action", { item: result.data })
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
})
.catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
this.$emit("finish-action", "cancel")
})
} else {
this.genericAPI(this.model, this.Actions.UPDATE, this.form_data)
.then((result) => {
this.$emit("finish-action")
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
})
.catch((err) => {
console.log(err, err.response)
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
this.$emit("finish-action", "cancel")
})
}
},
move: function () {
if (this.item1.id === this.form_data.target.id) {
this.makeToast(this.$t("Error"), this.$t("err_move_self"), "danger")
this.$emit("finish-action", "cancel")
return
}
if (this.form_data.target.id === undefined || this.item1?.parent == this.form_data.target.id) {
this.makeToast(this.$t("Warning"), this.$t("nothing"), "warning")
this.$emit("finish-action", "cancel")
return
}
this.genericAPI(this.model, this.Actions.MOVE, { source: this.item1.id, target: this.form_data.target.id })
.then((result) => {
this.$emit("finish-action", { target: this.form_data.target.id })
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_MOVE)
})
.catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_MOVE)
this.$emit("finish-action", "cancel")
})
},
merge: function () {
if (this.item1.id === this.form_data.target.id) {
this.makeToast(this.$t("Error"), this.$t("err_merge_self"), "danger")
this.$emit("finish-action", "cancel")
return
}
if (!this.item1.id || !this.form_data.target.id) {
this.makeToast(this.$t("Warning"), this.$t("nothing"), "warning")
this.$emit("finish-action", "cancel")
return
}
this.genericAPI(this.model, this.Actions.MERGE, {
source: this.item1.id,
target: this.form_data.target.id,
})
.then((result) => {
this.$emit("finish-action", { target: this.form_data.target.id })
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_MERGE)
})
.catch((err) => {
//TODO error checking not working with OpenAPI methods
console.log("Error", err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_MERGE)
this.$emit("finish-action", "cancel")
})
if (this.item1.automate) {
let apiClient = new ApiApiFactory()
let automation = {
name: `Merge ${this.item1.name} with ${this.form_data.target.name}`,
param_1: this.item1.name,
param_2: this.form_data.target.name,
}
if (this.model === this.Models.FOOD) {
automation.type = "FOOD_ALIAS"
}
if (this.model === this.Models.UNIT) {
automation.type = "UNIT_ALIAS"
}
if (this.model === this.Models.KEYWORD) {
automation.type = "KEYWORD_ALIAS"
}
apiClient.createAutomation(automation)
}
},
},
}
</script>
</script>

View File

@ -1,157 +1,171 @@
<template>
<div>
<b-form-group
v-bind:label="form.label"
class="mb-3">
<generic-multiselect
@change="new_value=$event.val"
@remove="new_value=undefined"
:initial_selection="initialSelection"
:model="model"
:multiple="useMultiple"
:sticky_options="sticky_options"
:allow_create="create_new"
:create_placeholder="createPlaceholder"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
:placeholder="modelName"
@new="addNew">
</generic-multiselect>
<b-form-group class="mb-3">
<template #label v-if="show_label">
{{ form.label }}
</template>
<generic-multiselect
@change="new_value = $event.val"
@remove="new_value = undefined"
:initial_selection="initialSelection"
:model="model"
:multiple="useMultiple"
:sticky_options="sticky_options"
:allow_create="form.allow_create"
:create_placeholder="createPlaceholder"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
:placeholder="modelName"
@new="addNew"
>
</generic-multiselect>
</b-form-group>
</div>
</template>
<script>
import GenericMultiselect from "@/components/GenericMultiselect";
import {StandardToasts, ApiMixin} from "@/utils/utils";
import GenericMultiselect from "@/components/GenericMultiselect"
import { StandardToasts, ApiMixin } from "@/utils/utils"
export default {
name: 'LookupInput',
components: {GenericMultiselect},
mixins: [ApiMixin],
props: {
form: {type: Object, default () {return undefined}},
model: {type: Object, default () {return undefined}},
// TODO: include create_new and create_text props and associated functionality to create objects for drop down
// see 'tagging' here: https://vue-multiselect.js.org/#sub-tagging
// perfect world would have it trigger a new modal associated with the associated item model
},
data() {
return {
new_value: undefined,
field: undefined,
label: undefined,
sticky_options: undefined,
first_run: true
}
},
mounted() {
this.new_value = this.form?.value
this.field = this.form?.field ?? 'You Forgot To Set Field Name'
this.label = this.form?.label ?? ''
this.sticky_options = this.form?.sticky_options ?? []
},
computed: {
modelName() {
return this?.model?.name ?? this.$t('Search')
name: "LookupInput",
components: { GenericMultiselect },
mixins: [ApiMixin],
props: {
form: {
type: Object,
default() {
return undefined
},
},
model: {
type: Object,
default() {
return undefined
},
},
show_label: { type: Boolean, default: true },
},
useMultiple() {
return this.form?.multiple || this.form?.ordered || false
},
initialSelection() {
let this_value = this.form.value
let arrayValues = undefined
// multiselect is expect to get an array of objects - make sure it gets one
if (Array.isArray(this_value)) {
arrayValues = this_value
} else if (!this_value) {
arrayValues = []
} else if (typeof(this_value) === 'object') {
arrayValues = [this_value]
} else {
arrayValues = [{'id': -1, 'name': this_value}]
}
if (this.form?.ordered && this.first_run) {
return this.flattenItems(arrayValues)
} else {
return arrayValues
}
},
createPlaceholder() {
return this.$t('Create_New_' + this?.model?.name)
}
},
watch: {
'new_value': function () {
let x = this?.new_value
// pass the unflattened attributes that can be restored when ready to save/update
if (this.form?.ordered) {
x['__override__'] = this.unflattenItem(this?.new_value)
}
this.$root.$emit('change', this.form.field, x)
},
},
methods: {
addNew: function(e) {
// if create a new item requires more than 1 parameter or the field 'name' is insufficient this will need reworked
// in a perfect world this would trigger a new modal and allow editing all fields
this.genericAPI(this.model, this.Actions.CREATE, {'name': e}).then((result) => {
this.new_value = result.data
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
}).catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
})
},
// ordered lookups have nested attributes that need flattened attributes to drive lookup
flattenItems: function(itemlist) {
let flat_items = []
let item = undefined
let label = this.form.list_label.split('::')
itemlist.forEach(x => {
item = {}
for (const [k, v] of Object.entries(x)) {
if (k == label[0]) {
item['id'] = v.id
item[label[1]] = v[label[1]]
} else {
item[this.form.field + '__' + k] = v
}
data() {
return {
new_value: undefined,
field: undefined,
label: undefined,
sticky_options: undefined,
first_run: true,
}
flat_items.push(item)
});
this.first_run = false
return flat_items
},
unflattenItem: function(itemList) {
let unflat_items = []
let item = undefined
let this_label = undefined
let label = this.form.list_label.split('::')
let order = 0
itemList.forEach(x => {
item = {}
item[label[0]] = {}
for (const [k, v] of Object.entries(x)) {
switch(k) {
case 'id':
item[label[0]]['id'] = v
break;
case label[1]:
item[label[0]][label[1]] = v
break;
default:
this_label = k.replace(this.form.field + '__', '')
}
}
item['order'] = order
order++
unflat_items.push(item)
});
return unflat_items
}
}
mounted() {
this.new_value = this.form?.value
this.field = this.form?.field ?? "You Forgot To Set Field Name"
this.label = this.form?.label ?? ""
this.sticky_options = this.form?.sticky_options ?? []
},
computed: {
modelName() {
return this?.model?.name ?? this.$t("Search")
},
useMultiple() {
return this.form?.multiple || this.form?.ordered || false
},
initialSelection() {
let this_value = this.new_value
let arrayValues = undefined
// multiselect is expect to get an array of objects - make sure it gets one
if (Array.isArray(this_value)) {
arrayValues = this_value
} else if (!this_value) {
arrayValues = []
} else if (typeof this_value === "object") {
arrayValues = [this_value]
} else {
arrayValues = [{ id: -1, name: this_value }]
}
if (this.form?.ordered && this.first_run) {
return this.flattenItems(arrayValues)
} else {
return arrayValues
}
},
createPlaceholder() {
return this.$t("Create_New_" + this?.model?.name)
},
},
watch: {
"form.value": function (newVal, oldVal) {
this.new_value = newVal
},
new_value: function () {
let x = this?.new_value
// pass the unflattened attributes that can be restored when ready to save/update
if (this.form?.ordered) {
x["__override__"] = this.unflattenItem(this?.new_value)
}
this.$root.$emit("change", this.form.field, x)
this.$emit("change", x)
},
},
methods: {
addNew: function (e) {
// if create a new item requires more than 1 parameter or the field 'name' is insufficient this will need reworked
// in a perfect world this would trigger a new modal and allow editing all fields
this.genericAPI(this.model, this.Actions.CREATE, { name: e })
.then((result) => {
this.new_value = result.data
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
})
.catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
})
},
// ordered lookups have nested attributes that need flattened attributes to drive lookup
flattenItems: function (itemlist) {
let flat_items = []
let item = undefined
let label = this.form.list_label.split("::")
itemlist.forEach((x) => {
item = {}
for (const [k, v] of Object.entries(x)) {
if (k == label[0]) {
item["id"] = v.id
item[label[1]] = v[label[1]]
} else {
item[this.form.field + "__" + k] = v
}
}
flat_items.push(item)
})
this.first_run = false
return flat_items
},
unflattenItem: function (itemList) {
let unflat_items = []
let item = undefined
let this_label = undefined
let label = this.form.list_label.split("::")
let order = 0
itemList.forEach((x) => {
item = {}
item[label[0]] = {}
for (const [k, v] of Object.entries(x)) {
switch (k) {
case "id":
item[label[0]]["id"] = v
break
case label[1]:
item[label[0]][label[1]] = v
break
default:
this_label = k.replace(this.form.field + "__", "")
}
}
item["order"] = order
order++
unflat_items.push(item)
})
return unflat_items
},
},
}
</script>
</script>

View File

@ -4,10 +4,14 @@
"err_creating_resource": "There was an error creating a resource!",
"err_updating_resource": "There was an error updating a resource!",
"err_deleting_resource": "There was an error deleting a resource!",
"err_moving_resource": "There was an error moving a resource!",
"err_merging_resource": "There was an error merging a resource!",
"success_fetching_resource": "Successfully fetched a resource!",
"success_creating_resource": "Successfully created a resource!",
"success_updating_resource": "Successfully updated a resource!",
"success_deleting_resource": "Successfully deleted a resource!",
"success_moving_resource": "Successfully moved a resource!",
"success_merging_resource": "Successfully merged a resource!",
"file_upload_disabled": "File upload is not enabled for your space.",
"step_time_minutes": "Step time in minutes",
"confirm_delete": "Are you sure you want to delete this {object}?",
@ -207,5 +211,8 @@
"New_Cookbook": "New cookbook",
"Hide_Keyword": "Hide keywords",
"Clear": "Clear",
"err_move_self": "Cannot move item to itself",
"nothing": "Nothing to do",
"err_merge_self": "Cannot merge item with itself",
"show_sql": "Show SQL"
}

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,26 @@
/*
* Utility functions to call bootstrap toasts
* */
import {BToast} from 'bootstrap-vue'
import i18n from "@/i18n";
* Utility functions to call bootstrap toasts
* */
import i18n from "@/i18n"
import { frac } from "@/utils/fractions"
/*
* Utility functions to use OpenAPIs generically
* */
import { ApiApiFactory } from "@/utils/openapi/api.ts"
import axios from "axios"
import { BToast } from "bootstrap-vue"
// /*
// * Utility functions to use manipulate nested components
// * */
import Vue from "vue"
import { Actions, Models } from "./models"
export const ToastMixin = {
methods: {
makeToast: function (title, message, variant = null) {
return makeToast(title, message, variant)
}
}
},
},
}
export function makeToast(title, message, variant = null) {
@ -17,57 +28,71 @@ export function makeToast(title, message, variant = null) {
toaster.$bvToast.toast(message, {
title: title,
variant: variant,
toaster: 'b-toaster-bottom-right',
solid: true
toaster: "b-toaster-bottom-right",
solid: true,
})
}
export class StandardToasts {
static SUCCESS_CREATE = 'SUCCESS_CREATE'
static SUCCESS_FETCH = 'SUCCESS_FETCH'
static SUCCESS_UPDATE = 'SUCCESS_UPDATE'
static SUCCESS_DELETE = 'SUCCESS_DELETE'
static SUCCESS_CREATE = "SUCCESS_CREATE"
static SUCCESS_FETCH = "SUCCESS_FETCH"
static SUCCESS_UPDATE = "SUCCESS_UPDATE"
static SUCCESS_DELETE = "SUCCESS_DELETE"
static SUCCESS_MOVE = "SUCCESS_MOVE"
static SUCCESS_MERGE = "SUCCESS_MERGE"
static FAIL_CREATE = 'FAIL_CREATE'
static FAIL_FETCH = 'FAIL_FETCH'
static FAIL_UPDATE = 'FAIL_UPDATE'
static FAIL_DELETE = 'FAIL_DELETE'
static FAIL_CREATE = "FAIL_CREATE"
static FAIL_FETCH = "FAIL_FETCH"
static FAIL_UPDATE = "FAIL_UPDATE"
static FAIL_DELETE = "FAIL_DELETE"
static FAIL_MOVE = "FAIL_MOVE"
static FAIL_MERGE = "FAIL_MERGE"
static makeStandardToast(toast) {
static makeStandardToast(toast, err_details = undefined) {
switch (toast) {
case StandardToasts.SUCCESS_CREATE:
makeToast(i18n.tc('Success'), i18n.tc('success_creating_resource'), 'success')
break;
makeToast(i18n.tc("Success"), i18n.tc("success_creating_resource"), "success")
break
case StandardToasts.SUCCESS_FETCH:
makeToast(i18n.tc('Success'), i18n.tc('success_fetching_resource'), 'success')
break;
makeToast(i18n.tc("Success"), i18n.tc("success_fetching_resource"), "success")
break
case StandardToasts.SUCCESS_UPDATE:
makeToast(i18n.tc('Success'), i18n.tc('success_updating_resource'), 'success')
break;
makeToast(i18n.tc("Success"), i18n.tc("success_updating_resource"), "success")
break
case StandardToasts.SUCCESS_DELETE:
makeToast(i18n.tc('Success'), i18n.tc('success_deleting_resource'), 'success')
break;
makeToast(i18n.tc("Success"), i18n.tc("success_deleting_resource"), "success")
break
case StandardToasts.SUCCESS_MOVE:
makeToast(i18n.tc("Success"), i18n.tc("success_moving_resource"), "success")
break
case StandardToasts.SUCCESS_MERGE:
makeToast(i18n.tc("Success"), i18n.tc("success_merging_resource"), "success")
break
case StandardToasts.FAIL_CREATE:
makeToast(i18n.tc('Failure'), i18n.tc('err_creating_resource'), 'danger')
break;
makeToast(i18n.tc("Failure"), i18n.tc("err_creating_resource"), "danger")
break
case StandardToasts.FAIL_FETCH:
makeToast(i18n.tc('Failure'), i18n.tc('err_fetching_resource'), 'danger')
break;
makeToast(i18n.tc("Failure"), i18n.tc("err_fetching_resource"), "danger")
break
case StandardToasts.FAIL_UPDATE:
makeToast(i18n.tc('Failure'), i18n.tc('err_updating_resource'), 'danger')
break;
makeToast(i18n.tc("Failure"), i18n.tc("err_updating_resource"), "danger")
break
case StandardToasts.FAIL_DELETE:
makeToast(i18n.tc('Failure'), i18n.tc('err_deleting_resource'), 'danger')
break;
makeToast(i18n.tc("Failure"), i18n.tc("err_deleting_resource"), "danger")
break
case StandardToasts.FAIL_MOVE:
makeToast(i18n.tc("Failure"), i18n.tc("err_moving_resource") + (err_details ? "\n" + err_details : ""), "danger")
break
case StandardToasts.FAIL_MERGE:
makeToast(i18n.tc("Failure"), i18n.tc("err_merging_resource") + (err_details ? "\n" + err_details : ""), "danger")
break
}
}
}
/*
* Utility functions to use djangos gettext
* */
* Utility functions to use djangos gettext
* */
export const GettextMixin = {
methods: {
@ -77,8 +102,8 @@ export const GettextMixin = {
*/
_: function (param) {
return djangoGettext(param)
}
}
},
},
}
export function djangoGettext(param) {
@ -86,8 +111,8 @@ export function djangoGettext(param) {
}
/*
* Utility function to use djangos named urls
* */
* Utility function to use djangos named urls
* */
// uses https://github.com/ierror/django-js-reverse#use-the-urls-in-javascript
export const ResolveUrlMixin = {
@ -99,50 +124,48 @@ export const ResolveUrlMixin = {
*/
resolveDjangoUrl: function (url, params = null) {
return resolveDjangoUrl(url, params)
}
}
},
},
}
export function resolveDjangoUrl(url, params = null) {
if (params == null) {
return window.Urls[url]()
} else if (typeof(params) != "object") {
} else if (typeof params != "object") {
return window.Urls[url](params)
} else if (typeof(params) == "object") {
} else if (typeof params == "object") {
if (params.length === 1) {
return window.Urls[url](params)
} else if (params.length === 2) {
return window.Urls[url](params[0],params[1])
return window.Urls[url](params[0], params[1])
} else if (params.length === 3) {
return window.Urls[url](params[0],params[1],params[2])
return window.Urls[url](params[0], params[1], params[2])
}
}
}
/*
* other utilities
* */
* other utilities
* */
export function getUserPreference(pref) {
if(window.USER_PREF === undefined) {
return undefined;
if (window.USER_PREF === undefined) {
return undefined
}
return window.USER_PREF[pref]
}
import {frac} from "@/utils/fractions";
export function calculateAmount(amount, factor) {
if (getUserPreference('use_fractions')) {
let return_string = ''
let fraction = frac((amount * factor), 10, true)
if (getUserPreference("use_fractions")) {
let return_string = ""
let fraction = frac(amount * factor, 10, true)
if (fraction[0] > 0) {
return_string += fraction[0]
}
if (fraction[1] > 0) {
return_string += ` <sup>${(fraction[1])}</sup>&frasl;<sub>${(fraction[2])}</sub>`
return_string += ` <sup>${fraction[1]}</sup>&frasl;<sub>${fraction[2]}</sub>`
}
return return_string
@ -152,23 +175,23 @@ export function calculateAmount(amount, factor) {
}
export function roundDecimals(num) {
let decimals = ((getUserPreference('user_fractions')) ? getUserPreference('user_fractions') : 2);
return +(Math.round(num + `e+${decimals}`) + `e-${decimals}`);
let decimals = getUserPreference("user_fractions") ? getUserPreference("user_fractions") : 2
return +(Math.round(num + `e+${decimals}`) + `e-${decimals}`)
}
const KILOJOULES_PER_CALORIE = 4.18
export function calculateEnergy(amount, factor) {
if (getUserPreference('use_kj')) {
if (getUserPreference("use_kj")) {
let joules = amount * KILOJOULES_PER_CALORIE
return calculateAmount(joules, factor) + ' kJ'
return calculateAmount(joules, factor) + " kJ"
} else {
return calculateAmount(amount, factor) + ' kcal'
return calculateAmount(amount, factor) + " kcal"
}
}
export function convertEnergyToCalories(amount) {
if (getUserPreference('use_kj')) {
if (getUserPreference("use_kj")) {
return amount / KILOJOULES_PER_CALORIE
} else {
return amount
@ -176,33 +199,25 @@ export function convertEnergyToCalories(amount) {
}
export function energyHeading() {
if (getUserPreference('use_kj')) {
return 'Energy'
if (getUserPreference("use_kj")) {
return "Energy"
} else {
return 'Calories'
return "Calories"
}
}
/*
* Utility functions to use OpenAPIs generically
* */
import {ApiApiFactory} from "@/utils/openapi/api.ts";
import axios from "axios";
axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfCookieName = "csrftoken"
axios.defaults.xsrfHeaderName = "X-CSRFTOKEN"
import { Actions, Models } from './models';
import {RequestArgs} from "@/utils/openapi/base";
export const ApiMixin = {
data() {
return {
Models: Models,
Actions: Actions
Actions: Actions,
}
},
methods: {
genericAPI: function(model, action, options) {
genericAPI: function (model, action, options) {
let setup = getConfig(model, action)
if (setup?.config?.function) {
return specialCases[setup.config.function](action, options, setup)
@ -212,10 +227,10 @@ export const ApiMixin = {
let apiClient = new ApiApiFactory()
return apiClient[func](...parameters)
},
genericGetAPI: function(url, options) {
return axios.get(this.resolveDjangoUrl(url), {'params':options, 'emulateJSON': true})
}
}
genericGetAPI: function (url, options) {
return axios.get(this.resolveDjangoUrl(url), { params: options, emulateJSON: true })
},
},
}
// /*
@ -223,37 +238,37 @@ export const ApiMixin = {
// * */
function formatParam(config, value, options) {
if (config) {
for (const [k, v] of Object.entries(config)) {
switch(k) {
case 'type':
switch(v) {
case 'string':
for (const [k, v] of Object.entries(config)) {
switch (k) {
case "type":
switch (v) {
case "string":
if (Array.isArray(value)) {
let tmpValue = []
value.forEach(x => tmpValue.push(String(x)))
value.forEach((x) => tmpValue.push(String(x)))
value = tmpValue
} else if (value !== undefined) {
value = String(value)
}
break;
case 'integer':
break
case "integer":
if (Array.isArray(value)) {
let tmpValue = []
value.forEach(x => tmpValue.push(parseInt(x)))
value.forEach((x) => tmpValue.push(parseInt(x)))
value = tmpValue
} else if (value !== undefined) {
value = parseInt(value)
}
break;
break
}
break;
case 'function':
break
case "function":
// needs wrapped in a promise and wait for the called function to complete before moving on
specialCases[v](value, options)
break;
break
}
}
}
}
return value
}
function buildParams(options, setup) {
@ -280,60 +295,56 @@ function buildParams(options, setup) {
this_value = getDefault(config?.[item], options)
}
parameters.push(this_value)
});
})
return parameters
}
function getDefault(config, options) {
let value = undefined
value = config?.default ?? undefined
if (typeof(value) === 'object') {
if (typeof value === "object") {
let condition = false
switch(value.function) {
switch (value.function) {
// CONDITIONAL case requires 4 keys:
// - check: which other OPTIONS key to check against
// - operator: what type of operation to perform
// - true: what value to assign when true
// - false: what value to assign when false
case 'CONDITIONAL':
switch(value.operator) {
case 'not_exist':
condition = (
(!options?.[value.check] ?? undefined)
|| options?.[value.check]?.length == 0
)
case "CONDITIONAL":
switch (value.operator) {
case "not_exist":
condition = (!options?.[value.check] ?? undefined) || options?.[value.check]?.length == 0
if (condition) {
value = value.true
} else {
value = value.false
}
break;
break
}
break;
break
}
}
return value
}
export function getConfig(model, action) {
let f = action.function
// if not defined partialUpdate will use params from create
if (f === 'partialUpdate' && !model?.[f]?.params) {
model[f] = {'params': [...['id'], ...model.create.params]}
if (f === "partialUpdate" && !model?.[f]?.params) {
model[f] = { params: [...["id"], ...model.create.params] }
}
let config = {
'name': model.name,
'apiName': model.apiName,
name: model.name,
apiName: model.apiName,
}
// spread operator merges dictionaries - last item in list takes precedence
config = {...config, ...action, ...model.model_type?.[f], ...model?.[f]}
config = { ...config, ...action, ...model.model_type?.[f], ...model?.[f] }
// nested dictionaries are not merged - so merge again on any nested keys
config.config = {...action?.config, ...model.model_type?.[f]?.config, ...model?.[f]?.config}
config.config = { ...action?.config, ...model.model_type?.[f]?.config, ...model?.[f]?.config }
// look in partialUpdate again if necessary
if (f === 'partialUpdate' && Object.keys(config.config).length === 0) {
config.config = {...model.model_type?.create?.config, ...model?.create?.config}
if (f === "partialUpdate" && Object.keys(config.config).length === 0) {
config.config = { ...model.model_type?.create?.config, ...model?.create?.config }
}
config['function'] = f + config.apiName + (config?.suffix ?? '') // parens are required to force optional chaining to evaluate before concat
config["function"] = f + config.apiName + (config?.suffix ?? "") // parens are required to force optional chaining to evaluate before concat
return config
}
@ -342,181 +353,175 @@ export function getConfig(model, action) {
// * */
export function getForm(model, action, item1, item2) {
let f = action.function
let config = {...action?.form, ...model.model_type?.[f]?.form, ...model?.[f]?.form}
// if not defined partialUpdate will use form from create
if (f === 'partialUpdate' && Object.keys(config).length == 0) {
config = {...Actions.CREATE?.form, ...model.model_type?.['create']?.form, ...model?.['create']?.form}
config['title'] = {...action?.form_title, ...model.model_type?.[f]?.form_title, ...model?.[f]?.form_title}
let config = { ...action?.form, ...model.model_type?.[f]?.form, ...model?.[f]?.form }
// if not defined partialUpdate will use form from create
if (f === "partialUpdate" && Object.keys(config).length == 0) {
config = { ...Actions.CREATE?.form, ...model.model_type?.["create"]?.form, ...model?.["create"]?.form }
config["title"] = { ...action?.form_title, ...model.model_type?.[f]?.form_title, ...model?.[f]?.form_title }
}
let form = {'fields': []}
let value = ''
let form = { fields: [] }
let value = ""
for (const [k, v] of Object.entries(config)) {
if (v?.function){
switch(v.function) {
case 'translate':
if (v?.function) {
switch (v.function) {
case "translate":
value = formTranslate(v, model, item1, item2)
}
} else {
value = v
}
if (value?.form_field) {
value['value'] = item1?.[value?.field] ?? undefined
form.fields.push(
{
...value,
...{
'label': formTranslate(value?.label, model, item1, item2),
'placeholder': formTranslate(value?.placeholder, model, item1, item2)
}
}
)
value["value"] = item1?.[value?.field] ?? undefined
form.fields.push({
...value,
...{
label: formTranslate(value?.label, model, item1, item2),
placeholder: formTranslate(value?.placeholder, model, item1, item2),
},
})
} else {
form[k] = value
}
}
return form
}
function formTranslate(translate, model, item1, item2) {
if (typeof(translate) !== 'object') {return translate}
if (typeof translate !== "object") {
return translate
}
let phrase = translate.phrase
let options = {}
let obj = undefined
translate?.params.forEach(function (x, index) {
switch(x.from){
case 'item1':
switch (x.from) {
case "item1":
obj = item1
break;
case 'item2':
break
case "item2":
obj = item2
break;
case 'model':
break
case "model":
obj = model
}
options[x.token] = obj[x.attribute]
})
return i18n.t(phrase, options)
}
// /*
// * Utility functions to use manipulate nested components
// * */
import Vue from 'vue'
export const CardMixin = {
methods: {
findCard: function(id, card_list){
findCard: function (id, card_list) {
let card_length = card_list?.length ?? 0
if (card_length == 0) {
return false
return false
}
let cards = card_list.filter(obj => obj.id == id)
let cards = card_list.filter((obj) => obj.id == id)
if (cards.length == 1) {
return cards[0]
return cards[0]
} else if (cards.length == 0) {
for (const c of card_list.filter(x => x.show_children == true)) {
cards = this.findCard(id, c.children)
if (cards) {
return cards
for (const c of card_list.filter((x) => x.show_children == true)) {
cards = this.findCard(id, c.children)
if (cards) {
return cards
}
}
}
} else {
console.log('something terrible happened')
console.log("something terrible happened")
}
},
destroyCard: function(id, card_list) {
destroyCard: function (id, card_list) {
let card = this.findCard(id, card_list)
let p_id = card?.parent ?? undefined
if (p_id) {
let parent = this.findCard(p_id, card_list)
if (parent){
Vue.set(parent, 'numchild', parent.numchild - 1)
if (parent) {
Vue.set(parent, "numchild", parent.numchild - 1)
if (parent.show_children) {
let idx = parent.children.indexOf(parent.children.find(x => x.id === id))
let idx = parent.children.indexOf(parent.children.find((x) => x.id === id))
Vue.delete(parent.children, idx)
}
}
}
return card_list.filter(x => x.id != id)
},
refreshCard: function(obj, card_list){
return card_list.filter((x) => x.id != id)
},
refreshCard: function (obj, card_list) {
let target = {}
let idx = undefined
target = this.findCard(obj.id, card_list)
if (target) {
idx = card_list.indexOf(card_list.find(x => x.id === target.id))
idx = card_list.indexOf(card_list.find((x) => x.id === target.id))
Vue.set(card_list, idx, obj)
}
if (target?.parent) {
let parent = this.findCard(target.parent, card_list)
if (parent) {
if (parent.show_children){
idx = parent.children.indexOf(parent.children.find(x => x.id === target.id))
if (parent.show_children) {
idx = parent.children.indexOf(parent.children.find((x) => x.id === target.id))
Vue.set(parent.children, idx, obj)
}
}
}
},
}
},
}
const specialCases = {
// the supermarket API requires chaining promises together, instead of trying to make
// this use case generic just treat it as a unique use case
SupermarketWithCategories: function(action, options, setup) {
SupermarketWithCategories: function (action, options, setup) {
let API = undefined
let GenericAPI = ApiMixin.methods.genericAPI
let params = []
if (action.function === 'partialUpdate') {
if (action.function === "partialUpdate") {
API = GenericAPI
params = [Models.SUPERMARKET, Actions.FETCH, {'id': options.id}]
} else if (action.function === 'create') {
params = [Models.SUPERMARKET, Actions.FETCH, { id: options.id }]
} else if (action.function === "create") {
API = new ApiApiFactory()[setup.function]
params = buildParams(options, setup)
}
return API(...params).then((result) => {
// either get the supermarket or create the supermarket (but without the category relations)
return result.data
}).then((result) => {
// delete, update or change all of the category/relations
let id = result.id
let existing_categories = result.category_to_supermarket
let updated_categories = options.category_to_supermarket
let promises = []
// if the 'category.name' key does not exist on the updated_categories, the categories were not updated
if (updated_categories?.[0]?.category?.name) {
// list of category relationship ids that are not part of the updated supermarket
let removed_categories = existing_categories.filter(x => !updated_categories.map(x => x.category.id).includes(x.category.id))
let added_categories = updated_categories.filter(x => !existing_categories.map(x => x.category.id).includes(x.category.id))
let changed_categories = updated_categories.filter(x => existing_categories.map(x => x.category.id).includes(x.category.id))
removed_categories.forEach(x => {
promises.push(GenericAPI(Models.SHOPPING_CATEGORY_RELATION, Actions.DELETE, {'id': x.id}))
})
let item = {'supermarket': id}
added_categories.forEach(x => {
item.order = x.order
item.category = {'id': x.category.id, 'name': x.category.name}
promises.push(GenericAPI(Models.SHOPPING_CATEGORY_RELATION, Actions.CREATE, item))
})
changed_categories.forEach(x => {
item.id = x?.id ?? existing_categories.find(y => y.category.id === x.category.id).id;
item.order = x.order
item.category = {'id': x.category.id, 'name': x.category.name}
promises.push(GenericAPI(Models.SHOPPING_CATEGORY_RELATION, Actions.UPDATE, item))
})
}
return Promise.all(promises).then(() => {
// finally get and return the Supermarket which everything downstream is expecting
return GenericAPI(Models.SUPERMARKET, Actions.FETCH, {'id': id})
return API(...params)
.then((result) => {
// either get the supermarket or create the supermarket (but without the category relations)
return result.data
})
})
}
.then((result) => {
// delete, update or change all of the category/relations
let id = result.id
let existing_categories = result.category_to_supermarket
let updated_categories = options.category_to_supermarket
let promises = []
// if the 'category.name' key does not exist on the updated_categories, the categories were not updated
if (updated_categories?.[0]?.category?.name) {
// list of category relationship ids that are not part of the updated supermarket
let removed_categories = existing_categories.filter((x) => !updated_categories.map((x) => x.category.id).includes(x.category.id))
let added_categories = updated_categories.filter((x) => !existing_categories.map((x) => x.category.id).includes(x.category.id))
let changed_categories = updated_categories.filter((x) => existing_categories.map((x) => x.category.id).includes(x.category.id))
removed_categories.forEach((x) => {
promises.push(GenericAPI(Models.SHOPPING_CATEGORY_RELATION, Actions.DELETE, { id: x.id }))
})
let item = { supermarket: id }
added_categories.forEach((x) => {
item.order = x.order
item.category = { id: x.category.id, name: x.category.name }
promises.push(GenericAPI(Models.SHOPPING_CATEGORY_RELATION, Actions.CREATE, item))
})
changed_categories.forEach((x) => {
item.id = x?.id ?? existing_categories.find((y) => y.category.id === x.category.id).id
item.order = x.order
item.category = { id: x.category.id, name: x.category.name }
promises.push(GenericAPI(Models.SHOPPING_CATEGORY_RELATION, Actions.UPDATE, item))
})
}
return Promise.all(promises).then(() => {
// finally get and return the Supermarket which everything downstream is expecting
return GenericAPI(Models.SUPERMARKET, Actions.FETCH, { id: id })
})
})
},
}