sprintify-ui 0.8.70 → 0.9.1

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.
@@ -1,15 +1,16 @@
1
1
  <template>
2
- <BaseTagAutocompleteFetch
2
+ <BaseTagAutocomplete
3
3
  ref="tagAutocompleteFetch"
4
4
  :model-value="models"
5
- :url="url"
5
+ :options="options"
6
6
  :disabled="disabled"
7
+ :name="name"
7
8
  :placeholder="placeholder"
8
9
  :required="required"
9
10
  :value-key="primaryKey"
10
11
  :label-key="field"
12
+ :size="size"
11
13
  :has-error="hasError"
12
- :query-key="queryKey"
13
14
  :max="max"
14
15
  @update:model-value="onUpdate"
15
16
  >
@@ -40,30 +41,24 @@
40
41
  v-bind="footerProps"
41
42
  />
42
43
  </template>
43
- </BaseTagAutocompleteFetch>
44
+ </BaseTagAutocomplete>
44
45
  </template>
45
46
 
46
47
  <script lang="ts" setup>
47
- import { debounce } from 'lodash';
48
48
  import { RawOption } from '@/types';
49
- import { config } from '@/index';
50
49
  import { PropType } from 'vue';
51
50
  import BaseTagAutocompleteFetch from './BaseTagAutocompleteFetch.vue';
52
- import { AxiosResponse } from 'axios';
53
- import { getItems } from '@/utils/getApiData';
51
+ import BaseTagAutocomplete from './BaseTagAutocomplete.vue';
52
+ import { Size } from '@/utils/sizes';
54
53
 
55
54
  const props = defineProps({
56
55
  modelValue: {
57
56
  default: undefined,
58
57
  type: [Array, String, Number, null, undefined] as PropType<string[] | string | number | null | undefined>,
59
58
  },
60
- url: {
59
+ options: {
61
60
  required: true,
62
- type: String,
63
- },
64
- showRouteUrl: {
65
- default: undefined,
66
- type: Function as PropType<((ids: (string | number)[]) => string) | undefined>,
61
+ type: Array as PropType<RawOption[]>,
67
62
  },
68
63
  primaryKey: {
69
64
  default: 'id',
@@ -81,57 +76,47 @@ const props = defineProps({
81
76
  default: false,
82
77
  type: Boolean,
83
78
  },
79
+ name: {
80
+ default: undefined,
81
+ type: String,
82
+ },
84
83
  placeholder: {
85
84
  default: undefined,
86
85
  type: String,
87
86
  },
87
+ size: {
88
+ default: undefined,
89
+ type: String as PropType<Size>,
90
+ },
88
91
  max: {
89
92
  default: undefined,
90
93
  type: Number,
91
94
  },
92
- queryKey: {
93
- default: 'search',
94
- type: String,
95
- },
96
- currentModels: {
97
- default() {
98
- return undefined;
99
- },
100
- type: Array as PropType<RawOption[] | undefined>,
101
- },
102
95
  hasError: {
103
96
  default: false,
104
97
  type: Boolean,
105
98
  },
106
99
  });
107
100
 
108
- const http = config.http;
109
-
110
101
  const emit = defineEmits(['update:modelValue']);
111
102
 
112
103
  const tagAutocompleteFetch = ref<InstanceType<
113
104
  typeof BaseTagAutocompleteFetch
114
105
  > | null>(null);
115
106
 
116
- const models = ref<RawOption[]>([]);
117
- const ensureModelIsFilledDebounced = debounce(() => ensureModelsAreFilled(), 100);
118
-
119
- watch(
120
- () => props.currentModels,
121
- ensureModelIsFilledDebounced,
122
- { deep: true }
123
- );
124
-
125
- watch(
126
- () => props.modelValue,
127
- ensureModelIsFilledDebounced,
128
- { deep: true }
129
- );
107
+ const models = computed(() => {
108
+ if (!props.modelValue || !Array.isArray(props.modelValue)) {
109
+ return [];
110
+ }
130
111
 
131
- ensureModelIsFilledDebounced();
112
+ return props.modelValue
113
+ .map((id) => {
114
+ return props.options.find((m) => m[props.primaryKey] === id);
115
+ })
116
+ .filter((m) => m) as RawOption[];
117
+ });
132
118
 
133
119
  function onUpdate(newModels: RawOption[]) {
134
- models.value = newModels;
135
120
  emit(
136
121
  'update:modelValue',
137
122
  newModels.map((m) => m[props.primaryKey]),
@@ -139,84 +124,12 @@ function onUpdate(newModels: RawOption[]) {
139
124
  );
140
125
  }
141
126
 
142
- function ensureModelsAreFilled() {
143
-
144
- let modelValueInternal = props.modelValue;
145
-
146
- if (typeof modelValueInternal == 'string' || typeof modelValueInternal == 'number') {
147
- modelValueInternal = [modelValueInternal + ''];
148
- }
149
-
150
- if (!Array.isArray(modelValueInternal)) {
151
- models.value = [];
152
- return;
153
- }
154
-
155
- if (modelValueInternal.length == 0) {
156
- models.value = [];
157
- return;
158
- }
159
-
160
- // Remove incorrect models
161
-
162
- const ids = modelValueInternal.map((id: number | string) => id.toString());
163
-
164
- models.value = models.value.filter((m) => ids.includes(m[props.primaryKey] + ''));
165
-
166
- const localModelIds = models.value.map((m) => m[props.primaryKey] + '');
167
-
168
- let missingIds = ids.filter((id) => !localModelIds.includes(id));
169
-
170
-
171
- // Current models are fully set
172
- if (missingIds.length == 0) {
173
- return;
174
- }
175
-
176
- // Try with current models
177
-
178
- if (Array.isArray(props.currentModels)) {
179
- missingIds.forEach((id) => {
180
- const model = props.currentModels?.find((m) => m[props.primaryKey as never] == id);
181
-
182
- if (model) {
183
- models.value.push(model);
184
- missingIds = missingIds.filter((i) => i != id);
185
- }
186
- });
187
- }
188
-
189
- // Current models are fully set
190
- if (missingIds.length == 0) {
191
- return;
192
- }
193
-
194
- // Try with show route
195
-
196
- if (props.showRouteUrl == null) {
197
- return;
198
- }
199
-
200
- http
201
- .get(props.showRouteUrl(missingIds))
202
- .then((response: AxiosResponse) => {
203
-
204
- const items = getItems(response.data);
205
-
206
- models.value = items.filter((i: Record<string, any>) => {
207
- // convert primary keys to string for comparison
208
- return ids.includes(i[props.primaryKey] + '');
209
- });
210
- })
211
- .catch((e: Error) => e);
212
- }
213
-
214
127
  defineExpose({
215
128
  focus: () => tagAutocompleteFetch.value?.focus(),
216
129
  blur: () => tagAutocompleteFetch.value?.blur(),
217
130
  open: () => tagAutocompleteFetch.value?.open(),
218
131
  close: () => tagAutocompleteFetch.value?.close(),
219
- setKeywords: (input: string) =>
220
- tagAutocompleteFetch.value?.setKeywords(input),
132
+ setKeywords: (input: string) => tagAutocompleteFetch.value?.setKeywords(input),
221
133
  });
134
+
222
135
  </script>
@@ -0,0 +1,271 @@
1
+ import BaseHasManyFetch from "./BaseHasManyFetch.vue";
2
+ import ShowValue from "@/../.storybook/components/ShowValue.vue";
3
+ import { createFieldStory, options, sizes } from "../../.storybook/utils";
4
+ import BaseAppSnackbars from "./BaseAppSnackbars.vue";
5
+ import QueryString from "qs";
6
+ import { random } from "lodash";
7
+
8
+ export default {
9
+ title: "Form/BaseHasManyFetch",
10
+ component: BaseHasManyFetch,
11
+ argTypes: {
12
+ size: {
13
+ control: { type: "select" },
14
+ options: sizes,
15
+ },
16
+ },
17
+ args: {
18
+ url: "https://faker.witify.io/api/todos",
19
+ field: "name",
20
+ primaryKey: "id",
21
+ showRouteUrl: (ids) => {
22
+ const params = QueryString.stringify({ filter: { id: ids } });
23
+ return `https://faker.witify.io/api/todos?${params}`;
24
+ },
25
+ },
26
+ decorators: [() => ({ template: '<div class="mb-36"><story/></div>' })],
27
+ };
28
+
29
+ const Template = (args) => {
30
+ return {
31
+ components: { BaseHasManyFetch, ShowValue, BaseAppSnackbars },
32
+ setup() {
33
+ const value = ref(4);
34
+ const currentModels = ref([
35
+ { id: 4, name: "Todo 4 (local)" },
36
+ { id: 6, name: "Todo 6 (local)" },
37
+ ]);
38
+ return { args, value, currentModels };
39
+ },
40
+ template: `
41
+ <BaseHasManyFetch
42
+ v-model="value"
43
+ v-bind="args"
44
+ :current-models="currentModels"
45
+ ></BaseHasManyFetch>
46
+ <ShowValue :value="value" />
47
+ <BaseAppSnackbars />
48
+ `,
49
+ };
50
+ };
51
+
52
+ export const Demo = Template.bind({});
53
+ Demo.args = {};
54
+
55
+ export const Disabled = (args) => {
56
+ return {
57
+ components: { BaseHasManyFetch, ShowValue },
58
+ setup() {
59
+ // current model is incorrect, to test component's resilience
60
+ const currentModel = options[1];
61
+ const value = ref([7]);
62
+ return { args, value, currentModel };
63
+ },
64
+ template: `<BaseHasManyFetch
65
+ v-bind="args"
66
+ v-model="value"
67
+ :current-models="[currentModel]"
68
+ :disabled="true"
69
+ ></BaseHasManyFetch>
70
+ <ShowValue :value="value" />`,
71
+ };
72
+ };
73
+
74
+ export const Maximum = Template.bind({});
75
+ Maximum.args = {
76
+ max: 3,
77
+ };
78
+
79
+ export const ShowRouteUrl = Template.bind({});
80
+ ShowRouteUrl.args = {
81
+ max: 3,
82
+ showRouteUrl: (ids) => {
83
+ const params = QueryString.stringify({ filter: { id: ids } });
84
+ return `https://faker.witify.io/api/todos?${params}`;
85
+ },
86
+ };
87
+
88
+ export const Sizes = (args) => ({
89
+ components: { BaseHasManyFetch },
90
+ setup() {
91
+ const value = ref([]);
92
+ return { args, sizes, value };
93
+ },
94
+ template: `
95
+ <div v-for="size in sizes" class="mb-1">
96
+ <p class="text-xs text-slate-600 leading-tight">{{ size }}</p>
97
+ <BaseHasManyFetch v-model="value" v-bind="args" :size="size"></BaseHasManyFetch>
98
+ </div>
99
+ `,
100
+ });
101
+
102
+ export const SlotOption = (args) => {
103
+ return {
104
+ components: { BaseHasManyFetch },
105
+ setup() {
106
+ const value = ref([]);
107
+ return { args, value };
108
+ },
109
+ template: `
110
+ <div class="mb-20">
111
+ <BaseHasManyFetch
112
+ v-model="value"
113
+ v-bind="args"
114
+ >
115
+ <template #option="{ option, active, selected }">
116
+ <div
117
+ class="rounded px-2 py-1"
118
+ :class="{
119
+ 'hover:bg-slate-100': !active && !selected,
120
+ 'bg-slate-200 hover:bg-slate-300': active && !selected,
121
+ 'bg-blue-500 text-white hover:bg-blue-600': !active && selected,
122
+ 'bg-blue-600 text-white hover:bg-blue-700': active && selected,
123
+ }"
124
+ >
125
+ <p class="text-sm font-medium">{{ option.title }}</p>
126
+ <p class="opacity-60 text-xs">{{ option.owner?.name }}</p>
127
+ </div>
128
+ </template>
129
+ </BaseHasManyFetch>
130
+ </div>
131
+ `,
132
+ };
133
+ };
134
+
135
+ export const SlotItem = (args) => {
136
+ return {
137
+ components: { BaseHasManyFetch },
138
+ setup() {
139
+ const value = ref(null);
140
+ return { args, value };
141
+ },
142
+ template: `
143
+ <BaseHasManyFetch
144
+ v-model="value"
145
+ v-bind="args"
146
+ >
147
+ <template #items="{items, removeOption}">
148
+ <div
149
+ v-for="item in items"
150
+ :key="item"
151
+ class="p-0.5"
152
+ >
153
+ <div class="flex items-center rounded border pl-2 py-1 bg-white">
154
+ <BaseIcon icon="heroicons:tag" class="mr-2 text-slate-500" />
155
+ <div>
156
+ {{ item.label }}
157
+ </div>
158
+
159
+ <button
160
+ type="button"
161
+ class="flex shrink-0 appearance-none items-center justify-center border-0 bg-transparent pl-1 pr-3 text-xs outline-none"
162
+ @click=removeOption(item)
163
+ >
164
+
165
+ </button>
166
+ </div>
167
+ </div>
168
+ </template>
169
+ </BaseHasManyFetch>
170
+ `,
171
+ };
172
+ };
173
+
174
+ export const SlotFooter = (args) => {
175
+ return {
176
+ components: { BaseHasManyFetch },
177
+ setup() {
178
+ const value = ref([]);
179
+ function onClick() {
180
+ setTimeout(() => {
181
+ alert(1);
182
+ }, 300);
183
+ }
184
+ return { args, value, onClick };
185
+ },
186
+ template: `
187
+ <BaseHasManyFetch
188
+ v-model="value"
189
+ v-bind="args"
190
+ >
191
+ <template #footer>
192
+ <div class="text-center p-2 border-t">
193
+ <button type="button" @click=onClick class="btn btn-sm w-full btn-slate-200-outline">This is the footer 💯</button>
194
+ </div>
195
+ </template>
196
+ </BaseHasManyFetch>
197
+ `,
198
+ };
199
+ };
200
+
201
+ export const SlotEmpty = (args) => {
202
+ return {
203
+ components: { BaseHasManyFetch },
204
+ setup() {
205
+ const value = ref([]);
206
+ return { args, value };
207
+ },
208
+ template: `
209
+ <BaseHasManyFetch
210
+ v-model="value"
211
+ v-bind="args"
212
+ >
213
+ <template #empty="props">
214
+ <div>
215
+ <div v-if="props.firstSearch" class="text-center py-10 p-6">🤓🤓🤓</div>
216
+ <div v-else class="text-center px-6 py-20">Start your search... 🔎</div>
217
+ </div>
218
+ </template>
219
+ </BaseHasManyFetch>
220
+ `,
221
+ };
222
+ };
223
+
224
+ const RaceConditionTemplate = (args) => {
225
+ return {
226
+ components: { BaseHasManyFetch, ShowValue, BaseAppSnackbars },
227
+ setup() {
228
+ const value = ref(["4", "6"]);
229
+
230
+ const valueExternal = ref([]);
231
+
232
+ const intervalId = setInterval(() => {
233
+ const newValue = [random(1, 10)];
234
+ value.value = newValue;
235
+ valueExternal.value = newValue;
236
+ }, 300);
237
+
238
+ setTimeout(() => {
239
+ clearInterval(intervalId);
240
+ }, 1000);
241
+
242
+ return { args, value, valueExternal };
243
+ },
244
+ template: `
245
+ <BaseHasManyFetch
246
+ v-model="value"
247
+ v-bind="args"
248
+ ></BaseHasManyFetch>
249
+
250
+ <br>
251
+
252
+ <p class="-mb-4">Value from data</p>
253
+ <ShowValue :value="valueExternal" />
254
+
255
+ <br>
256
+
257
+ <p class="-mb-4">Value from component</p>
258
+ <ShowValue :value="value" />
259
+ <BaseAppSnackbars />
260
+ `,
261
+ };
262
+ };
263
+
264
+ export const RaceCondition = RaceConditionTemplate.bind({});
265
+ RaceCondition.args = {};
266
+
267
+ export const Field = createFieldStory({
268
+ component: BaseHasManyFetch,
269
+ componentName: "BaseHasManyFetch",
270
+ label: "Name",
271
+ });
@@ -0,0 +1,222 @@
1
+ <template>
2
+ <BaseTagAutocompleteFetch
3
+ ref="tagAutocompleteFetch"
4
+ :model-value="models"
5
+ :url="url"
6
+ :disabled="disabled"
7
+ :placeholder="placeholder"
8
+ :required="required"
9
+ :value-key="primaryKey"
10
+ :label-key="field"
11
+ :has-error="hasError"
12
+ :query-key="queryKey"
13
+ :max="max"
14
+ @update:model-value="onUpdate"
15
+ >
16
+ <template #items="itemProps">
17
+ <slot
18
+ name="items"
19
+ v-bind="itemProps"
20
+ />
21
+ </template>
22
+
23
+ <template #option="optionProps">
24
+ <slot
25
+ name="option"
26
+ v-bind="optionProps"
27
+ />
28
+ </template>
29
+
30
+ <template #empty="emptyProps">
31
+ <slot
32
+ name="empty"
33
+ v-bind="emptyProps"
34
+ />
35
+ </template>
36
+
37
+ <template #footer="footerProps">
38
+ <slot
39
+ name="footer"
40
+ v-bind="footerProps"
41
+ />
42
+ </template>
43
+ </BaseTagAutocompleteFetch>
44
+ </template>
45
+
46
+ <script lang="ts" setup>
47
+ import { debounce } from 'lodash';
48
+ import { RawOption } from '@/types';
49
+ import { config } from '@/index';
50
+ import { PropType } from 'vue';
51
+ import BaseTagAutocompleteFetch from './BaseTagAutocompleteFetch.vue';
52
+ import { AxiosResponse } from 'axios';
53
+ import { getItems } from '@/utils/getApiData';
54
+
55
+ const props = defineProps({
56
+ modelValue: {
57
+ default: undefined,
58
+ type: [Array, String, Number, null, undefined] as PropType<string[] | string | number | null | undefined>,
59
+ },
60
+ url: {
61
+ required: true,
62
+ type: String,
63
+ },
64
+ showRouteUrl: {
65
+ default: undefined,
66
+ type: Function as PropType<((ids: (string | number)[]) => string) | undefined>,
67
+ },
68
+ primaryKey: {
69
+ default: 'id',
70
+ type: String,
71
+ },
72
+ field: {
73
+ required: true,
74
+ type: String,
75
+ },
76
+ required: {
77
+ default: false,
78
+ type: Boolean,
79
+ },
80
+ disabled: {
81
+ default: false,
82
+ type: Boolean,
83
+ },
84
+ placeholder: {
85
+ default: undefined,
86
+ type: String,
87
+ },
88
+ max: {
89
+ default: undefined,
90
+ type: Number,
91
+ },
92
+ queryKey: {
93
+ default: 'search',
94
+ type: String,
95
+ },
96
+ currentModels: {
97
+ default() {
98
+ return undefined;
99
+ },
100
+ type: Array as PropType<RawOption[] | undefined>,
101
+ },
102
+ hasError: {
103
+ default: false,
104
+ type: Boolean,
105
+ },
106
+ });
107
+
108
+ const http = config.http;
109
+
110
+ const emit = defineEmits(['update:modelValue']);
111
+
112
+ const tagAutocompleteFetch = ref<InstanceType<
113
+ typeof BaseTagAutocompleteFetch
114
+ > | null>(null);
115
+
116
+ const models = ref<RawOption[]>([]);
117
+ const ensureModelIsFilledDebounced = debounce(() => ensureModelsAreFilled(), 100);
118
+
119
+ watch(
120
+ () => props.currentModels,
121
+ ensureModelIsFilledDebounced,
122
+ { deep: true }
123
+ );
124
+
125
+ watch(
126
+ () => props.modelValue,
127
+ ensureModelIsFilledDebounced,
128
+ { deep: true }
129
+ );
130
+
131
+ ensureModelIsFilledDebounced();
132
+
133
+ function onUpdate(newModels: RawOption[]) {
134
+ models.value = newModels;
135
+ emit(
136
+ 'update:modelValue',
137
+ newModels.map((m) => m[props.primaryKey]),
138
+ newModels,
139
+ );
140
+ }
141
+
142
+ function ensureModelsAreFilled() {
143
+
144
+ let modelValueInternal = props.modelValue;
145
+
146
+ if (typeof modelValueInternal == 'string' || typeof modelValueInternal == 'number') {
147
+ modelValueInternal = [modelValueInternal + ''];
148
+ }
149
+
150
+ if (!Array.isArray(modelValueInternal)) {
151
+ models.value = [];
152
+ return;
153
+ }
154
+
155
+ if (modelValueInternal.length == 0) {
156
+ models.value = [];
157
+ return;
158
+ }
159
+
160
+ // Remove incorrect models
161
+
162
+ const ids = modelValueInternal.map((id: number | string) => id.toString());
163
+
164
+ models.value = models.value.filter((m) => ids.includes(m[props.primaryKey] + ''));
165
+
166
+ const localModelIds = models.value.map((m) => m[props.primaryKey] + '');
167
+
168
+ let missingIds = ids.filter((id) => !localModelIds.includes(id));
169
+
170
+
171
+ // Current models are fully set
172
+ if (missingIds.length == 0) {
173
+ return;
174
+ }
175
+
176
+ // Try with current models
177
+
178
+ if (Array.isArray(props.currentModels)) {
179
+ missingIds.forEach((id) => {
180
+ const model = props.currentModels?.find((m) => m[props.primaryKey as never] == id);
181
+
182
+ if (model) {
183
+ models.value.push(model);
184
+ missingIds = missingIds.filter((i) => i != id);
185
+ }
186
+ });
187
+ }
188
+
189
+ // Current models are fully set
190
+ if (missingIds.length == 0) {
191
+ return;
192
+ }
193
+
194
+ // Try with show route
195
+
196
+ if (props.showRouteUrl == null) {
197
+ return;
198
+ }
199
+
200
+ http
201
+ .get(props.showRouteUrl(missingIds))
202
+ .then((response: AxiosResponse) => {
203
+
204
+ const items = getItems(response.data);
205
+
206
+ models.value = items.filter((i: Record<string, any>) => {
207
+ // convert primary keys to string for comparison
208
+ return ids.includes(i[props.primaryKey] + '');
209
+ });
210
+ })
211
+ .catch((e: Error) => e);
212
+ }
213
+
214
+ defineExpose({
215
+ focus: () => tagAutocompleteFetch.value?.focus(),
216
+ blur: () => tagAutocompleteFetch.value?.blur(),
217
+ open: () => tagAutocompleteFetch.value?.open(),
218
+ close: () => tagAutocompleteFetch.value?.close(),
219
+ setKeywords: (input: string) =>
220
+ tagAutocompleteFetch.value?.setKeywords(input),
221
+ });
222
+ </script>
@@ -79,7 +79,6 @@ const { floatingStyles, showTooltip } = useTooltip(targetInternal, tooltipRef, p
79
79
 
80
80
  const classInternal = computed(() => {
81
81
  return [
82
- 'relative',
83
82
  props.class,
84
83
  ];
85
84
  });