quasar-ui-danx 0.0.46 → 0.0.48

Sign up to get free protection for your applications and to get access to all the features.
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";