quasar-ui-danx 0.0.46 → 0.0.48

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": "quasar-ui-danx",
3
- "version": "0.0.46",
3
+ "version": "0.0.48",
4
4
  "author": "Dan <dan@flytedesk.com>",
5
5
  "description": "DanX Vue / Quasar component library",
6
6
  "license": "MIT",
@@ -4,16 +4,15 @@
4
4
  :items="activeActions"
5
5
  :disabled="!hasTarget"
6
6
  :tooltip="!hasTarget ? tooltip : null"
7
- :loading="loading"
7
+ :loading="isSaving || loading"
8
8
  :loading-component="loadingComponent"
9
- @action-item="$emit('action', $event)"
9
+ @action-item="onAction"
10
10
  />
11
11
  </template>
12
12
  <script setup>
13
- import { computed } from 'vue';
13
+ import { computed, ref } from 'vue';
14
14
  import { PopoverMenu } from '../Utility';
15
15
 
16
- const emit = defineEmits(['action']);
17
16
  const props = defineProps({
18
17
  actions: {
19
18
  type: Array,
@@ -43,4 +42,14 @@ const activeActions = computed(() => props.actions.filter(action => {
43
42
 
44
43
  return action.enabled ? action.enabled(props.target) : true;
45
44
  }));
45
+
46
+ const isSaving = ref(false);
47
+ async function onAction(action) {
48
+ if (!action.trigger) {
49
+ throw new Error('Action must have a trigger function! Make sure you are using useActions() or implement your own trigger function.');
50
+ }
51
+ isSaving.value = true;
52
+ await action.trigger(props.target);
53
+ isSaving.value = false;
54
+ }
46
55
  </script>
@@ -51,10 +51,8 @@
51
51
  <ActionTableColumn
52
52
  :row-props="rowProps"
53
53
  :settings="columnSettings[rowProps.col.name]"
54
- :is-saving="isSavingRow(rowProps.row)"
55
- @action="$emit('action', $event, rowProps.row)"
56
54
  >
57
- <slot />
55
+ <slot :column-name="rowProps.col.name" :row="rowProps.row" :value="rowProps.value" />
58
56
  </ActionTableColumn>
59
57
  </template>
60
58
  <template #bottom>
@@ -71,7 +69,7 @@ import { HandleDraggable } from '../DragAndDrop';
71
69
  import { ActionVnode, mapSortBy } from '../index';
72
70
  import { ActionTableColumn, EmptyTableState, registerStickyScrolling, TableSummaryRow } from './index';
73
71
 
74
- defineEmits(['action', 'update:quasar-pagination', 'update:selected-rows']);
72
+ defineEmits(['update:quasar-pagination', 'update:selected-rows']);
75
73
  const props = defineProps({
76
74
  name: {
77
75
  type: String,
@@ -89,10 +87,6 @@ const props = defineProps({
89
87
  type: Object,
90
88
  required: true
91
89
  },
92
- isSavingTarget: {
93
- type: Object,
94
- default: null
95
- },
96
90
  isLoadingList: Boolean,
97
91
  pagedItems: {
98
92
  type: Object,
@@ -126,15 +120,6 @@ function onResizeColumn(column, val) {
126
120
  };
127
121
  setItem(COLUMN_SETTINGS_KEY, columnSettings.value);
128
122
  }
129
-
130
- function isSavingRow(row) {
131
- if (!props.isSavingTarget) return false;
132
-
133
- if (Array.isArray(props.isSavingTarget)) {
134
- return !!props.isSavingTarget.find(t => t.id === row.id);
135
- }
136
- return props.isSavingTarget.id === row.id;
137
- }
138
123
  </script>
139
124
 
140
125
  <style lang="scss" scoped>
@@ -9,29 +9,18 @@
9
9
  class="flex-grow"
10
10
  @click="column.onClick(row)"
11
11
  >
12
- <RenderVnode
13
- v-if="column.vnode"
14
- :vnode="column.vnode(row)"
15
- />
16
- <slot v-else v-bind="{name: column.name, row, value}">
17
- {{ value }}
18
- </slot>
12
+ <RenderVnode v-if="column.vnode" :vnode="column.vnode(row)" />
13
+ <slot v-else>{{ value }}</slot>
19
14
  </a>
20
15
  <div v-else class="flex-grow">
21
- <RenderVnode
22
- v-if="column.vnode"
23
- :vnode="column.vnode(row)"
24
- />
25
- <slot v-else v-bind="{name: column.name, row, value}">
26
- {{ value }}
27
- </slot>
16
+ <RenderVnode v-if="column.vnode" :vnode="column.vnode(row)" />
17
+ <slot v-else>{{ value }}</slot>
28
18
  </div>
29
- <div v-if="column.actions" class="flex flex-shrink-0 pl-2">
19
+ <div v-if="column.actionMenu" class="flex flex-shrink-0 pl-2">
30
20
  <ActionMenu
31
- :actions="column.actions"
21
+ :actions="column.actionMenu"
32
22
  :target="row"
33
23
  :loading="isSaving"
34
- @action="$emit('action', $event)"
35
24
  />
36
25
  </div>
37
26
  </div>
@@ -42,7 +31,6 @@ import { computed } from 'vue';
42
31
  import { RenderVnode } from '../Utility';
43
32
  import { ActionMenu } from './index';
44
33
 
45
- defineEmits(['action']);
46
34
  const props = defineProps({
47
35
  rowProps: {
48
36
  type: Object,
@@ -51,13 +39,13 @@ const props = defineProps({
51
39
  settings: {
52
40
  type: Object,
53
41
  default: null
54
- },
55
- isSaving: Boolean
42
+ }
56
43
  });
57
44
 
58
45
  const row = computed(() => props.rowProps.row);
59
46
  const column = computed(() => props.rowProps.col);
60
47
  const value = computed(() => props.rowProps.value);
48
+ const isSaving = computed(() => column.value.isSaving && column.value.isSaving(row.value));
61
49
 
62
50
  const columnStyle = computed(() => {
63
51
  const width = props.settings?.width || column.value.width;
@@ -65,6 +53,7 @@ const columnStyle = computed(() => {
65
53
  });
66
54
 
67
55
  const columnClass = computed(() => ({
56
+ 'is-saving': isSaving.value,
68
57
  'justify-end': column.value.align === 'right',
69
58
  'justify-center': column.value.align === 'center',
70
59
  'justify-start': column.value.align === 'left'
@@ -0,0 +1,39 @@
1
+ <template>
2
+ <div
3
+ class="flex items-center w-full"
4
+ :class="{'cursor-not-allowed': locked}"
5
+ >
6
+ <a v-if="locked" class="text-neutral-on-plus-3 cursor-not-allowed">
7
+ <LockedIcon class="w-4" />
8
+ </a>
9
+ <div class="font-semibold text-sm ml-5 py-3 flex-grow">{{ column.label }}</div>
10
+ <div v-if="!locked" class="flex items-center">
11
+ <a class="py-2 px-1" @click="$emit('visible', !visible)">
12
+ <VisibleIcon v-if="visible" class="w-4" />
13
+ <HiddenIcon v-else class="w-4 text-neutral-on-plus-3" />
14
+ </a>
15
+ <a class="py-2 px-1" @click="$emit('is-title', !isTitle)">
16
+ <IsTitleIcon class="w-4" :class="isTitle ? '' : 'text-neutral-plus-3'" />
17
+ <QTooltip>
18
+ <template v-if="!isTitle">Add to priority list</template>
19
+ <template v-else>Remove from priority list</template>
20
+ </QTooltip>
21
+ </a>
22
+ </div>
23
+ </div>
24
+ </template>
25
+ <script setup>
26
+ import { EyeIcon as VisibleIcon, EyeOffIcon as HiddenIcon, LockClosedIcon as LockedIcon } from "@heroicons/vue/outline";
27
+ import { StarIcon as IsTitleIcon } from "@heroicons/vue/solid";
28
+
29
+ defineEmits(["visible", "is-title"]);
30
+ defineProps({
31
+ locked: Boolean,
32
+ visible: Boolean,
33
+ isTitle: Boolean,
34
+ column: {
35
+ type: Object,
36
+ required: true
37
+ }
38
+ });
39
+ </script>
@@ -0,0 +1,102 @@
1
+ <template>
2
+ <InfoDialog
3
+ title="Column Settings"
4
+ @close="$emit('close')"
5
+ >
6
+ <div class="mb-4 text-sm">
7
+ Customize columns by visibility, order, or priority (maximum 3 additional).
8
+ </div>
9
+ <ColumnListItem
10
+ v-for="column in lockedColumns" :key="column.name" locked visible :column="column"
11
+ class="px-2.5 border border-neutral-plus-5 bg-white rounded-t-lg"
12
+ />
13
+ <ListTransition
14
+ name="fade-down-list"
15
+ data-drop-zone="column-list"
16
+ >
17
+ <ListItemDraggable
18
+ v-for="(column, index) in sortableColumns"
19
+ :key="column.name"
20
+ :list-items="sortableColumns"
21
+ drop-zone="column-list"
22
+ class="px-2 border border-neutral-plus-5 bg-white"
23
+ :class="{'rounded-b-lg': index === sortableColumns.length - 1}"
24
+ show-handle
25
+ @update:list-items="$emit('update:sortable-columns', $event)"
26
+ >
27
+ <ColumnListItem
28
+ :column="column"
29
+ :visible="isVisible(column)"
30
+ :is-title="isTitleColumn(column)"
31
+ @visible="onVisibilityChange(column, $event)"
32
+ @is-title="onTitleColumnChange(column, $event)"
33
+ />
34
+ </ListItemDraggable>
35
+ </ListTransition>
36
+ </InfoDialog>
37
+ </template>
38
+ <script setup>
39
+ import { computed } from 'vue';
40
+ import { FlashMessages, remove } from '../../../helpers';
41
+ import { ListItemDraggable } from '../../DragAndDrop';
42
+ import { InfoDialog, ListTransition } from '../../Utility';
43
+ import { ColumnListItem } from './index';
44
+
45
+ const emit = defineEmits(['close', 'update:hidden-column-names', 'update:title-column-names', 'update:sortable-columns']);
46
+ const props = defineProps({
47
+ hiddenColumnNames: {
48
+ type: Array,
49
+ required: true
50
+ },
51
+ titleColumnNames: {
52
+ type: Array,
53
+ required: true
54
+ },
55
+ lockedColumns: {
56
+ type: Array,
57
+ required: true
58
+ },
59
+ sortableColumns: {
60
+ type: Array,
61
+ required: true
62
+ },
63
+ titleColumnLimit: {
64
+ type: Number,
65
+ default: 3
66
+ }
67
+ });
68
+
69
+ const allowMoreTitleColumns = computed(() => {
70
+ return props.titleColumnNames.length < props.titleColumnLimit;
71
+ });
72
+ function isVisible(column) {
73
+ return !props.hiddenColumnNames.includes(column.name);
74
+ }
75
+ function onVisibilityChange(column, visible) {
76
+ let hiddenColumnNames = [...props.hiddenColumnNames];
77
+
78
+ if (visible && hiddenColumnNames.includes(column.name)) {
79
+ hiddenColumnNames = remove(hiddenColumnNames, column.name);
80
+ } else {
81
+ hiddenColumnNames.push(column.name);
82
+ }
83
+ emit('update:hidden-column-names', [...new Set(hiddenColumnNames)]);
84
+ }
85
+
86
+ function isTitleColumn(column) {
87
+ return props.titleColumnNames.includes(column.name);
88
+ }
89
+ function onTitleColumnChange(column, isTitle) {
90
+ let titleColumnNames = [...props.titleColumnNames];
91
+ if (isTitle && !titleColumnNames.includes(column.name)) {
92
+ if (!allowMoreTitleColumns.value) {
93
+ FlashMessages.warning(`You can only have ${props.titleColumnLimit} priority columns.`);
94
+ return;
95
+ }
96
+ titleColumnNames.push(column.name);
97
+ } else {
98
+ titleColumnNames = remove(titleColumnNames, column.name);
99
+ }
100
+ emit('update:title-column-names', [...new Set(titleColumnNames)]);
101
+ }
102
+ </script>
@@ -0,0 +1,28 @@
1
+ <template>
2
+ <div>
3
+ <div
4
+ v-for="column in columns" :key="column.name"
5
+ class="overflow-hidden overflow-ellipsis text-xs text-gray-base"
6
+ >{{ format(row[column.name], column.format) }}
7
+ </div>
8
+ </div>
9
+ </template>
10
+ <script setup>
11
+ defineProps({
12
+ row: {
13
+ type: Object,
14
+ required: true
15
+ },
16
+ columns: {
17
+ type: Array,
18
+ required: true
19
+ }
20
+ });
21
+
22
+ function format(value, format) {
23
+ if (typeof format === 'function') {
24
+ return format(value);
25
+ }
26
+ return value;
27
+ }
28
+ </script>
@@ -0,0 +1,156 @@
1
+ <template>
2
+ <div class="flex items-center flex-nowrap">
3
+ <div
4
+ v-for="category in categories"
5
+ :key="category"
6
+ class="category-toggle"
7
+ :class="{'has-visible-columns': categoryHasVisibleColumns(category)}"
8
+ >
9
+ <QCheckbox
10
+ toggle-indeterminate
11
+ size="20px"
12
+ :model-value="getCategoryCheckboxState(category)"
13
+ class="mr-2 cb-white-border"
14
+ @click="toggleColumns(columnsInCategory(category), !categoryHasVisibleColumns(category))"
15
+ />
16
+ <div>
17
+ {{ category }}
18
+ </div>
19
+ <CaretDownIcon
20
+ class="ml-2 w-5 transition-all"
21
+ :class="{'rotate-180' : isShowingColumnToggle === category}"
22
+ />
23
+ <QMenu
24
+ @update:model-value="isShowingColumnToggle = $event ? category : ''"
25
+ >
26
+ <QList>
27
+ <div
28
+ v-for="column in columnsInCategory(category)"
29
+ :key="column"
30
+ class="flex items-center flex-nowrap px-2 py-3 cursor-pointer"
31
+ @click="toggleColumn(column.name)"
32
+ >
33
+ <QCheckbox
34
+ :model-value="!hiddenColumnNames.includes(column.name)"
35
+ class="mr-3 cb-white-border"
36
+ size="20px"
37
+ :color="column.required ? 'gray-base': 'blue-base'"
38
+ :disable="column.required"
39
+ @click="toggleColumn(column.name)"
40
+ />
41
+ <div class="text-xs">{{ column.label }}</div>
42
+ </div>
43
+ </QList>
44
+ </QMenu>
45
+ </div>
46
+ </div>
47
+ </template>
48
+ <script setup>
49
+ import { computed, ref } from 'vue';
50
+ import { remove } from '../../../helpers';
51
+ import { CaretDownIcon } from '../../../svg';
52
+
53
+ const emit = defineEmits(['update:hidden-column-names']);
54
+ const props = defineProps({
55
+ columns: {
56
+ type: Array,
57
+ required: true
58
+ },
59
+ hiddenColumnNames: {
60
+ type: Array,
61
+ required: true
62
+ }
63
+ });
64
+
65
+ const isShowingColumnToggle = ref('');
66
+ const categories = computed(() => [...new Set(props.columns.map(c => c.category)).values()]);
67
+
68
+ /**
69
+ * Return a list of column names that belong to the category
70
+ * @param category
71
+ * @returns {(string|*)[]}
72
+ */
73
+ function columnsInCategory(category) {
74
+ return props.columns.filter(c => c.category === category);
75
+ }
76
+
77
+ /**
78
+ * Return true if any columns in the category are visible
79
+ * @param category
80
+ * @returns {boolean}
81
+ */
82
+ function categoryHasVisibleColumns(category) {
83
+ // If there are any columns in the category that are not hidden, then the category has visible columns
84
+ return columnsInCategory(category).filter(c => !c.required).map(c => c.name).some(c => !props.hiddenColumnNames.includes(c));
85
+ }
86
+
87
+ /**
88
+ * Determines the state of the checkbox as either true, false or null (for the indeterminate state)
89
+ * @param category
90
+ * @returns {boolean|null}
91
+ */
92
+ function getCategoryCheckboxState(category) {
93
+ let categoryColumns = columnsInCategory(category).filter(c => !c.required);
94
+ const visibleColumns = categoryColumns.filter(c => !props.hiddenColumnNames.includes(c.name));
95
+ if (visibleColumns.length === 0) {
96
+ return false;
97
+ } else if (visibleColumns.length === categoryColumns.length) {
98
+ return true;
99
+ }
100
+ return null;
101
+ }
102
+ /**
103
+ * Toggle all columns in a category
104
+ * @param columns
105
+ * @param showColumns
106
+ */
107
+ function toggleColumns(columns, showColumns) {
108
+ // Ignore required columns
109
+ columns = columns.filter(c => !c.required);
110
+
111
+ let hiddenColumnNames = [...props.hiddenColumnNames];
112
+ if (showColumns) {
113
+ hiddenColumnNames = hiddenColumnNames.filter(c => !columns.map(c => c.name).includes(c));
114
+ } else {
115
+ hiddenColumnNames = [...new Set([...hiddenColumnNames, ...columns.map(c => c.name)])];
116
+ }
117
+ emit('update:hidden-column-names', hiddenColumnNames);
118
+ }
119
+
120
+ /**
121
+ * Toggle a single column
122
+ * @param columnName
123
+ * @param showColumn
124
+ */
125
+ function toggleColumn(columnName, showColumn) {
126
+ // Do not allow toggling required columns
127
+ if (props.columns.find(c => c.name === columnName).required) return;
128
+
129
+ // Determine weather to add (hide) or remove (show) the column
130
+ showColumn = showColumn ?? props.hiddenColumnNames.includes(columnName);
131
+
132
+ let hiddenColumnNames = [...props.hiddenColumnNames];
133
+
134
+ // Add or remove the column from the hiddenColumnNames array
135
+ if (showColumn) {
136
+ hiddenColumnNames = remove(hiddenColumnNames, columnName);
137
+ } else {
138
+ hiddenColumnNames.push(columnName);
139
+ hiddenColumnNames = [...new Set(hiddenColumnNames)];
140
+ }
141
+
142
+ emit('update:hidden-column-names', hiddenColumnNames);
143
+ }
144
+ </script>
145
+ <style
146
+ lang="scss"
147
+ scoped
148
+ >
149
+ .category-toggle {
150
+ @apply text-xs font-bold rounded-lg border border-solid border-neutral-plus-5 px-2 py-1 mx-1 cursor-pointer flex items-center;
151
+
152
+ &.has-visible-columns {
153
+ @apply text-white bg-blue-base;
154
+ }
155
+ }
156
+ </style>
@@ -0,0 +1,4 @@
1
+ export { default as ColumnListItem } from "./ColumnListItem.vue";
2
+ export { default as ColumnSettingsDialog } from "./ColumnSettingsDialog.vue";
3
+ export { default as TitleColumnFormat } from "./TItleColumnFormat.vue";
4
+ export { default as VisibleColumnsToggleButtons } from "./VisibleColumnsToggleButtons.vue";
@@ -20,11 +20,12 @@
20
20
  </template>
21
21
 
22
22
  <script setup>
23
+ import { useDebounceFn } from '@vueuse/core';
23
24
  import { computed, nextTick, ref, watch } from 'vue';
24
25
  import { fNumber } from '../../../../helpers';
25
26
  import FieldLabel from './FieldLabel';
26
27
 
27
- const emit = defineEmits(['update:model-value']);
28
+ const emit = defineEmits(['update:model-value', 'update']);
28
29
  const props = defineProps({
29
30
  modelValue: {
30
31
  type: [String, Number],
@@ -46,6 +47,10 @@ const props = defineProps({
46
47
  type: String,
47
48
  default: ''
48
49
  },
50
+ delay: {
51
+ type: Number,
52
+ default: 1000
53
+ },
49
54
  hidePrependLabel: Boolean,
50
55
  currency: Boolean,
51
56
  showName: Boolean
@@ -73,6 +78,9 @@ function format(number) {
73
78
  }
74
79
  return fNumber(number, options);
75
80
  }
81
+
82
+ const onUpdateDebounced = useDebounceFn((val) => emit('update', val), props.delay);
83
+
76
84
  function onInput(value) {
77
85
  let number = '';
78
86
 
@@ -89,6 +97,11 @@ function onInput(value) {
89
97
  number = Number(value);
90
98
  numberVal.value = format(number);
91
99
  }
92
- emit('update:model-value', number === '' ? undefined : number);
100
+
101
+ number = number === '' ? undefined : number;
102
+ emit('update:model-value', number);
103
+
104
+ // Delay the change event, so we only see the value after the user has finished
105
+ onUpdateDebounced(number);
93
106
  }
94
107
  </script>
@@ -41,13 +41,13 @@
41
41
  :key="'selected-' + chipOption.label"
42
42
  class="!mr-1"
43
43
  >{{ chipOption.label }}
44
- </q-chip>
45
- <q-chip
46
- v-if="selectedOptions.length > chipOptions.length"
47
- class="!mr-1"
48
- >
49
- +{{ selectedOptions.length - chipOptions.length }}
50
- </q-chip>
44
+ </q-chip>
45
+ <q-chip
46
+ v-if="selectedOptions.length > chipOptions.length"
47
+ class="!mr-1"
48
+ >
49
+ +{{ selectedOptions.length - chipOptions.length }}
50
+ </q-chip>
51
51
  </template>
52
52
  <template v-else>
53
53
  {{ placeholder }}
@@ -59,7 +59,7 @@
59
59
  >{{ selectedLabel }}
60
60
  </div>
61
61
  </template>
62
- </q-select>
62
+ </q-select>
63
63
  </div>
64
64
  </template>
65
65
  <script setup>
@@ -257,7 +257,11 @@ function onUpdate(value) {
257
257
  if (Array.isArray(value)) {
258
258
  value = value.map((v) => v === '__null__' ? null : v);
259
259
  }
260
- emit('update:model-value', value === '__null__' ? null : value);
260
+
261
+ value = value === '__null__' ? null : value;
262
+
263
+ emit('change', value);
264
+ emit('update:model-value', value);
261
265
  }
262
266
 
263
267
  /** XXX: This tells us when we should apply the filter. QSelect likes to trigger a new filter everytime you open the dropdown
@@ -292,6 +296,7 @@ async function onFilter(val, update) {
292
296
  */
293
297
  function onClear() {
294
298
  emit('update:model-value', undefined);
299
+ emit('change', undefined);
295
300
  }
296
301
 
297
302
  /**
@@ -1,3 +1,4 @@
1
+ export * from "./Columns";
1
2
  export * from "./Filters";
2
3
  export * from "./Form";
3
4
  export * from "./Layouts";
@@ -147,6 +147,11 @@ export function useListControls(name: string, {
147
147
  function setItemInPagedList(updatedItem) {
148
148
  const data = pagedItems.value?.data?.map(item => (item.id === updatedItem.id && (item.updated_at === null || item.updated_at <= updatedItem.updated_at)) ? updatedItem : item);
149
149
  pagedItems.value = { ...pagedItems.value, data };
150
+
151
+ // Update the active item as well if it is set
152
+ if (activeItem.value?.id === updatedItem.id) {
153
+ activeItem.value = { ...activeItem.value, ...updatedItem };
154
+ }
150
155
  }
151
156
 
152
157
  /**
@@ -1,16 +1,19 @@
1
1
  <template>
2
2
  <QTabPanels
3
- :model-value="activePanel"
4
- class="overflow-y-auto bg-neutral-plus-7 h-full transition-all"
3
+ :model-value="activePanel"
4
+ class="overflow-y-auto bg-neutral-plus-7 h-full transition-all"
5
5
  >
6
6
  <QTabPanel v-for="panel in panels" :key="panel.name" :name="panel.name">
7
- <RenderVnode v-if="panel.vnode" :vnode="panel.vnode" />
7
+ <RenderVnode
8
+ v-if="panel.vnode"
9
+ :vnode="panel.vnode"
10
+ />
8
11
  </QTabPanel>
9
12
  </QTabPanels>
10
13
  </template>
11
14
 
12
15
  <script setup>
13
- import { RenderVnode } from "quasar-ui-danx";
16
+ import { RenderVnode } from '../Utility';
14
17
 
15
18
  defineProps({
16
19
  activePanel: {
@@ -1,4 +1,4 @@
1
- import { shallowRef, VNode } from "vue";
1
+ import { ref, shallowRef, VNode } from "vue";
2
2
  import { FlashMessages } from "./index";
3
3
 
4
4
  interface ActionOptions {
@@ -8,11 +8,15 @@ interface ActionOptions {
8
8
  batch?: boolean;
9
9
  category?: string;
10
10
  class?: string;
11
+ trigger?: (target: object[] | object, input: any) => Promise<any>;
12
+ activeTarget?: any;
11
13
  vnode?: (target: object[] | object) => VNode;
12
14
  enabled?: (target: object) => boolean;
13
15
  batchEnabled?: (targets: object[]) => boolean;
16
+ optimistic?: (action: ActionOptions, target: object, input: any) => void;
14
17
  onAction?: (action: string | null, target: object, input: any) => Promise<any>;
15
18
  onBatchAction?: (action: string | null, targets: object[], input: any) => Promise<any>;
19
+ onStart?: (action: ActionOptions | null, targets: object, input: any) => boolean;
16
20
  onSuccess?: (action: string | null, targets: object, input: any) => any;
17
21
  onError?: (action: string | null, targets: object, input: any) => any;
18
22
  onFinish?: (action: string | null, targets: object, input: any) => any;
@@ -28,111 +32,113 @@ export const activeActionVnode = shallowRef(null);
28
32
  * @param {ActionOptions} globalOptions
29
33
  */
30
34
  export function useActions(actions: ActionOptions[], globalOptions: ActionOptions = null) {
31
- const isSavingTarget = shallowRef(null);
35
+ const mappedActions = actions.map(action => {
36
+ const mappedAction = { ...globalOptions, ...action };
37
+ if (!mappedAction.trigger) {
38
+ mappedAction.trigger = (target, input) => performAction(mappedAction, target, input);
39
+ mappedAction.activeTarget = ref(null);
40
+ }
41
+ return mappedAction;
42
+ });
32
43
 
33
44
  /**
34
- * Resolves an action by name or object, adds globalOptions and overrides any passes options
45
+ * Check if the provided target is currently being saved by any of the actions
46
+ */
47
+ function isSavingTarget(target: any): boolean {
48
+ if (!target) return false;
49
+
50
+ for (const action of mappedActions) {
51
+ const activeTargets = (Array.isArray(action.activeTarget.value) ? action.activeTarget.value : [action.activeTarget.value]).filter(t => t);
52
+ if (activeTargets.length === 0) continue;
53
+
54
+ for (const activeTarget of activeTargets) {
55
+ if (activeTarget === target || (activeTarget.id && activeTarget.id === target.id)) {
56
+ return true;
57
+ }
58
+ }
59
+ }
60
+
61
+ return false;
62
+ }
63
+
64
+ /**
65
+ * Perform an action on a set of targets
35
66
  *
36
- * @param name
37
- * @param {any} options
38
- * @returns {any}
67
+ * @param {string} name - can either be a string or an action object
68
+ * @param {object[]|object} target - an array of targets or a single target object
69
+ * @param {any} input - The input data to pass to the action handler
39
70
  */
40
- function resolveAction(name, options = null) {
41
- const action = typeof name === "string" ? actions.find(a => a.name === name) : name;
71
+ async function performAction(name: string | object, target: object[] | object, input: any = null) {
72
+ const action: ActionOptions = typeof name === "string" ? mappedActions.find(a => a.name === name) : name;
42
73
  if (!action) {
43
74
  throw new Error(`Unknown action: ${name}`);
44
75
  }
45
76
 
46
- return { ...globalOptions, ...action, ...options };
47
- }
77
+ const vnode = action.vnode && action.vnode(target);
78
+ let result: any;
48
79
 
49
- return {
50
- actions,
51
- isSavingTarget,
52
- resolveAction,
53
-
54
- /**
55
- * Filter the list of actions based on the provided filters in key-value pairs
56
- * You can filter on any ActionOptions property by matching the value exactly or by providing an array of values
57
- *
58
- * @param filters
59
- * @returns {ActionOptions[]}
60
- */
61
- filterActions(filters: object) {
62
- let filteredActions = [...actions];
63
-
64
- for (const filter of Object.keys(filters)) {
65
- const filterValue = filters[filter];
66
- filteredActions = filteredActions.filter(a => a[filter] === filterValue || (Array.isArray(filterValue) && filterValue.includes(a[filter])));
67
- }
68
- return filteredActions;
69
- },
70
-
71
- /**
72
- * TODO: HOW TO INTEGRATE optimistic updates and single item updates?
73
- */
74
- async applyAction(item, input, itemData = {}) {
75
- setItemInPagedList({ ...item, ...input, ...itemData });
76
- const result = await applyActionRoute(item, input);
77
- if (result.success) {
78
- // Only render the most recent campaign changes
79
- if (resultNumber !== actionResultCount) return;
80
-
81
- // Update the updated item in the previously loaded list if it exists
82
- setItemInPagedList(result.item);
83
-
84
- // Update the active item if it is the same as the updated item
85
- if (activeItem.value?.id === result.item.id) {
86
- activeItem.value = { ...activeItem.value, ...result.item };
87
- }
88
- }
89
- return result;
90
- },
91
-
92
- /**
93
- * Perform an action on a set of targets
94
- *
95
- * @param {string} name - can either be a string or an action object
96
- * @param {object[]|object} target - an array of targets or a single target object
97
- * @param {any} input
98
- */
99
- async performAction(name: string | object, target: object[] | object, input: any = null) {
100
- const action = resolveAction(name);
101
- const vnode = action.vnode && action.vnode(target);
102
- let result = null;
103
-
104
- isSavingTarget.value = target;
105
-
106
- // If additional input is required, first render the vnode and wait for the confirm or cancel action
107
- if (vnode) {
108
- // If the action requires an input, we set the activeActionVnode to the input component.
109
- // This will tell the ActionVnode to render the input component, and confirm or cancel the
110
- // action The confirm function has the input from the component passed and will resolve the promise
111
- // with the result of the action
112
- result = await new Promise((resolve, reject) => {
113
- activeActionVnode.value = {
114
- vnode,
115
- confirm: async input => {
116
- const result = await onConfirmAction(action, target, input);
117
-
118
- // Only resolve when we have a non-error response, so we can show the error message w/o
119
- // hiding the dialog / vnode
120
- if (result === undefined || result === true || result?.success) {
121
- resolve(result);
122
- }
123
- },
124
- cancel: resolve
125
- };
126
- });
127
-
128
- activeActionVnode.value = null;
129
- } else {
130
- result = await onConfirmAction(action, target, input);
80
+ action.activeTarget.value = target;
81
+
82
+ // Run the onStart handler if it exists and quit the operation if it returns false
83
+ if (action.onStart) {
84
+ if (action.onStart(action, target, input) === false) {
85
+ return;
131
86
  }
87
+ }
132
88
 
133
- isSavingTarget.value = null;
134
- return result;
89
+ // If additional input is required, first render the vnode and wait for the confirm or cancel action
90
+ if (vnode) {
91
+ // If the action requires an input, we set the activeActionVnode to the input component.
92
+ // This will tell the ActionVnode to render the input component, and confirm or cancel the
93
+ // action The confirm function has the input from the component passed and will resolve the promise
94
+ // with the result of the action
95
+ result = await new Promise((resolve, reject) => {
96
+ activeActionVnode.value = {
97
+ vnode,
98
+ confirm: async (confirmInput: any) => {
99
+ const result = await onConfirmAction(action, target, { ...input, ...confirmInput });
100
+
101
+ // Only resolve when we have a non-error response, so we can show the error message w/o
102
+ // hiding the dialog / vnode
103
+ if (result === undefined || result === true || result?.success) {
104
+ resolve(result);
105
+ }
106
+ },
107
+ cancel: resolve
108
+ };
109
+ });
110
+
111
+ activeActionVnode.value = null;
112
+ } else {
113
+ result = await onConfirmAction(action, target, input);
135
114
  }
115
+
116
+ action.activeTarget.value = null;
117
+ return result;
118
+ }
119
+
120
+ /**
121
+ * Filter the list of actions based on the provided filters in key-value pairs
122
+ * You can filter on any ActionOptions property by matching the value exactly or by providing an array of values
123
+ *
124
+ * @param filters
125
+ * @returns {ActionOptions[]}
126
+ */
127
+ function filterActions(filters: object): ActionOptions[] {
128
+ let filteredActions = [...mappedActions];
129
+
130
+ for (const filter of Object.keys(filters)) {
131
+ const filterValue = filters[filter];
132
+ filteredActions = filteredActions.filter(a => a[filter] === filterValue || (Array.isArray(filterValue) && filterValue.includes(a[filter])));
133
+ }
134
+ return filteredActions;
135
+ }
136
+
137
+ return {
138
+ actions: mappedActions,
139
+ isSavingTarget,
140
+ filterActions,
141
+ performAction
136
142
  };
137
143
  }
138
144
 
@@ -146,6 +152,12 @@ async function onConfirmAction(action: ActionOptions, target: object[] | object,
146
152
  if (Array.isArray(target)) {
147
153
  result = await action.onBatchAction(action.name, target, input);
148
154
  } else {
155
+ // If the action has an optimistic callback, we call it before the actual action to immediately
156
+ // update the UI
157
+ if (action.optimistic) {
158
+ action.optimistic(action, target, input);
159
+ }
160
+
149
161
  result = await action.onAction(action.name, target, input);
150
162
  }
151
163
  } catch (e) {
@@ -0,0 +1,3 @@
1
+ <svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M10 12L6 8H14L10 12Z" fill="currentColor"/>
3
+ </svg>
package/src/svg/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { default as CaretDownIcon } from "./CaretDownIcon.svg";
1
2
  export { default as DragHandleDotsIcon } from "./DragHandleDotsIcon.svg";
2
3
  export { default as DragHandleIcon } from "./DragHandleIcon.svg";
3
4
  export { default as FilterIcon } from "./FilterIcon.svg";