diff --git a/aleksis/core/frontend/components/generic/InlineCRUDList.vue b/aleksis/core/frontend/components/generic/InlineCRUDList.vue index d8ef84608fdc32c3dd08ddc696c14bd81bbf6eef..1a31de5fc285831d43eb713725b292473253d5a3 100644 --- a/aleksis/core/frontend/components/generic/InlineCRUDList.vue +++ b/aleksis/core/frontend/components/generic/InlineCRUDList.vue @@ -133,7 +133,7 @@ gqlCreateMutation: gqlCreateMutation, gqlPatchMutation: gqlPatchMutation, isCreate: true, - fields: headers, + fields: editableHeaders, createItemI18nKey: createItemI18nKey, }' @@ -155,7 +155,7 @@ :gql-create-mutation="gqlCreateMutation" :gql-patch-mutation="gqlPatchMutation" :isCreate="true" - :fields="headers" + :fields="editableHeaders" :create-item-i18n-key="createItemI18nKey" @cancel="cancelCreate" @save="handleCreateDone" @@ -170,7 +170,7 @@ </template> <template - v-for="header in headers" + v-for="header in editableHeaders" #[formFieldSlotName(header)]="{ item, isCreate, on, attrs }" > <slot @@ -204,7 +204,7 @@ v-slot:[tableItemSlotName(header)]="{ item }" > <v-scroll-x-transition mode="out-in"> - <span key="value" v-if="!editMode"> + <span key="value" v-if="!editMode || header.disableEdit"> <slot :name="header.value" :item="item">{{ item[header.value] }}</slot> @@ -229,6 +229,10 @@ </template> <template #item.actions="{ item }"> + <slot + name="actions" + :item="item" + /> <v-btn v-if="'canDelete' in item && item.canDelete" icon @@ -334,6 +338,9 @@ export default { ] : []) .filter((header) => this.hiddenColumns.indexOf(header.value) === -1); }, + editableHeaders() { + return this.headers.filter((header) => !header.disableEdit); + }, elevationClass() { return this.elevated ? "elevation-2" : ""; }, diff --git a/aleksis/core/frontend/components/generic/ObjectCRUDList.vue b/aleksis/core/frontend/components/generic/ObjectCRUDList.vue index 40339e7b6d060f90214acf382c6ce7c75cad3c61..99fd458ab48a9516c5c4727083dba6438c87947e 100644 --- a/aleksis/core/frontend/components/generic/ObjectCRUDList.vue +++ b/aleksis/core/frontend/components/generic/ObjectCRUDList.vue @@ -5,103 +5,133 @@ import DialogObjectForm from "./dialogs/DialogObjectForm.vue"; <template> <v-card> - <template v-if="!items && $apollo.queries.items.loading"> - <slot name="loading"> - <v-skeleton-loader type="card-heading, list-item-avatar-two-line@3, actions" /> - </slot> - </template> - <template v-else> - <v-card-title>{{ title }}</v-card-title> - <v-list v-if="items.length"> - <template v-for="(item, index) in items"> - <v-list-item - :key="item.id" - > - <slot name="listItemContent" :item="item"> + <v-data-iterator + :items="items" + :items-per-page="itemsPerPage" + :loading="$apollo.queries.items.loading" + hide-default-footer + > + <template #loading> + <slot name="loading"> + <v-skeleton-loader type="card-heading, list-item-avatar-two-line@3, actions" /> + </slot> + </template> + + <template #no-data> + <v-card-text>{{ $t(noItemsI18nKey) }}</v-card-text> + </template> + + <template #header> + <v-card-title>{{ title }}</v-card-title> + </template> + + <template #default="props"> + <slot + v-if="items.length" + name="iteratorContent" + :items="props.items" + :editing-enabled="editingEnabled" + :deletion-enabled="deletionEnabled" + :handle-edit="handleEdit" + :handle-delete="handleDelete" + > + <v-list> + <template v-for="(item, index) in items"> + <v-list-item + :key="item.id" + > + <v-list-item-avatar> + <slot name="listIteratorItemAvatar" :item="item" :index="index" /> + </v-list-item-avatar> <v-list-item-content> - <v-list-item-title> - {{ item.name }} - </v-list-item-title> + <slot name="listIteratorItemContent" :item="item" :index="index"> + <v-list-item-title> + {{ item.name }} + </v-list-item-title> + </slot> </v-list-item-content> - </slot> - <v-list-item-action v-if="editingEnabled && item.canEdit"> - <slot name="listItemAction" :on="{ - input: handleEdit, - }"> - <v-btn icon @click="handleEdit(item)"> + <v-list-item-action> + <v-btn v-if="editingEnabled && item.canEdit" icon @click="handleEdit(item)"> <v-icon>mdi-pencil-outline</v-icon> </v-btn> - </slot> - </v-list-item-action> - </v-list-item> - <v-divider - v-if="index < items.length - 1" - :key="index" - inset - ></v-divider> - </template> - </v-list> - <v-card-text v-else>{{ $t(noItemsI18nKey) }}</v-card-text> - <v-card-actions> - <slot - name="createComponent" - :attrs="{ - value: objectFormModel, - defaultItem: defaultItem, - editItem: editItem, - gqlCreateMutation: gqlCreateMutation, - gqlPatchMutation: gqlPatchMutation, - isCreate: isCreate, - fields: fields, - getCreateData: getCreateData, - createItemI18nKey: createItemI18nKey, - }" - :on="{ - input: (i) => (objectFormModel = i), - cancel: () => (objectFormModel = false), - save: handleCreateDone, - error: handleError, - }" - > - <dialog-object-form - v-model="objectFormModel" - :get-create-data="getCreateData" - :get-patch-data="getPatchData" - :default-item="defaultItem" - :edit-item="editItem" - :force-model-item-update="true" - :gql-create-mutation="gqlCreateMutation" - :gql-patch-mutation="gqlPatchMutation" - :is-create="isCreate" - :fields="fields" - :create-item-i18n-key="createItemI18nKey" - @cancel="objectFormModel = false" - @save="handleCreateDone" - @error="handleError" - > - <template #activator="{ props }"> - <create-button - @click="handleCreate" - :disabled="objectFormModel" - /> + <v-btn v-if="deletionEnabled && item.canDelete" icon @click="handleDelete(item)"> + <v-icon>mdi-delete-outline</v-icon> + </v-btn> + </v-list-item-action> + </v-list-item> + <v-divider + v-if="index < items.length - 1" + :key="index" + inset + ></v-divider> </template> + </v-list> + </slot> + </template> - <template - v-for="field in fields" - #[formFieldSlotName(field)]="{ item, isCreate, on, attrs }" + <template #footer> + <v-card-actions> + <slot + v-if="creatingEnabled || editingEnabled" + name="createComponent" + :attrs="{ + value: objectFormModel, + defaultItem: defaultItem, + editItem: editItem, + gqlCreateMutation: gqlCreateMutation, + gqlPatchMutation: gqlPatchMutation, + isCreate: isCreate, + fields: fields, + getCreateData: getCreateData, + createItemI18nKey: createItemI18nKey, + }" + :on="{ + input: (i) => (objectFormModel = i), + cancel: () => (objectFormModel = false), + save: handleCreateDone, + error: handleError, + }" + > + <dialog-object-form + v-model="objectFormModel" + :get-create-data="getCreateData" + :get-patch-data="getPatchData" + :default-item="defaultItem" + :edit-item="editItem" + :force-model-item-update="true" + :gql-create-mutation="gqlCreateMutation" + :gql-patch-mutation="gqlPatchMutation" + :is-create="isCreate" + :fields="fields" + :create-item-i18n-key="createItemI18nKey" + @cancel="objectFormModel = false" + @save="handleCreateDone" + @error="handleError" > - <slot - :name="formFieldSlotName(field)" - :attrs="attrs" - :on="on" - :item="item" - :isCreate="isCreate" - /> - </template> - </dialog-object-form> - </slot> - </v-card-actions> - </template> + <template #activator="{ props }" v-if="creatingEnabled"> + <create-button + @click="handleCreate" + :disabled="objectFormModel" + /> + </template> + + <template + v-for="field in fields" + #[formFieldSlotName(field)]="{ item, isCreate, on, attrs }" + > + <slot + :name="formFieldSlotName(field)" + :attrs="attrs" + :on="on" + :item="item" + :isCreate="isCreate" + /> + </template> + </dialog-object-form> + </slot> + </v-card-actions> + </template> + </v-data-iterator> </v-card> </template> @@ -130,11 +160,13 @@ export default { }, fields: { type: Array, - required: true, + required: false, + default: undefined, }, defaultItem: { type: Object, - required: true, + required: false, + default: undefined, }, getGqlData: { type: Function, @@ -145,9 +177,15 @@ export default { type: Object, required: true, }, + gqlVariables: { + type: Object, + required: false, + default: undefined, + }, gqlCreateMutation: { type: Object, - required: true, + required: false, + default: undefined, }, gqlPatchMutation: { type: Object, @@ -172,6 +210,11 @@ export default { return patchItem; }, }, + itemsPerPage: { + type: Number, + required: false, + default: 5, + } }, components: { CreateButton, @@ -187,6 +230,12 @@ export default { items() { return { query: this.gqlQuery, + variables() { + if (this.gqlVariables) { + return this.gqlVariables; + } + return {}; + }, error: function (error) { this.handleError(error); }, @@ -210,6 +259,9 @@ export default { this.editItem = item; this.isCreate = false; this.objectFormModel = true; + }, + handleDelete() { + }, handleCreateDone() { this.$apollo.queries.items.refetch(); @@ -237,9 +289,19 @@ export default { }, }, computed: { + creatingEnabled() { + return ( + this.gqlCreateMutation && + this.fields && + this.defaultItem + ); + }, editingEnabled() { return ( - this.gqlPatchMutation && this.items && this.items.some((i) => i.canEdit) + this.gqlPatchMutation && + this.fields && + this.items && + this.items.some((i) => i.canEdit) ); }, deletionEnabled() { diff --git a/aleksis/core/frontend/components/generic/buttons/BaseButton.vue b/aleksis/core/frontend/components/generic/buttons/BaseButton.vue index 781525f54f221fedca51bbc74f383ee118c7d2a3..875b01b4105c1223b7011ad203af10f39adb2798 100644 --- a/aleksis/core/frontend/components/generic/buttons/BaseButton.vue +++ b/aleksis/core/frontend/components/generic/buttons/BaseButton.vue @@ -1,5 +1,5 @@ <template> - <v-btn v-bind="{ ...$props, ...$attrs }" @click="$emit('click')"> + <v-btn v-bind="{ ...$props, ...$attrs }" @click="$emit('click', $event)"> <slot> <v-icon v-if="iconText" v-text="iconText" left/> <span v-t="i18nKey"/> diff --git a/aleksis/core/frontend/components/generic/forms/DateTimeField.vue b/aleksis/core/frontend/components/generic/forms/DateTimeField.vue new file mode 100644 index 0000000000000000000000000000000000000000..da046a69b36014968995db908d5190e5f027ca30 --- /dev/null +++ b/aleksis/core/frontend/components/generic/forms/DateTimeField.vue @@ -0,0 +1,115 @@ +<script setup> +import DateField from "./DateField.vue"; +import TimeField from "./TimeField.vue"; +</script> + +<template> + <v-row> + <v-col cols="7"> + <date-field + v-model="date" + v-bind="{ ...$attrs }" + :label="$t('forms.date_time.date')" + :min="minDate" + :max="maxDate" + /> + </v-col> + <v-col cols="5"> + <time-field + v-model="time" + v-bind="{ ...$attrs }" + :label="$t('forms.date_time.time')" + :min="minTime" + :max="maxTime" + /> + </v-col> + </v-row> +</template> + +<script> +export default { + name: "DateTimeField", + data() { + return { + innerDateTime: this.value, + }; + }, + props: { + value: { + type: String, + required: false, + default: undefined, + }, + minDate: { + type: String, + required: false, + default: undefined, + }, + maxDate: { + type: String, + required: false, + default: undefined, + }, + minTime: { + type: String, + required: false, + default: undefined, + }, + maxTime: { + type: String, + required: false, + default: undefined, + }, + }, + computed: { + dateTime: { + get() { + return new Date(this.innerDateTime); + }, + set(value) { + this.innerDateTime = value; + this.$emit("input", value); + }, + }, + date: { + get() { + return this.dateTime.toISOString().split("T")[0]; + }, + set(value) { + let newDateTime = this.dateTime; + const [year, month, day] = value.split("-"); + + newDateTime.setFullYear(year); + newDateTime.setMonth(month - 1); + newDateTime.setDate(day); + + this.dateTime = newDateTime.toISOString(); + }, + }, + time: { + get() { + return `${("0" + this.dateTime.getHours()).slice(-2)}:${( + "0" + this.dateTime.getMinutes() + ).slice(-2)}`; + }, + set(value) { + let newDateTime = this.dateTime; + + const [hours, minutes] = value.split(":"); + + newDateTime.setHours(hours); + newDateTime.setMinutes(minutes); + + this.dateTime = newDateTime.toISOString(); + }, + }, + }, + watch: { + value(newValue) { + this.innerDateTime = newValue; + }, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/generic/forms/TimeField.vue b/aleksis/core/frontend/components/generic/forms/TimeField.vue index 7f5b0f03ca425a30c84962083c1b70f71d373825..e21014739d38fc66b8e4215a403b728dd7b5c52d 100644 --- a/aleksis/core/frontend/components/generic/forms/TimeField.vue +++ b/aleksis/core/frontend/components/generic/forms/TimeField.vue @@ -1,26 +1,114 @@ <template> - <v-text-field - v-bind="$attrs" - v-on="$listeners" - placeholder="HH:MM[:SS]" - :rules="rules" - /> + <v-menu + ref="menu" + v-model="menu" + :close-on-content-click="false" + transition="scale-transition" + offset-y + min-width="290" + eager + > + <template v-slot:activator="{ on, attrs }"> + <v-text-field + v-model="time" + v-bind="{ ...$attrs, ...attrs }" + @click="handleClick" + @focusin="handleFocusIn" + @focusout="handleFocusOut" + @click:clear="handleClickClear" + placeholder="HH:MM[:SS]" + @keydown.esc="menu = false" + @keydown.enter="menu = false" + :prepend-icon="prependIcon" + :rules="rules" + ></v-text-field> + </template> + <v-time-picker + v-model="time" + ref="picker" + :min="min" + :max="max" + full-width + format="24hr" + @click:minute="menu = false" + ></v-time-picker> + </v-menu> </template> <script> - export default { name: "TimeField", + extends: "v-text-field", data() { return { + menu: false, + innerTime: this.value, + openDueToFocus: true, rules: [ - v => !v || /^([01]\d|2[0-3]):([0-5]\d)(:([0-5]\d))?$/.test(v) || this.$t("forms.errors.invalid_time"), - ] - } + (v) => + !v || + /^([01]\d|2[0-3]):([0-5]\d)(:([0-5]\d))?$/.test(v) || + this.$t("forms.errors.invalid_time"), + ], + }; + }, + props: { + value: { + type: String, + required: false, + default: undefined, + }, + min: { + type: String, + required: false, + default: undefined, + }, + max: { + type: String, + required: false, + default: undefined, + }, + prependIcon: { + type: String, + required: false, + default: undefined, + }, + }, + computed: { + time: { + get() { + return this.innerTime; + }, + set(value) { + this.innerTime = value; + this.$emit("input", value); + }, + }, }, -} + methods: { + handleClickClear() { + if (this.clearable) { + this.time = null; + } + }, + handleClick() { + this.menu = true; + this.openDueToFocus = false; + }, + handleFocusIn() { + this.openDueToFocus = true; + this.menu = true; + }, + handleFocusOut() { + if (this.openDueToFocus) this.menu = false; + }, + }, + watch: { + value(newValue) { + this.innerTime = newValue; + }, + }, +}; </script> -<style scoped> - -</style> +<style scoped></style> diff --git a/aleksis/core/frontend/messages/en.json b/aleksis/core/frontend/messages/en.json index 23ffc376f590f14b3aec8c17237926a34cb99d2c..439c9aeb2db072dbdbf8c4d213721efc98cfda78 100644 --- a/aleksis/core/frontend/messages/en.json +++ b/aleksis/core/frontend/messages/en.json @@ -297,6 +297,10 @@ "not_a_whole_number": "Please enter a whole number", "number_too_small": "Please enter a bigger number.", "number_too_big": "Please enter a smaller number." + }, + "date_time": { + "date": "Date", + "time": "Time" } }, "weekdays": { diff --git a/aleksis/core/schema/base.py b/aleksis/core/schema/base.py index c928ed933e85d09657bb4f74a661d5d5bcc571df..8084bf594c62ac19ef759ff393c1a3a75342ca1c 100644 --- a/aleksis/core/schema/base.py +++ b/aleksis/core/schema/base.py @@ -82,9 +82,11 @@ class PermissionBatchPatchMixin: login_required = True @classmethod - def check_permissions(cls, root, info, input): # noqa - # TODO: Check PERMISSIONS (or rules) - return True + def check_permissions(cls, root, info, input): + if info.context.user.has_perms(cls._meta.permissions, root): + return + + raise PermissionDenied() class PermissionBatchDeleteMixin: @@ -92,9 +94,11 @@ class PermissionBatchDeleteMixin: login_required = True @classmethod - def check_permissions(cls, root, info, input): # noqa - # TODO: Check PERMISSIONS (or rules) - return True + def check_permissions(cls, root, info, input): + if info.context.user.has_perms(cls._meta.permissions, root): + return + + raise PermissionDenied() class PermissionPatchMixin: @@ -102,9 +106,11 @@ class PermissionPatchMixin: login_required = True @classmethod - def check_permissions(cls, root, info, input, id, obj): # noqa - # TODO: Check PERMISSIONS (or rules) - return True + def check_permissions(cls, root, info, input, id, obj): + if info.context.user.has_perms(cls._meta.permissions, root): + return + + raise PermissionDenied() class DjangoFilterMixin: