@dejwcake/craftable 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dejwcake/craftable",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "description": "This is a frontend dependency for Craftable. Craftable is a Laravel-based open-source toolkit for building administration interfaces.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -13,9 +13,20 @@
13
13
 
14
14
  <span class="float-end pe-2">
15
15
  <slot>
16
- <button class="btn btn-sm btn-danger px-4" @click="onBulkDelete()">
17
- {{ translations.delete_btn }}
18
- </button>
16
+ <span class="d-inline-flex gap-2">
17
+ <button v-if="onBulkDelete" class="btn btn-sm btn-danger px-4" @click="onBulkDelete()">
18
+ <i class="fa fa-trash me-1"></i>
19
+ {{ translations.delete_btn }}
20
+ </button>
21
+ <button v-if="onBulkForceDelete" class="btn btn-sm btn-dark px-4" @click="onBulkForceDelete()">
22
+ <i class="fa fa-eraser me-1"></i>
23
+ {{ translations.force_delete_btn }}
24
+ </button>
25
+ <button v-if="onBulkRestore" class="btn btn-sm btn-success text-white px-4" @click="onBulkRestore()">
26
+ <i class="fa fa-trash-arrow-up me-1"></i>
27
+ {{ translations.restore_btn }}
28
+ </button>
29
+ </span>
19
30
  </slot>
20
31
  </span>
21
32
  </template>
@@ -27,7 +38,9 @@ defineProps({
27
38
  loading: { type: Boolean, default: false },
28
39
  onCheckAll: { type: Function, required: true },
29
40
  onUncheckAll: { type: Function, required: true },
30
- onBulkDelete: { type: Function, required: true },
41
+ onBulkDelete: { type: Function, default: null },
42
+ onBulkForceDelete: { type: Function, default: null },
43
+ onBulkRestore: { type: Function, default: null },
31
44
  translations: {
32
45
  type: Object,
33
46
  default: () => ({
@@ -35,6 +48,8 @@ defineProps({
35
48
  check_all_items: 'Check all items',
36
49
  uncheck_all_items: 'Uncheck all items',
37
50
  delete_btn: 'Delete',
51
+ force_delete_btn: 'Delete permanently',
52
+ restore_btn: 'Restore',
38
53
  }),
39
54
  },
40
55
  });
@@ -0,0 +1,57 @@
1
+ <template>
2
+ <button
3
+ type="button"
4
+ class="btn btn-sm btn-dark"
5
+ :title="translations.force_delete_btn"
6
+ @click="showConfirm = true"
7
+ >
8
+ <i class="fa fa-eraser"></i>
9
+ </button>
10
+
11
+ <ConfirmModal
12
+ :show="showConfirm"
13
+ variant="danger"
14
+ :translations="translations"
15
+ @confirm="onConfirm"
16
+ @cancel="showConfirm = false"
17
+ />
18
+ </template>
19
+
20
+ <script setup>
21
+ import { ref } from 'vue';
22
+ const axios = window.axios;
23
+ import { notifySuccess, notifyError } from '../../utils/notify.js';
24
+ import ConfirmModal from '../ConfirmModal.vue';
25
+
26
+ const props = defineProps({
27
+ url: { type: String, required: true },
28
+ translations: {
29
+ type: Object,
30
+ default: () => ({
31
+ force_delete_btn: 'Delete permanently',
32
+ confirm_title: 'Deletion',
33
+ confirm_text: 'Delete this item permanently? This cannot be undone.',
34
+ confirm_btn: 'Delete permanently',
35
+ cancel_btn: 'Cancel',
36
+ }),
37
+ },
38
+ });
39
+
40
+ const emit = defineEmits(['deleted']);
41
+
42
+ const showConfirm = ref(false);
43
+
44
+ function onConfirm() {
45
+ showConfirm.value = false;
46
+
47
+ axios.delete(props.url).then(
48
+ (response) => {
49
+ notifySuccess(response.data.message);
50
+ emit('deleted');
51
+ },
52
+ (error) => {
53
+ notifyError(error.response?.data?.message);
54
+ },
55
+ );
56
+ }
57
+ </script>
@@ -0,0 +1,62 @@
1
+ <template>
2
+ <span :title="disabled ? translations.not_restorable : translations.restore_btn">
3
+ <button
4
+ type="button"
5
+ class="btn btn-sm btn-success text-white"
6
+ :aria-label="disabled ? translations.not_restorable : translations.restore_btn"
7
+ :disabled="disabled"
8
+ @click="showConfirm = true"
9
+ >
10
+ <i class="fa fa-trash-arrow-up"></i>
11
+ </button>
12
+ </span>
13
+
14
+ <ConfirmModal
15
+ :show="showConfirm"
16
+ variant="success"
17
+ :translations="translations"
18
+ @confirm="onConfirm"
19
+ @cancel="showConfirm = false"
20
+ />
21
+ </template>
22
+
23
+ <script setup>
24
+ import { ref } from 'vue';
25
+ const axios = window.axios;
26
+ import { notifySuccess, notifyError } from '../../utils/notify.js';
27
+ import ConfirmModal from '../ConfirmModal.vue';
28
+
29
+ const props = defineProps({
30
+ url: { type: String, required: true },
31
+ disabled: { type: Boolean, default: false },
32
+ translations: {
33
+ type: Object,
34
+ default: () => ({
35
+ restore_btn: 'Restore',
36
+ not_restorable: 'This item cannot be restored, an item it belongs to is deleted.',
37
+ confirm_title: 'Restore',
38
+ confirm_text: 'Do you really want to restore this item?',
39
+ confirm_btn: 'Restore',
40
+ cancel_btn: 'Cancel',
41
+ }),
42
+ },
43
+ });
44
+
45
+ const emit = defineEmits(['restored']);
46
+
47
+ const showConfirm = ref(false);
48
+
49
+ function onConfirm() {
50
+ showConfirm.value = false;
51
+
52
+ axios.post(props.url).then(
53
+ (response) => {
54
+ notifySuccess(response.data.message);
55
+ emit('restored');
56
+ },
57
+ (error) => {
58
+ notifyError(error.response?.data?.message);
59
+ },
60
+ );
61
+ }
62
+ </script>
@@ -0,0 +1,28 @@
1
+ <template>
2
+ <label class="d-inline-flex align-items-center gap-2 mb-0 text-nowrap text-reset">
3
+ <span class="form-check form-switch form-switch-inline mb-0">
4
+ <input
5
+ class="form-check-input"
6
+ type="checkbox"
7
+ role="switch"
8
+ :checked="modelValue"
9
+ @change="$emit('update:modelValue', $event.target.checked)"
10
+ />
11
+ </span>
12
+ {{ translations.show_deleted }}
13
+ </label>
14
+ </template>
15
+
16
+ <script setup>
17
+ defineProps({
18
+ modelValue: { type: Boolean, default: false },
19
+ translations: {
20
+ type: Object,
21
+ default: () => ({
22
+ show_deleted: 'Show deleted',
23
+ }),
24
+ },
25
+ });
26
+
27
+ defineEmits(['update:modelValue']);
28
+ </script>
@@ -32,15 +32,18 @@ export function useBaseListing(props) {
32
32
  const bulkItems = reactive({});
33
33
  const bulkCheckingAllLoader = ref(false);
34
34
 
35
+ const defaultConfirmTranslations = {
36
+ confirm_title: 'Warning!',
37
+ confirm_text: 'Are you sure?',
38
+ confirm_btn: 'Yes, confirm',
39
+ cancel_btn: 'Cancel',
40
+ };
41
+
35
42
  const confirmModal = reactive({
36
43
  show: false,
37
44
  onConfirm: null,
38
- translations: {
39
- confirm_title: 'Warning!',
40
- confirm_text: 'Are you sure?',
41
- confirm_btn: 'Yes, confirm',
42
- cancel_btn: 'Cancel',
43
- },
45
+ variant: 'danger',
46
+ translations: { ...defaultConfirmTranslations },
44
47
  });
45
48
 
46
49
  // Update "now" every second
@@ -152,13 +155,14 @@ export function useBaseListing(props) {
152
155
  }
153
156
  }
154
157
 
155
- function bulkDelete(url, trans = {}) {
156
- const itemsToDelete = Object.keys(bulkItems).filter((key) => bulkItems[key]);
158
+ function bulkAction(url, trans = {}, variant = 'danger') {
159
+ const selectedItems = Object.keys(bulkItems).filter((key) => bulkItems[key]);
157
160
 
161
+ confirmModal.variant = variant;
158
162
  confirmModal.translations = {
159
- ...confirmModal.translations,
163
+ ...defaultConfirmTranslations,
160
164
  ...trans,
161
- confirm_text: (trans.confirm_text || 'Do you really want to delete :number selected items?').replace(
165
+ confirm_text: (trans.confirm_text || defaultConfirmTranslations.confirm_text).replace(
162
166
  ':number',
163
167
  clickedBulkItemsCount.value,
164
168
  ),
@@ -167,7 +171,7 @@ export function useBaseListing(props) {
167
171
  confirmModal.show = false;
168
172
  axios
169
173
  .post(url, {
170
- ids: itemsToDelete,
174
+ ids: selectedItems,
171
175
  })
172
176
  .then((response) => {
173
177
  Object.keys(bulkItems).forEach((key) => delete bulkItems[key]);
@@ -181,6 +185,34 @@ export function useBaseListing(props) {
181
185
  confirmModal.show = true;
182
186
  }
183
187
 
188
+ function bulkDelete(url, trans = {}) {
189
+ bulkAction(url, {
190
+ ...trans,
191
+ confirm_text: trans.confirm_text || 'Do you really want to delete :number selected items?',
192
+ });
193
+ }
194
+
195
+ function bulkForceDelete(url, trans = {}) {
196
+ bulkAction(url, {
197
+ confirm_btn: 'Delete permanently',
198
+ ...trans,
199
+ confirm_text: trans.confirm_text || 'Delete :number selected items permanently? This cannot be undone.',
200
+ });
201
+ }
202
+
203
+ function bulkRestore(url, trans = {}) {
204
+ bulkAction(
205
+ url,
206
+ {
207
+ confirm_title: 'Restore',
208
+ confirm_btn: 'Restore',
209
+ ...trans,
210
+ confirm_text: trans.confirm_text || 'Do you really want to restore :number selected items?',
211
+ },
212
+ 'success',
213
+ );
214
+ }
215
+
184
216
  function loadData(resetCurrentPage) {
185
217
  const options = {
186
218
  params: {
@@ -298,7 +330,10 @@ export function useBaseListing(props) {
298
330
  onBulkItemsClickedAll,
299
331
  onBulkItemsClickedAllWithPagination,
300
332
  onBulkItemsClickedAllUncheck,
333
+ bulkAction,
301
334
  bulkDelete,
335
+ bulkForceDelete,
336
+ bulkRestore,
302
337
  loadData,
303
338
  filter,
304
339
  resolveUrl,
package/src/index.js CHANGED
@@ -25,6 +25,9 @@ export { default as ListingHeader } from './components/listing/ListingHeader.vue
25
25
  export { default as ShowButton } from './components/listing/ShowButton.vue';
26
26
  export { default as EditButton } from './components/listing/EditButton.vue';
27
27
  export { default as DeleteButton } from './components/listing/DeleteButton.vue';
28
+ export { default as ForceDeleteButton } from './components/listing/ForceDeleteButton.vue';
29
+ export { default as RestoreButton } from './components/listing/RestoreButton.vue';
30
+ export { default as ShowDeleted } from './components/listing/ShowDeleted.vue';
28
31
  export { default as BackButton } from './components/BackButton.vue';
29
32
  export { default as ConfirmModal } from './components/ConfirmModal.vue';
30
33
  export { default as ToggleSwitch } from './components/listing/ToggleSwitch.vue';
@@ -61,7 +64,14 @@ export { default as ActivationError } from './auth/ActivationError.vue';
61
64
  export { default as TranslationListing } from './translation/TranslationListing.vue';
62
65
 
63
66
  // Utilities
64
- export { formatDate, formatDatetime, formatTime, nowInTimezone, dayjs } from './utils/dateFormatters.js';
67
+ export {
68
+ formatDate,
69
+ formatDatetime,
70
+ formatTime,
71
+ formatDuration,
72
+ nowInTimezone,
73
+ dayjs,
74
+ } from './utils/dateFormatters.js';
65
75
  export { mediaCollectionProp, mediaCollectionDefaults } from './utils/mediaProps.js';
66
76
  export { notifySuccess, notifyError } from './utils/notify.js';
67
77
  export { getDateFnsLocale, initDateFnsLocale } from './utils/dateFnsLocale.js';
@@ -23,6 +23,47 @@ export function formatTime(time, format = 'HH:mm') {
23
23
  return d.isValid() ? d.format(format) : '';
24
24
  }
25
25
 
26
+ const SECONDS_IN_MINUTE = 60;
27
+ const SECONDS_IN_HOUR = 3600;
28
+ const SECONDS_IN_DAY = 86400;
29
+
30
+ /**
31
+ * Formats a number of seconds as a compact duration, e.g. `359d 19h 1m 39s`.
32
+ *
33
+ * Zero-valued units are dropped, so an exact hour reads `2h` rather than `0d 2h 0m 0s`, and a
34
+ * duration of zero reads `0s`. `null`, `undefined`, negative and non-finite values (`NaN`,
35
+ * `Infinity`) return the placeholder, which lets a listing cell or detail row render a missing or
36
+ * invalid value without a guard at the call site.
37
+ *
38
+ * Deliberately plain arithmetic rather than dayjs's duration plugin: that plugin normalises a
39
+ * duration into years and months, so its `hours()` is the component of a year/month/day breakdown
40
+ * and does not line up with a total day count. Mixing the two reports 31,086,099 s as
41
+ * `359d 5h 1m 39s` instead of `359d 19h 1m 39s`.
42
+ *
43
+ * @param {number|null|undefined} seconds total seconds; fractions are floored.
44
+ * @param {string} [placeholder] returned for `null`, `undefined`, negative and non-finite values.
45
+ * @returns {string}
46
+ */
47
+ export function formatDuration(seconds, placeholder = '—') {
48
+ if (seconds === null || seconds === undefined) {
49
+ return placeholder;
50
+ }
51
+
52
+ const total = Math.floor(seconds);
53
+ if (!Number.isFinite(total) || total < 0) {
54
+ return placeholder;
55
+ }
56
+
57
+ const parts = [
58
+ [Math.floor(total / SECONDS_IN_DAY), 'd'],
59
+ [Math.floor((total % SECONDS_IN_DAY) / SECONDS_IN_HOUR), 'h'],
60
+ [Math.floor((total % SECONDS_IN_HOUR) / SECONDS_IN_MINUTE), 'm'],
61
+ [total % SECONDS_IN_MINUTE, 's'],
62
+ ].filter(([amount]) => amount > 0);
63
+
64
+ return parts.length === 0 ? '0s' : parts.map(([amount, unit]) => `${amount}${unit}`).join(' ');
65
+ }
66
+
26
67
  export function nowInTimezone(tz = 'UTC', format = 'YYYY-MM-DD HH:mm:ss') {
27
68
  return dayjs().tz(tz).format(format);
28
69
  }