@ramathibodi/nuxt-commons 4.0.7 → 4.0.9

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.
Files changed (29) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/module.mjs +7 -0
  3. package/dist/runtime/components/form/CheckboxGroup.d.vue.ts +4 -1
  4. package/dist/runtime/components/form/CheckboxGroup.vue +42 -23
  5. package/dist/runtime/components/form/CheckboxGroup.vue.d.ts +4 -1
  6. package/dist/runtime/components/form/Iterator.d.vue.ts +4 -0
  7. package/dist/runtime/components/form/Iterator.vue +8 -1
  8. package/dist/runtime/components/form/Iterator.vue.d.ts +4 -0
  9. package/dist/runtime/components/form/Pad.vue +11 -2
  10. package/dist/runtime/components/form/Table.d.vue.ts +4 -0
  11. package/dist/runtime/components/form/Table.vue +9 -0
  12. package/dist/runtime/components/form/Table.vue.d.ts +4 -0
  13. package/dist/runtime/components/form/images/Field.vue +13 -11
  14. package/dist/runtime/components/model/Table.d.vue.ts +4 -0
  15. package/dist/runtime/components/model/Table.vue +10 -0
  16. package/dist/runtime/components/model/Table.vue.d.ts +4 -0
  17. package/dist/runtime/components/model/iterator.d.vue.ts +4 -0
  18. package/dist/runtime/components/model/iterator.vue +7 -0
  19. package/dist/runtime/components/model/iterator.vue.d.ts +4 -0
  20. package/dist/runtime/composables/api.d.ts +35 -4
  21. package/dist/runtime/composables/api.js +22 -6
  22. package/dist/runtime/composables/document/template.js +6 -4
  23. package/dist/runtime/composables/document/templateMigrate.d.ts +43 -0
  24. package/dist/runtime/composables/document/templateMigrate.js +118 -0
  25. package/dist/runtime/composables/localStorageModel.js +10 -8
  26. package/dist/runtime/composables/lookupListMaster.js +6 -6
  27. package/dist/runtime/composables/perPagePreference.d.ts +41 -0
  28. package/dist/runtime/composables/perPagePreference.js +55 -0
  29. package/package.json +1 -1
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": "^4.3.1"
6
6
  },
7
- "version": "4.0.7",
7
+ "version": "4.0.9",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.2",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -48,6 +48,13 @@ const module$1 = defineNuxtModule({
48
48
  ...runtimeConfig,
49
49
  ..._options
50
50
  };
51
+ const publicConfig = _nuxt.options.runtimeConfig.public;
52
+ if (publicConfig.IDEMPOTENCY_HEADER == null) {
53
+ publicConfig.IDEMPOTENCY_HEADER = "Idempotency-Key";
54
+ }
55
+ if (publicConfig.IDEMPOTENCY_ENABLED == null) {
56
+ publicConfig.IDEMPOTENCY_ENABLED = true;
57
+ }
51
58
  _nuxt.options.vite.optimizeDeps ||= {};
52
59
  _nuxt.options.vite.optimizeDeps.include ||= [];
53
60
  _nuxt.options.vite.optimizeDeps.include.push("painterro");
@@ -12,7 +12,10 @@ interface Props extends /* @vue-ignore */ InstanceType<typeof VCheckbox['$props'
12
12
  }
13
13
  declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractPropTypes<__VLS_WithDefaults<__VLS_TypePropsToOption<Props>, {
14
14
  inline: boolean;
15
- }>>, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
15
+ }>>, {
16
+ validate: () => boolean;
17
+ reset: () => void;
18
+ }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
16
19
  "update:modelValue": (...args: any[]) => void;
17
20
  }, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<__VLS_WithDefaults<__VLS_TypePropsToOption<Props>, {
18
21
  inline: boolean;
@@ -12,6 +12,18 @@ const props = defineProps({
12
12
  const emit = defineEmits(["update:modelValue"]);
13
13
  const values = ref([]);
14
14
  const valuesOther = ref();
15
+ const touched = ref(false);
16
+ const onUserInput = () => {
17
+ touched.value = true;
18
+ };
19
+ const validate = () => {
20
+ touched.value = true;
21
+ return !computedRules.value;
22
+ };
23
+ const reset = () => {
24
+ touched.value = false;
25
+ };
26
+ defineExpose({ validate, reset });
15
27
  const computedRules = computed(() => {
16
28
  if (props.rules) {
17
29
  let rules = props.rules.map((rule) => rule(values.value.length ? values.value : null));
@@ -19,6 +31,7 @@ const computedRules = computed(() => {
19
31
  return join(rules, ",");
20
32
  }
21
33
  });
34
+ const showError = computed(() => touched.value && !!computedRules.value);
22
35
  const computedOther = computed(() => {
23
36
  const itemOther = filter(props.items, { value: "other" });
24
37
  return head(itemOther);
@@ -46,27 +59,33 @@ watch(props.modelValue, () => {
46
59
  </script>
47
60
 
48
61
  <template>
49
- <label class="text-body-1 opacity-60">{{props.label}}</label>
50
- <div :class="`d-flex ${inline ? 'flex-row' : 'flex-column'}`">
51
- <v-checkbox v-for="item in computeItems"
52
- :value="item.value"
53
- v-model="values"
54
- density="compact"
55
- hide-details
56
- :error = "!!computedRules"
57
- :="$attrs"
58
- :label="item.label">
59
- </v-checkbox>
60
- </div>
61
- <v-text-field hide-details
62
- :error = "!!computedRules"
63
- v-if="computedOther"
64
- v-model="valuesOther"
65
- :="$attrs"
66
- :label="computedOther.label">
67
-
68
- </v-text-field>
69
- <label class="text-error text-subtitle-2 ml-1">
70
- {{computedRules}}
71
- </label>
62
+ <label class="text-body-1 opacity-60">{{ props.label }}</label>
63
+ <div :class="`d-flex ${inline ? 'flex-row' : 'flex-column'}`">
64
+ <v-checkbox
65
+ v-for="item in computeItems"
66
+ v-model="values"
67
+ :value="item.value"
68
+ density="compact"
69
+ hide-details
70
+ :error="showError"
71
+ :="$attrs"
72
+ :label="item.label"
73
+ @update:model-value="onUserInput"
74
+ />
75
+ </div>
76
+ <v-text-field
77
+ v-if="computedOther"
78
+ v-model="valuesOther"
79
+ hide-details
80
+ :error="showError"
81
+ :="$attrs"
82
+ :label="computedOther.label"
83
+ @update:model-value="onUserInput"
84
+ />
85
+ <label
86
+ v-if="showError"
87
+ class="text-error text-subtitle-2 ml-1"
88
+ >
89
+ {{ computedRules }}
90
+ </label>
72
91
  </template>
@@ -12,7 +12,10 @@ interface Props extends /* @vue-ignore */ InstanceType<typeof VCheckbox['$props'
12
12
  }
13
13
  declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractPropTypes<__VLS_WithDefaults<__VLS_TypePropsToOption<Props>, {
14
14
  inline: boolean;
15
- }>>, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
15
+ }>>, {
16
+ validate: () => boolean;
17
+ reset: () => void;
18
+ }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
16
19
  "update:modelValue": (...args: any[]) => void;
17
20
  }, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<__VLS_WithDefaults<__VLS_TypePropsToOption<Props>, {
18
21
  inline: boolean;
@@ -29,6 +29,8 @@ interface Props extends /* @vue-ignore */ InstanceType<typeof VDataIterator['$pr
29
29
  preferTableLg?: string | number | boolean;
30
30
  preferTableMd?: string | number | boolean;
31
31
  preferTableSm?: string | number | boolean;
32
+ perPageStorageKey?: string;
33
+ perPageStorageEnabled?: boolean;
32
34
  }
33
35
  declare function setSearch(keyword: string): void;
34
36
  declare function createItem(item: Record<string, any>, callback?: FormDialogCallback): void;
@@ -193,6 +195,7 @@ declare const __VLS_base: import("vue").DefineComponent<import("vue").ExtractPro
193
195
  md: number;
194
196
  sm: number;
195
197
  itemsPerPage: number;
198
+ perPageStorageEnabled: boolean;
196
199
  }>>, {
197
200
  errorMessages: import("vue").ComputedRef<(string & string[]) | (readonly string[] & string[]) | undefined>;
198
201
  isValid: import("vue").ComputedRef<boolean | null | undefined>;
@@ -248,6 +251,7 @@ declare const __VLS_base: import("vue").DefineComponent<import("vue").ExtractPro
248
251
  md: number;
249
252
  sm: number;
250
253
  itemsPerPage: number;
254
+ perPageStorageEnabled: boolean;
251
255
  }>>> & Readonly<{
252
256
  "onUpdate:modelValue"?: ((...args: any[]) => any) | undefined;
253
257
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
@@ -5,6 +5,7 @@ import { VDataIterator } from "vuetify/components/VDataIterator";
5
5
  import { VDataTable } from "vuetify/components/VDataTable";
6
6
  import { VInput } from "vuetify/components/VInput";
7
7
  import { useDisplay } from "vuetify";
8
+ import { usePerPagePreference } from "../../composables/perPagePreference";
8
9
  defineOptions({
9
10
  inheritAttrs: false
10
11
  });
@@ -35,7 +36,9 @@ const props = defineProps({
35
36
  preferTableXl: { type: [String, Number, Boolean], required: false },
36
37
  preferTableLg: { type: [String, Number, Boolean], required: false },
37
38
  preferTableMd: { type: [String, Number, Boolean], required: false },
38
- preferTableSm: { type: [String, Number, Boolean], required: false }
39
+ preferTableSm: { type: [String, Number, Boolean], required: false },
40
+ perPageStorageKey: { type: String, required: false },
41
+ perPageStorageEnabled: { type: Boolean, required: false, default: true }
39
42
  });
40
43
  const emit = defineEmits(["update:modelValue"]);
41
44
  const attrs = useAttrs();
@@ -122,6 +125,10 @@ watch(() => props.itemsPerPage, (newValue) => {
122
125
  if (newValue.toString().toLowerCase() == "all") itemsPerPageInternal.value = "-1";
123
126
  else if (newValue) itemsPerPageInternal.value = newValue;
124
127
  }, { immediate: true });
128
+ usePerPagePreference(itemsPerPageInternal, {
129
+ storageKey: props.perPageStorageKey,
130
+ enabled: props.perPageStorageEnabled
131
+ });
125
132
  function createItem(item, callback) {
126
133
  if (items.value.length > 0) item[props.modelKey] = Math.max(...items.value.map((i) => i[props.modelKey] || 0)) + 1;
127
134
  else item[props.modelKey] = 1;
@@ -29,6 +29,8 @@ interface Props extends /* @vue-ignore */ InstanceType<typeof VDataIterator['$pr
29
29
  preferTableLg?: string | number | boolean;
30
30
  preferTableMd?: string | number | boolean;
31
31
  preferTableSm?: string | number | boolean;
32
+ perPageStorageKey?: string;
33
+ perPageStorageEnabled?: boolean;
32
34
  }
33
35
  declare function setSearch(keyword: string): void;
34
36
  declare function createItem(item: Record<string, any>, callback?: FormDialogCallback): void;
@@ -193,6 +195,7 @@ declare const __VLS_base: import("vue").DefineComponent<import("vue").ExtractPro
193
195
  md: number;
194
196
  sm: number;
195
197
  itemsPerPage: number;
198
+ perPageStorageEnabled: boolean;
196
199
  }>>, {
197
200
  errorMessages: import("vue").ComputedRef<(string & string[]) | (readonly string[] & string[]) | undefined>;
198
201
  isValid: import("vue").ComputedRef<boolean | null | undefined>;
@@ -248,6 +251,7 @@ declare const __VLS_base: import("vue").DefineComponent<import("vue").ExtractPro
248
251
  md: number;
249
252
  sm: number;
250
253
  itemsPerPage: number;
254
+ perPageStorageEnabled: boolean;
251
255
  }>>> & Readonly<{
252
256
  "onUpdate:modelValue"?: ((...args: any[]) => any) | undefined;
253
257
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
@@ -22,7 +22,7 @@ const props = defineProps({
22
22
  parentTemplates: { type: [String, Array], required: false, default: () => [] },
23
23
  dirtyClass: { type: String, required: false, default: "form-data-dirty" },
24
24
  dirtyOnCreate: { type: Boolean, required: false, default: false },
25
- sanitizeDelay: { type: Number, required: false, default: 2e3 }
25
+ sanitizeDelay: { type: Number, required: false, default: 5e3 }
26
26
  });
27
27
  const emit = defineEmits(["update:modelValue"]);
28
28
  const disabled = ref(props.disabled);
@@ -53,7 +53,16 @@ const formData = ref({});
53
53
  function isBlankString(v) {
54
54
  return isString(v) && v.trim().length === 0;
55
55
  }
56
- const sanitizeBlankStrings = debounce(sanitizeBlankStringsRaw, props.sanitizeDelay);
56
+ function scheduleIdle(fn) {
57
+ if (typeof window !== "undefined" && typeof window.requestIdleCallback === "function") {
58
+ window.requestIdleCallback(fn, { timeout: 2e3 });
59
+ } else {
60
+ setTimeout(fn, 0);
61
+ }
62
+ }
63
+ const sanitizeBlankStrings = debounce((val, original) => {
64
+ scheduleIdle(() => sanitizeBlankStringsRaw(val, original));
65
+ }, props.sanitizeDelay);
57
66
  function sanitizeBlankStringsRaw(val, original) {
58
67
  if (!original && props.originalData) {
59
68
  sanitizeBlankStrings(val, props.originalData);
@@ -25,6 +25,8 @@ interface Props extends /* @vue-ignore */ InstanceType<typeof VDataTable['$props
25
25
  inputPadOnly?: boolean;
26
26
  saveAndStay?: boolean;
27
27
  stringFields?: Array<string>;
28
+ perPageStorageKey?: string;
29
+ perPageStorageEnabled?: boolean;
28
30
  }
29
31
  /**
30
32
  * Public props accepted by FormTable.
@@ -136,6 +138,7 @@ declare const __VLS_base: import("vue").DefineComponent<import("vue").ExtractPro
136
138
  inputPadOnly: boolean;
137
139
  saveAndStay: boolean;
138
140
  stringFields: () => never[];
141
+ perPageStorageEnabled: boolean;
139
142
  }>>, {
140
143
  errorMessages: import("vue").ComputedRef<(string & string[]) | (readonly string[] & string[]) | undefined>;
141
144
  isValid: import("vue").ComputedRef<boolean | null | undefined>;
@@ -188,6 +191,7 @@ declare const __VLS_base: import("vue").DefineComponent<import("vue").ExtractPro
188
191
  inputPadOnly: boolean;
189
192
  saveAndStay: boolean;
190
193
  stringFields: () => never[];
194
+ perPageStorageEnabled: boolean;
191
195
  }>>> & Readonly<{
192
196
  "onUpdate:modelValue"?: ((...args: any[]) => any) | undefined;
193
197
  "onOpen:dialog"?: ((...args: any[]) => any) | undefined;
@@ -5,6 +5,7 @@ import { computed, nextTick, ref, useAttrs, watch, useTemplateRef } from "vue";
5
5
  import { omit } from "lodash-es";
6
6
  import { useDialog } from "../../composables/dialog";
7
7
  import { useLocalStorageModel } from "../../composables/localStorageModel";
8
+ import { usePerPagePreference } from "../../composables/perPagePreference";
8
9
  defineOptions({
9
10
  inheritAttrs: false
10
11
  });
@@ -28,6 +29,8 @@ const props = defineProps({
28
29
  inputPadOnly: { type: Boolean, required: false, default: false },
29
30
  saveAndStay: { type: Boolean, required: false, default: false },
30
31
  stringFields: { type: Array, required: false, default: () => [] },
32
+ perPageStorageKey: { type: String, required: false },
33
+ perPageStorageEnabled: { type: Boolean, required: false, default: true },
31
34
  persist: { type: Boolean, required: false },
32
35
  persistKey: { type: String, required: false },
33
36
  persistPrefix: { type: String, required: false },
@@ -47,7 +50,12 @@ const inputRef = useTemplateRef("inputRef");
47
50
  const items = ref([]);
48
51
  const search = ref();
49
52
  const currentItem = ref(void 0);
53
+ const itemsPerPageInternal = ref();
50
54
  useLocalStorageModel(items, props);
55
+ usePerPagePreference(itemsPerPageInternal, {
56
+ storageKey: props.perPageStorageKey,
57
+ enabled: props.perPageStorageEnabled
58
+ });
51
59
  function setSearch(keyword) {
52
60
  search.value = keyword;
53
61
  }
@@ -234,6 +242,7 @@ defineExpose({
234
242
  </slot>
235
243
  <v-data-table
236
244
  v-bind="plainAttrs"
245
+ v-model:items-per-page="itemsPerPageInternal"
237
246
  color="primary"
238
247
  :items="items"
239
248
  :search="search"
@@ -25,6 +25,8 @@ interface Props extends /* @vue-ignore */ InstanceType<typeof VDataTable['$props
25
25
  inputPadOnly?: boolean;
26
26
  saveAndStay?: boolean;
27
27
  stringFields?: Array<string>;
28
+ perPageStorageKey?: string;
29
+ perPageStorageEnabled?: boolean;
28
30
  }
29
31
  /**
30
32
  * Public props accepted by FormTable.
@@ -136,6 +138,7 @@ declare const __VLS_base: import("vue").DefineComponent<import("vue").ExtractPro
136
138
  inputPadOnly: boolean;
137
139
  saveAndStay: boolean;
138
140
  stringFields: () => never[];
141
+ perPageStorageEnabled: boolean;
139
142
  }>>, {
140
143
  errorMessages: import("vue").ComputedRef<(string & string[]) | (readonly string[] & string[]) | undefined>;
141
144
  isValid: import("vue").ComputedRef<boolean | null | undefined>;
@@ -188,6 +191,7 @@ declare const __VLS_base: import("vue").DefineComponent<import("vue").ExtractPro
188
191
  inputPadOnly: boolean;
189
192
  saveAndStay: boolean;
190
193
  stringFields: () => never[];
194
+ perPageStorageEnabled: boolean;
191
195
  }>>> & Readonly<{
192
196
  "onUpdate:modelValue"?: ((...args: any[]) => any) | undefined;
193
197
  "onOpen:dialog"?: ((...args: any[]) => any) | undefined;
@@ -20,7 +20,7 @@ const images = ref([]);
20
20
  const uploadImages = ref([]);
21
21
  const dialog = ref(false);
22
22
  const dialogUpdate = ref(false);
23
- const dataUpdate = ref({ imageData: {}, imageTitle: "", imageProps: {} });
23
+ const dataUpdateIndex = ref(null);
24
24
  const dialogImageFullScreen = ref(false);
25
25
  const imageFullScreen = ref({ title: "", image: "" });
26
26
  let internalSync = false;
@@ -59,14 +59,15 @@ const addImage = (img) => {
59
59
  const remove = (index) => {
60
60
  images.value.splice(index, 1);
61
61
  };
62
- const setDataUpdate = (img) => {
63
- dataUpdate.value = {
64
- imageData: { ...img.imageData ?? {} },
65
- imageTitle: img.imageTitle ?? "",
66
- imageProps: { ...img.imageProps ?? {} }
67
- };
62
+ const setDataUpdate = (index) => {
63
+ if (index < 0 || index >= images.value.length) return;
64
+ dataUpdateIndex.value = index;
68
65
  dialogUpdate.value = true;
69
66
  };
67
+ const closeUpdateDialog = () => {
68
+ dialogUpdate.value = false;
69
+ dataUpdateIndex.value = null;
70
+ };
70
71
  const checkDuplicationName = (name) => images.value.some(({ imageTitle }) => isEqual(imageTitle, name));
71
72
  const isImageDataUrl = (dataUrl) => /^data:image\//i.test(dataUrl);
72
73
  const imageSrcFromImageData = (imageData) => {
@@ -209,7 +210,7 @@ defineExpose({
209
210
  <v-btn icon @click="remove(index)" :disabled="isDisabled?.value" :readonly="isReadonly?.value">
210
211
  <v-icon>mdi mdi-delete-outline</v-icon>
211
212
  </v-btn>
212
- <v-btn color="primary" icon @click="setDataUpdate(image)" v-if="!image.imageData?.id" :disabled="isDisabled?.value" :readonly="isReadonly?.value">
213
+ <v-btn color="primary" icon @click="setDataUpdate(index)" v-if="!image.imageData?.id" :disabled="isDisabled?.value" :readonly="isReadonly?.value">
213
214
  <v-icon>mdi mdi-image-edit-outline</v-icon>
214
215
  </v-btn>
215
216
  </VToolbarItems>
@@ -219,7 +220,7 @@ defineExpose({
219
220
  :src="imageSrcFromImageData(image)"
220
221
  height="250"
221
222
  @click="() => {
222
- props.readonly || image.imageData?.id || isReadonly?.value ? openImageFullScreen(image) : setDataUpdate(image);
223
+ props.readonly || image.imageData?.id || isReadonly?.value ? openImageFullScreen(image) : setDataUpdate(index);
223
224
  }"
224
225
  :disabled="isDisabled?.value"
225
226
  />
@@ -235,8 +236,9 @@ defineExpose({
235
236
  <!-- Edit dialog -->
236
237
  <VDialog v-model="dialogUpdate" fullscreen transition="dialog-bottom-transition">
237
238
  <FormImagesPad
238
- v-model="dataUpdate.imageData.base64String"
239
- @closedDialog="dialogUpdate = false"
239
+ v-if="dialogUpdate && dataUpdateIndex !== null && images[dataUpdateIndex]"
240
+ v-model="images[dataUpdateIndex].imageData.base64String"
241
+ @closedDialog="closeUpdateDialog"
240
242
  />
241
243
  </VDialog>
242
244
 
@@ -21,6 +21,8 @@ interface Props extends /* @vue-ignore */ InstanceType<typeof VDataTable['$props
21
21
  onlyOwnerEdit?: boolean;
22
22
  onlyOwnerOverridePermission?: string | string[];
23
23
  api?: boolean;
24
+ perPageStorageKey?: string;
25
+ perPageStorageEnabled?: boolean;
24
26
  }
25
27
  /**
26
28
  * Public props accepted by ModelTable.
@@ -164,6 +166,7 @@ declare const __VLS_base: import("vue").DefineComponent<import("vue").ExtractPro
164
166
  stringFields: () => never[];
165
167
  onlyOwnerEdit: boolean;
166
168
  api: boolean;
169
+ perPageStorageEnabled: boolean;
167
170
  }>>, {
168
171
  reload: () => void;
169
172
  operation: import("vue").Ref<{
@@ -240,6 +243,7 @@ declare const __VLS_base: import("vue").DefineComponent<import("vue").ExtractPro
240
243
  stringFields: () => never[];
241
244
  onlyOwnerEdit: boolean;
242
245
  api: boolean;
246
+ perPageStorageEnabled: boolean;
243
247
  }>>> & Readonly<{
244
248
  onDelete?: ((...args: any[]) => any) | undefined;
245
249
  onCreate?: ((...args: any[]) => any) | undefined;
@@ -4,6 +4,7 @@ import { VDataTable } from "vuetify/components/VDataTable";
4
4
  import { clone } from "lodash-es";
5
5
  import { useGraphqlModel } from "../../composables/graphqlModel";
6
6
  import { useApiModel } from "../../composables/apiModel";
7
+ import { usePerPagePreference } from "../../composables/perPagePreference";
7
8
  import { useDialog } from "../../composables/dialog";
8
9
  import { useUserPermission } from "../../composables/userPermission";
9
10
  import { useState } from "#imports";
@@ -30,6 +31,8 @@ const props = defineProps({
30
31
  onlyOwnerEdit: { type: Boolean, required: false, default: false },
31
32
  onlyOwnerOverridePermission: { type: [String, Array], required: false },
32
33
  api: { type: Boolean, required: false, default: false },
34
+ perPageStorageKey: { type: String, required: false },
35
+ perPageStorageEnabled: { type: Boolean, required: false, default: true },
33
36
  modelName: { type: String, required: true },
34
37
  modelKey: { type: String, required: false, default: "id" },
35
38
  modelBy: { type: Object, required: false, default: void 0 },
@@ -57,6 +60,11 @@ const currentUsername = computed(() => {
57
60
  const currentItem = ref(void 0);
58
61
  const isDialogOpen = ref(false);
59
62
  const isDialogReadonly = ref(false);
63
+ const itemsPerPageInternal = ref();
64
+ usePerPagePreference(itemsPerPageInternal, {
65
+ storageKey: props.perPageStorageKey ?? props.modelName,
66
+ enabled: props.perPageStorageEnabled
67
+ });
60
68
  const {
61
69
  items,
62
70
  itemsLength,
@@ -208,6 +216,7 @@ defineExpose({ reload, operation, items });
208
216
  <v-data-table-server
209
217
  v-if="canServerPageable"
210
218
  v-bind="plainAttrs"
219
+ v-model:items-per-page="itemsPerPageInternal"
211
220
  color="primary"
212
221
  :items="items"
213
222
  :items-length="itemsLength"
@@ -258,6 +267,7 @@ defineExpose({ reload, operation, items });
258
267
  <v-data-table
259
268
  v-else
260
269
  v-bind="plainAttrs"
270
+ v-model:items-per-page="itemsPerPageInternal"
261
271
  color="primary"
262
272
  :items="items"
263
273
  :item-value="props.modelKey"
@@ -21,6 +21,8 @@ interface Props extends /* @vue-ignore */ InstanceType<typeof VDataTable['$props
21
21
  onlyOwnerEdit?: boolean;
22
22
  onlyOwnerOverridePermission?: string | string[];
23
23
  api?: boolean;
24
+ perPageStorageKey?: string;
25
+ perPageStorageEnabled?: boolean;
24
26
  }
25
27
  /**
26
28
  * Public props accepted by ModelTable.
@@ -164,6 +166,7 @@ declare const __VLS_base: import("vue").DefineComponent<import("vue").ExtractPro
164
166
  stringFields: () => never[];
165
167
  onlyOwnerEdit: boolean;
166
168
  api: boolean;
169
+ perPageStorageEnabled: boolean;
167
170
  }>>, {
168
171
  reload: () => void;
169
172
  operation: import("vue").Ref<{
@@ -240,6 +243,7 @@ declare const __VLS_base: import("vue").DefineComponent<import("vue").ExtractPro
240
243
  stringFields: () => never[];
241
244
  onlyOwnerEdit: boolean;
242
245
  api: boolean;
246
+ perPageStorageEnabled: boolean;
243
247
  }>>> & Readonly<{
244
248
  onDelete?: ((...args: any[]) => any) | undefined;
245
249
  onCreate?: ((...args: any[]) => any) | undefined;
@@ -27,6 +27,8 @@ interface Props extends /* @vue-ignore */ InstanceType<typeof VDataIterator['$pr
27
27
  preferTableMd?: string | number | boolean;
28
28
  preferTableSm?: string | number | boolean;
29
29
  api?: boolean;
30
+ perPageStorageKey?: string;
31
+ perPageStorageEnabled?: boolean;
30
32
  }
31
33
  /**
32
34
  * Public props accepted by ModelIterator.
@@ -232,6 +234,7 @@ declare const __VLS_base: import("vue").DefineComponent<import("vue").ExtractPro
232
234
  sm: number;
233
235
  itemsPerPage: number;
234
236
  api: boolean;
237
+ perPageStorageEnabled: boolean;
235
238
  }>>, {
236
239
  reload: () => void;
237
240
  operation: import("vue").Ref<{
@@ -295,6 +298,7 @@ declare const __VLS_base: import("vue").DefineComponent<import("vue").ExtractPro
295
298
  sm: number;
296
299
  itemsPerPage: number;
297
300
  api: boolean;
301
+ perPageStorageEnabled: boolean;
298
302
  }>>> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
299
303
  declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
300
304
  declare const _default: typeof __VLS_export;
@@ -5,6 +5,7 @@ import { VDataTable } from "vuetify/components/VDataTable";
5
5
  import { omit } from "lodash-es";
6
6
  import { useGraphqlModel } from "../../composables/graphqlModel";
7
7
  import { useApiModel } from "../../composables/apiModel";
8
+ import { usePerPagePreference } from "../../composables/perPagePreference";
8
9
  import { useDisplay } from "vuetify";
9
10
  defineOptions({
10
11
  inheritAttrs: false
@@ -36,6 +37,8 @@ const props = defineProps({
36
37
  preferTableMd: { type: [String, Number, Boolean], required: false },
37
38
  preferTableSm: { type: [String, Number, Boolean], required: false },
38
39
  api: { type: Boolean, required: false, default: false },
40
+ perPageStorageKey: { type: String, required: false },
41
+ perPageStorageEnabled: { type: Boolean, required: false, default: true },
39
42
  modelName: { type: String, required: true },
40
43
  modelKey: { type: String, required: false, default: "id" },
41
44
  modelBy: { type: Object, required: false, default: void 0 },
@@ -122,6 +125,10 @@ watch(() => props.itemsPerPage, (newValue) => {
122
125
  if (newValue.toString().toLowerCase() == "all") itemsPerPageInternal.value = "-1";
123
126
  else if (newValue) itemsPerPageInternal.value = newValue;
124
127
  }, { immediate: true });
128
+ usePerPagePreference(itemsPerPageInternal, {
129
+ storageKey: props.perPageStorageKey ?? props.modelName,
130
+ enabled: props.perPageStorageEnabled
131
+ });
125
132
  const sortBy = ref();
126
133
  const pageCount = computed(() => {
127
134
  if (!itemsPerPageInternal.value || itemsPerPageInternal.value == "All" || itemsPerPageInternal.value == "-1" || Number(itemsPerPageInternal.value) <= 0) return 1;
@@ -27,6 +27,8 @@ interface Props extends /* @vue-ignore */ InstanceType<typeof VDataIterator['$pr
27
27
  preferTableMd?: string | number | boolean;
28
28
  preferTableSm?: string | number | boolean;
29
29
  api?: boolean;
30
+ perPageStorageKey?: string;
31
+ perPageStorageEnabled?: boolean;
30
32
  }
31
33
  /**
32
34
  * Public props accepted by ModelIterator.
@@ -232,6 +234,7 @@ declare const __VLS_base: import("vue").DefineComponent<import("vue").ExtractPro
232
234
  sm: number;
233
235
  itemsPerPage: number;
234
236
  api: boolean;
237
+ perPageStorageEnabled: boolean;
235
238
  }>>, {
236
239
  reload: () => void;
237
240
  operation: import("vue").Ref<{
@@ -295,6 +298,7 @@ declare const __VLS_base: import("vue").DefineComponent<import("vue").ExtractPro
295
298
  sm: number;
296
299
  itemsPerPage: number;
297
300
  api: boolean;
301
+ perPageStorageEnabled: boolean;
298
302
  }>>> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
299
303
  declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
300
304
  declare const _default: typeof __VLS_export;
@@ -21,6 +21,27 @@
21
21
  * so users can see stale empty / outdated lists for the full TTL window even after
22
22
  * creating data themselves.
23
23
  *
24
+ * Idempotency: every authenticated POST (REST and GraphQL via useGraphQl()) automatically
25
+ * receives an `Idempotency-Key` HTTP header. The value is computed client-side as
26
+ *
27
+ * SHA-256(`${floor(Date.now()/1000)}|${username}|${stableStringify(body ?? {})}`)
28
+ *
29
+ * in lowercase hex. The recipe is intentionally identical to the backend's server-side
30
+ * fallback in rama-spring-starter's @IdempotentMutation aspect, so a header-present and
31
+ * a header-absent request from the same user with the same body within the same wall-clock
32
+ * second resolve to the same dedup signature.
33
+ *
34
+ * Per-call escape hatch: pass `{ idempotent: false }` in the options argument to skip
35
+ * injection for a single call (e.g. a long-running mutation the caller wants to retry
36
+ * intentionally with the same body). A caller-supplied `Idempotency-Key` in
37
+ * `options.headers` always wins and is never overwritten.
38
+ *
39
+ * Configuration (consumer's `nuxt.config` `runtimeConfig.public`, mirroring the
40
+ * unprefixed `WS_API` / `WS_GRAPHQL` precedent):
41
+ * - IDEMPOTENCY_HEADER (default 'Idempotency-Key') — must match the backend's
42
+ * `rama.idempotency.header-name` setting if the consumer overrides it there.
43
+ * - IDEMPOTENCY_ENABLED (default true) — global kill switch.
44
+ *
24
45
  * This doc block is consumed by vue-docgen for generated API documentation.
25
46
  */
26
47
  import type { UseFetchOptions } from 'nuxt/app';
@@ -35,6 +56,16 @@ export type CacheOption = boolean | number | {
35
56
  } | {
36
57
  ttlMs: number;
37
58
  };
59
+ /**
60
+ * Per-call options accepted by useApi alongside Nuxt's UseFetchOptions.
61
+ *
62
+ * `idempotent: false` skips Idempotency-Key header injection for this single call.
63
+ * Default is `true`. See the useApi() docblock for the full idempotency contract.
64
+ */
65
+ export interface ApiIdempotencyOption {
66
+ idempotent?: boolean;
67
+ }
68
+ export type ApiFetchOptions = UseFetchOptions<unknown> & ApiIdempotencyOption;
38
69
  export declare function _resetLegacyHeuristicWarning(): void;
39
70
  /**
40
71
  * Resolve a cache option to seconds. Pure function, exported for tests.
@@ -60,10 +91,10 @@ export declare function resolveCacheTtlSeconds(cache: CacheOption | undefined |
60
91
  declare function invalidateCache(prefix?: string): number;
61
92
  export declare function useApi(): {
62
93
  urlBuilder: (url: string | string[]) => string;
63
- get: <T>(url: string | string[], body?: Record<string, any> | [], params?: SearchParameters, options?: UseFetchOptions<unknown>, cache?: CacheOption) => Promise<T>;
64
- getPromise: <T>(url: string | string[], body?: Record<string, any> | [], params?: SearchParameters, options?: UseFetchOptions<unknown>, cache?: CacheOption) => Promise<T>;
65
- post: <T>(url: string | string[], body?: Record<string, any> | [], params?: SearchParameters, options?: UseFetchOptions<unknown>, cache?: CacheOption) => Promise<T>;
66
- postPromise: <T>(url: string | string[], body?: Record<string, any> | [], params?: SearchParameters, options?: UseFetchOptions<unknown>, cache?: CacheOption) => Promise<T>;
94
+ get: <T>(url: string | string[], body?: Record<string, any> | [], params?: SearchParameters, options?: ApiFetchOptions, cache?: CacheOption) => Promise<T>;
95
+ getPromise: <T>(url: string | string[], body?: Record<string, any> | [], params?: SearchParameters, options?: ApiFetchOptions, cache?: CacheOption) => Promise<T>;
96
+ post: <T>(url: string | string[], body?: Record<string, any> | [], params?: SearchParameters, options?: ApiFetchOptions, cache?: CacheOption) => Promise<T>;
97
+ postPromise: <T>(url: string | string[], body?: Record<string, any> | [], params?: SearchParameters, options?: ApiFetchOptions, cache?: CacheOption) => Promise<T>;
67
98
  hashKey: (data: any) => Promise<string>;
68
99
  invalidate: typeof invalidateCache;
69
100
  };
@@ -7,6 +7,7 @@ import { useRuntimeConfig } from "#imports";
7
7
  import { useAuthentication } from "../bridges/authentication.js";
8
8
  const DEFAULT_TTL_SECONDS_FOR_TRUE = 5 * 60;
9
9
  const CACHE_PREFIX = "api-cache-";
10
+ const IDEMPOTENCY_HEADER_DEFAULT = "Idempotency-Key";
10
11
  let _legacyHeuristicWarned = false;
11
12
  function warnLegacyHeuristic(value, resolvedSeconds) {
12
13
  if (_legacyHeuristicWarned) return;
@@ -77,7 +78,7 @@ export function useApi() {
77
78
  if (returnUrl.startsWith("http://") || returnUrl.startsWith("https://")) return returnUrl;
78
79
  return trimEnd(config?.public.WS_API, "/") + "/" + trimStart(returnUrl, "/");
79
80
  }
80
- function optionBuilder(method, body, params, options = {}) {
81
+ async function optionBuilder(method, body, params, options = {}) {
81
82
  const headers = {
82
83
  "Content-Type": "application/json",
83
84
  Accept: "application/json"
@@ -85,9 +86,24 @@ export function useApi() {
85
86
  const auth = useAuthentication();
86
87
  const token = auth.keycloak?.token || auth.token;
87
88
  if (token) {
88
- ;
89
89
  headers["Authorization"] = `Bearer ${token}`;
90
90
  }
91
+ const callerHeaders = {
92
+ ...options.headers || {}
93
+ };
94
+ const enabledConfig = config?.public?.IDEMPOTENCY_ENABLED;
95
+ const idempotencyEnabled = enabledConfig !== false;
96
+ const headerName = config?.public?.IDEMPOTENCY_HEADER || IDEMPOTENCY_HEADER_DEFAULT;
97
+ const callerProvidedHeader = Object.keys(callerHeaders).some(
98
+ (k) => k.toLowerCase() === headerName.toLowerCase()
99
+ );
100
+ if (method === "POST" && idempotencyEnabled && options.idempotent !== false && !callerProvidedHeader) {
101
+ const username = (typeof auth.getUsername === "function" ? auth.getUsername() : auth.userProfile?.username) ?? "";
102
+ const second = Math.floor(Date.now() / 1e3).toString();
103
+ const bodyJson = stableStringify(body ?? {});
104
+ headers[headerName] = await sha256(`${second}|${username}|${bodyJson}`);
105
+ }
106
+ const { idempotent: _omitIdempotent, ...passThroughOptions } = options;
91
107
  const baseOptions = {
92
108
  method,
93
109
  body,
@@ -96,9 +112,9 @@ export function useApi() {
96
112
  };
97
113
  const finalHeaders = {
98
114
  ...headers,
99
- ...options.headers || {}
115
+ ...callerHeaders
100
116
  };
101
- Object.assign(baseOptions, options);
117
+ Object.assign(baseOptions, passThroughOptions);
102
118
  return {
103
119
  ...baseOptions,
104
120
  headers: finalHeaders
@@ -132,7 +148,7 @@ export function useApi() {
132
148
  const builtUrl = urlBuilder(url);
133
149
  const ttl = resolveCacheTtlSeconds(cache);
134
150
  if (ttl === 0) {
135
- return ofetch(builtUrl, optionBuilder(method, body, params, options));
151
+ return ofetch(builtUrl, await optionBuilder(method, body, params, options));
136
152
  }
137
153
  const keyData = { url: builtUrl, method, body, params, headers: options?.headers };
138
154
  const key = CACHE_PREFIX + await hashKey(keyData);
@@ -140,7 +156,7 @@ export function useApi() {
140
156
  if (cached !== null) {
141
157
  return cached;
142
158
  }
143
- const result = await ofetch(builtUrl, optionBuilder(method, body, params, options));
159
+ const result = await ofetch(builtUrl, await optionBuilder(method, body, params, options));
144
160
  ls.set(key, result, { ttl });
145
161
  return result;
146
162
  }
@@ -2,6 +2,7 @@ import { processTemplateFormTable } from "./templateFormTable.js";
2
2
  import { processTemplateFormTableData } from "./templateFormTableData.js";
3
3
  import { processTemplateFormHidden } from "./templateFormHidden.js";
4
4
  import { some, includes, cloneDeep } from "lodash-es";
5
+ import { migrateInputAttributes, migrateTemplateString } from "./templateMigrate.js";
5
6
  export const validationRulesRegex = /^(require(?:\([^)]*\))?|requireIf\([^)]*\)|requireTrue(?:\([^)]*\))?|requireTrueIf\([^)]*\)|numeric(?:\([^)]*\))?|range\([^)]*\)|integer(?:\([^)]*\))?|unique\([^)]*\)|length(?:\([^)]*\))?|lengthGreater\([^)]*\)|lengthLess\([^)]*\)|telephone(?:\([^)]*\))?|email(?:\([^)]*\))?|regex\([^)]*\)|idcard(?:\([^)]*\))?|DateFuture(?:\([^)]*\))?|DatetimeFuture(?:\([^)]*\))?|DateHappen(?:\([^)]*\))?|DatetimeHappen(?:\([^)]*\))?|DateAfter\([^)]*\)|DateBefore\([^)]*\)|DateEqual\([^)]*\))(,(require(?:\([^)]*\))?|requireIf\([^)]*\)|requireTrue(?:\([^)]*\))?|requireTrueIf\([^)]*\)|numeric(?:\([^)]*\))?|range\([^)]*\)|integer(?:\([^)]*\))?|unique\([^)]*\)|length(?:\([^)]*\))?|lengthGreater\([^)]*\)|lengthLess\([^)]*\)|telephone(?:\([^)]*\))?|email(?:\([^)]*\))?|regex\([^)]*\)|idcard(?:\([^)]*\))?|DateFuture(?:\([^)]*\))?|DatetimeFuture(?:\([^)]*\))?|DateHappen(?:\([^)]*\))?|DatetimeHappen(?:\([^)]*\))?|DateAfter\([^)]*\)|DateBefore\([^)]*\)|DateEqual\([^)]*\)))*$/;
6
7
  export function useDocumentTemplate(items, parentTemplates) {
7
8
  if (!items) return "";
@@ -16,11 +17,12 @@ export function useDocumentTemplate(items, parentTemplates) {
16
17
  return "";
17
18
  }
18
19
  const templateString = items.map((item) => columnWrapTemplateItemString(item, parentTemplates || [])).join("");
19
- return !parentTemplates || parentTemplates.length == 0 ? `<v-container fluid><v-row density='compact'>${templateString}</v-row></v-container>` : `<v-row density='compact'>${templateString}</v-row>`;
20
+ const wrapped = !parentTemplates || parentTemplates.length == 0 ? `<v-container fluid><v-row density='compact'>${templateString}</v-row></v-container>` : `<v-row density='compact'>${templateString}</v-row>`;
21
+ return migrateTemplateString(wrapped);
20
22
  }
21
23
  export function templateItemToString(inputItem, parentTemplates, dataVariable = "data") {
22
24
  let item = cloneDeep(inputItem);
23
- item.inputAttributes = item.inputAttributes?.trim() || "";
25
+ item.inputAttributes = migrateInputAttributes(item.inputAttributes?.trim() || "", item.inputType);
24
26
  let optionString = "";
25
27
  if (item.inputOptions) {
26
28
  if (item.inputType === "MasterAutocomplete") {
@@ -45,8 +47,8 @@ export function templateItemToString(inputItem, parentTemplates, dataVariable =
45
47
  if (item.conditionalDisplay) {
46
48
  item.inputAttributes = `${item.inputAttributes?.trim() || ""} v-if="${item.conditionalDisplay}"`.trim();
47
49
  }
48
- if (item.inputType === "FormDateTime" && !item.inputAttributes?.includes("dense")) {
49
- item.inputAttributes = `${item.inputAttributes?.trim() || ""} dense`.trim();
50
+ if (item.inputType === "FormDateTime" && !item.inputAttributes?.includes("density")) {
51
+ item.inputAttributes = `${item.inputAttributes?.trim() || ""} density="compact"`.trim();
50
52
  }
51
53
  let templateString;
52
54
  const validationRules = item.validationRules ? buildValidationRules(item.validationRules) || "" : "";
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Render-time migration helper that rewrites Vuetify 3 attribute syntax to
3
+ * Vuetify 4 inside Document templates. Used by `useDocumentTemplate`.
4
+ *
5
+ * Rules without `appliesTo` are universally safe and run in both the per-item
6
+ * pre-pass and the post-pass on the assembled template string. Rules with
7
+ * `appliesTo` only run in the pre-pass and only when `item.inputType` (or the
8
+ * tag name) matches.
9
+ *
10
+ * See `docs/superpowers/specs/2026-04-26-document-template-vuetify4-migration-design.md`.
11
+ */
12
+ export interface Vuetify4MigrationRule {
13
+ /** v3 bare boolean attribute name, e.g. 'dense'. */
14
+ from: string;
15
+ /** v4 replacement attribute incl. value, e.g. 'density="compact"'. */
16
+ to: string;
17
+ /**
18
+ * Optional whitelist of input types / tag names the rule applies to. If
19
+ * omitted, the rule is universally safe and also runs in the post-pass.
20
+ */
21
+ appliesTo?: string[];
22
+ }
23
+ /** Default rule table. Mutable / extensible by callers. */
24
+ export declare const vuetify4MigrationRules: Vuetify4MigrationRule[];
25
+ /**
26
+ * Walk an attribute string (the part inside `<tag …>`), replacing each bare
27
+ * boolean attribute that matches a rule with the rule's replacement. Skips
28
+ * text inside quoted attribute values. Internal — exported for unit tests.
29
+ */
30
+ export declare function migrateAttributeString(attrs: string, applicableRules: Vuetify4MigrationRule[]): string;
31
+ /**
32
+ * Per-item pre-pass. Filter the rule set to rules that apply to `inputType`
33
+ * (rules with no `appliesTo` always apply), then rewrite the attribute string.
34
+ */
35
+ export declare function migrateInputAttributes(attrs: string, inputType: string, rules?: Vuetify4MigrationRule[]): string;
36
+ /**
37
+ * Post-pass on the assembled template string. Walks opening tags (skipping
38
+ * closing tags and comments), extracts each tag's attribute region with
39
+ * quote-awareness so `>` inside `"…"` does not prematurely end the tag, then
40
+ * rewrites the attributes using only rules that have no `appliesTo` — those
41
+ * are universally safe to apply across both Vuetify and custom components.
42
+ */
43
+ export declare function migrateTemplateString(template: string, rules?: Vuetify4MigrationRule[]): string;
@@ -0,0 +1,118 @@
1
+ const VARIANT_APPLIES_TO = [
2
+ "VTextField",
3
+ "VSelect",
4
+ "VAutocomplete",
5
+ "VCombobox",
6
+ "VTextarea",
7
+ "VFileInput",
8
+ "VCard",
9
+ "FormDate",
10
+ "FormTime",
11
+ "FormDateTime",
12
+ "MasterAutocomplete",
13
+ "ModelAutocomplete",
14
+ "ModelSelect",
15
+ "ModelCombobox"
16
+ ];
17
+ export const vuetify4MigrationRules = [
18
+ { from: "dense", to: 'density="compact"' },
19
+ { from: "outlined", to: 'variant="outlined"', appliesTo: VARIANT_APPLIES_TO },
20
+ { from: "filled", to: 'variant="filled"', appliesTo: VARIANT_APPLIES_TO },
21
+ { from: "solo", to: 'variant="solo"', appliesTo: VARIANT_APPLIES_TO },
22
+ { from: "flat", to: 'variant="flat"', appliesTo: ["VCard"] }
23
+ ];
24
+ export function migrateAttributeString(attrs, applicableRules) {
25
+ if (!attrs || applicableRules.length === 0) return attrs;
26
+ let result = "";
27
+ let lastFlushed = 0;
28
+ let i = 0;
29
+ let inQuote = null;
30
+ while (i < attrs.length) {
31
+ const ch = attrs[i];
32
+ if (inQuote) {
33
+ if (ch === inQuote) inQuote = null;
34
+ i++;
35
+ continue;
36
+ }
37
+ if (ch === '"' || ch === "'") {
38
+ inQuote = ch;
39
+ i++;
40
+ continue;
41
+ }
42
+ if (i === 0 || /\s/.test(attrs[i - 1])) {
43
+ let matched = false;
44
+ for (const rule of applicableRules) {
45
+ const word = rule.from;
46
+ if (attrs.slice(i, i + word.length) !== word) continue;
47
+ const after = i + word.length;
48
+ if (after !== attrs.length && !/\s/.test(attrs[after])) continue;
49
+ result += attrs.slice(lastFlushed, i);
50
+ result += rule.to;
51
+ lastFlushed = after;
52
+ i = after;
53
+ matched = true;
54
+ break;
55
+ }
56
+ if (matched) continue;
57
+ }
58
+ i++;
59
+ }
60
+ result += attrs.slice(lastFlushed);
61
+ return result;
62
+ }
63
+ export function migrateInputAttributes(attrs, inputType, rules = vuetify4MigrationRules) {
64
+ if (!attrs) return "";
65
+ const applicable = rules.filter((r) => !r.appliesTo || r.appliesTo.includes(inputType));
66
+ return migrateAttributeString(attrs, applicable);
67
+ }
68
+ export function migrateTemplateString(template, rules = vuetify4MigrationRules) {
69
+ if (!template) return template;
70
+ const applicable = rules.filter((r) => !r.appliesTo);
71
+ if (applicable.length === 0) return template;
72
+ let result = "";
73
+ let i = 0;
74
+ while (i < template.length) {
75
+ const ch = template[i];
76
+ if (ch !== "<") {
77
+ result += ch;
78
+ i++;
79
+ continue;
80
+ }
81
+ if (template[i + 1] === "/" || template[i + 1] === "!") {
82
+ result += ch;
83
+ i++;
84
+ continue;
85
+ }
86
+ const nameMatch = /^[a-z][\w:-]*/i.exec(template.slice(i + 1));
87
+ if (!nameMatch) {
88
+ result += ch;
89
+ i++;
90
+ continue;
91
+ }
92
+ const tagName = nameMatch[0];
93
+ const attrsStart = i + 1 + tagName.length;
94
+ let j = attrsStart;
95
+ let inQuote = null;
96
+ while (j < template.length) {
97
+ const c = template[j];
98
+ if (inQuote) {
99
+ if (c === inQuote) inQuote = null;
100
+ } else if (c === '"' || c === "'") {
101
+ inQuote = c;
102
+ } else if (c === ">") {
103
+ break;
104
+ }
105
+ j++;
106
+ }
107
+ if (j >= template.length) {
108
+ result += template.slice(i);
109
+ break;
110
+ }
111
+ const tagEnd = template[j - 1] === "/" ? j - 1 : j;
112
+ const attrs = template.slice(attrsStart, tagEnd);
113
+ const closing = template.slice(tagEnd, j + 1);
114
+ result += `<${tagName}${migrateAttributeString(attrs, applicable)}${closing}`;
115
+ i = j + 1;
116
+ }
117
+ return result;
118
+ }
@@ -21,10 +21,11 @@ export function useLocalStorageModel(model, props, options) {
21
21
  function read() {
22
22
  if (!storageKey.value) return void 0;
23
23
  try {
24
- return ls.get(storageKey.value, {
25
- decrypt: !!props.persistEncrypt,
26
- secret: props.persistSecret
27
- });
24
+ const opts = {
25
+ decrypt: !!props.persistEncrypt
26
+ };
27
+ if (props.persistSecret !== void 0) opts.secret = props.persistSecret;
28
+ return ls.get(storageKey.value, opts);
28
29
  } catch {
29
30
  return void 0;
30
31
  }
@@ -32,12 +33,13 @@ export function useLocalStorageModel(model, props, options) {
32
33
  function write(val) {
33
34
  if (!storageKey.value) return;
34
35
  try {
35
- ls.set(storageKey.value, serializer(val), {
36
+ const opts = {
36
37
  ttl: props.persistTtl,
37
38
  // seconds
38
- encrypt: !!props.persistEncrypt,
39
- secret: props.persistSecret
40
- });
39
+ encrypt: !!props.persistEncrypt
40
+ };
41
+ if (props.persistSecret !== void 0) opts.secret = props.persistSecret;
42
+ ls.set(storageKey.value, serializer(val), opts);
41
43
  } catch {
42
44
  }
43
45
  }
@@ -31,15 +31,15 @@ export function useLookupListMaster(props) {
31
31
  );
32
32
  });
33
33
  const formatItemTitle = (item) => {
34
+ const raw = item?.raw ?? item ?? {};
35
+ const resolvedTitle = item?.title || raw?.[itemTitleField.value] || raw?.itemValue || raw?.itemCode;
34
36
  if (props.meilisearch) {
35
- const raw = item?._formatted ?? {};
36
- const code = raw?.itemCode;
37
- const title = raw?.[itemTitleField.value] ?? raw?.itemValue ?? raw?.itemCode;
37
+ const formatted = raw?._formatted ?? {};
38
+ const code = formatted?.itemCode ?? raw?.itemCode;
39
+ const title = formatted?.[itemTitleField.value] || formatted?.itemValue || formatted?.itemCode || resolvedTitle;
38
40
  return (props.showCode ? (code ?? "") + "-" : "") + (title ?? "");
39
41
  } else {
40
- const code = item?.itemCode;
41
- const title = item.title ?? item?.itemValue ?? item?.itemCode;
42
- return (props.showCode ? (code ?? "") + "-" : "") + (title ?? "");
42
+ return (props.showCode ? (raw?.itemCode ?? "") + "-" : "") + (resolvedTitle ?? "");
43
43
  }
44
44
  };
45
45
  const computedNoDataText = computed(() => {
@@ -0,0 +1,41 @@
1
+ /**
2
+ * usePerPagePreference persists a paginated component's items-per-page selection
3
+ * in localStorage, namespaced by the currently logged-in user and the active
4
+ * route. Built on top of `useLocalStorageModel` so it inherits the existing
5
+ * encryption + TTL + debounced-write behavior backed by `localstorage-slim`.
6
+ *
7
+ * The full storage key is composed as:
8
+ *
9
+ * nuxt-commons:perPage:<username|anon>:<route.path>:<storageKey>
10
+ *
11
+ * If `storageKey` is empty/undefined, persistence is disabled and the function
12
+ * is a no-op (so callers can opt-in safely without breaking existing usage).
13
+ */
14
+ import { type Ref } from 'vue';
15
+ export interface PerPagePreferenceOptions {
16
+ /**
17
+ * Logical key identifying the table/iterator instance (e.g. `modelName`).
18
+ * Persistence is disabled when this is empty.
19
+ */
20
+ storageKey?: string;
21
+ /** TTL in seconds — defaults to 90 days. */
22
+ ttlSeconds?: number;
23
+ /** Encrypt the stored value via localstorage-slim — defaults to `true`. */
24
+ encrypt?: boolean;
25
+ /** Optional encryption secret. When omitted, localstorage-slim derives one. */
26
+ secret?: string;
27
+ /** Debounce window for writes, in ms — defaults to 200. */
28
+ debounceMs?: number;
29
+ /**
30
+ * Explicit opt-out switch — when `false`, the composable becomes a no-op
31
+ * even if `storageKey` is set.
32
+ */
33
+ enabled?: boolean;
34
+ }
35
+ export declare function usePerPagePreference(perPage: Ref<string | number | undefined>, options?: PerPagePreferenceOptions): {
36
+ isHydrated: Ref<boolean, boolean>;
37
+ storageKey: import("vue").ComputedRef<string>;
38
+ remove: () => void;
39
+ read: () => unknown;
40
+ write: (val: string | number | undefined) => void;
41
+ };
@@ -0,0 +1,55 @@
1
+ import { computed } from "vue";
2
+ import { useState, useRoute } from "#imports";
3
+ import { useLocalStorageModel } from "./localStorageModel.js";
4
+ function safeRoutePath() {
5
+ try {
6
+ const route = useRoute();
7
+ return route?.path || "";
8
+ } catch {
9
+ return "";
10
+ }
11
+ }
12
+ const DEFAULT_TTL_SECONDS = 90 * 24 * 60 * 60;
13
+ export function usePerPagePreference(perPage, options = {}) {
14
+ const authState = useState("authentication", () => ({}));
15
+ const username = computed(() => {
16
+ const profile = authState.value?.userProfile;
17
+ return profile?.username?.trim() || "anon";
18
+ });
19
+ const routePath = computed(() => safeRoutePath());
20
+ const persistKey = computed(() => {
21
+ const key = options.storageKey?.trim();
22
+ if (!key || options.enabled === false) return "";
23
+ return `${username.value}:${routePath.value}:${key}`;
24
+ });
25
+ return useLocalStorageModel(
26
+ perPage,
27
+ {
28
+ persist: !!persistKey.value,
29
+ persistKey: persistKey.value,
30
+ persistPrefix: "nuxt-commons:perPage",
31
+ persistEncrypt: options.encrypt ?? true,
32
+ persistSecret: options.secret,
33
+ persistTtl: options.ttlSeconds ?? DEFAULT_TTL_SECONDS,
34
+ persistDebounce: options.debounceMs ?? 200,
35
+ persistClearOnEmpty: false,
36
+ // A stored preference should win over the component's prop default
37
+ // (which often runs `immediate: true` on mount and populates the ref
38
+ // before onMounted hydrates).
39
+ persistAlwaysHydrate: true
40
+ },
41
+ {
42
+ isEmpty: (v) => v === void 0 || v === null || v === "",
43
+ // useLocalStorageModel hydrates whenever stored !== undefined, but
44
+ // localstorage-slim returns `null` for missing keys. Without this filter
45
+ // a first-time render with no stored entry would clobber the prop default
46
+ // (e.g. `itemsPerPage: 12`) with `null`.
47
+ deserializer: (raw) => {
48
+ if (raw === null || raw === void 0) {
49
+ throw new Error("skip hydration: no stored preference");
50
+ }
51
+ return raw;
52
+ }
53
+ }
54
+ );
55
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramathibodi/nuxt-commons",
3
- "version": "4.0.7",
3
+ "version": "4.0.9",
4
4
  "description": "Ramathibodi Nuxt modules for common components",
5
5
  "repository": {
6
6
  "type": "git",