frappe-ui 0.1.98 → 0.1.101

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frappe-ui",
3
- "version": "0.1.98",
3
+ "version": "0.1.101",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "main": "./src/index.js",
6
6
  "scripts": {
@@ -60,7 +60,7 @@
60
60
  "typescript": "^5.0.2"
61
61
  },
62
62
  "peerDependencies": {
63
- "vue": ">=3.3.0",
63
+ "vue": ">=3.5.0",
64
64
  "vue-router": "^4.1.6"
65
65
  },
66
66
  "devDependencies": {
@@ -29,7 +29,11 @@
29
29
  :id="id"
30
30
  v-bind="{ ...controlAttrs, size }"
31
31
  />
32
- <TextInput v-else :id="id" v-bind="{ ...controlAttrs, type, size }">
32
+ <TextInput
33
+ v-else
34
+ :id="id"
35
+ v-bind="{ ...controlAttrs, type, size, required }"
36
+ >
33
37
  <template #prefix v-if="$slots.prefix">
34
38
  <slot name="prefix" />
35
39
  </template>
@@ -1,17 +1,16 @@
1
1
  <script setup>
2
2
  import { reactive, h, ref } from 'vue'
3
- import Avatar from './Avatar.vue'
4
- import Badge from './Badge.vue'
5
- import { Button } from './Button'
6
- import FeatherIcon from './FeatherIcon.vue'
7
- import ListHeader from './ListView/ListHeader.vue'
8
- import ListHeaderItem from './ListView/ListHeaderItem.vue'
9
- import ListRow from './ListView/ListRow.vue'
10
- import ListRowItem from './ListView/ListRowItem.vue'
11
- import ListRows from './ListView/ListRows.vue'
12
- import ListGroups from './ListView/ListGroups.vue'
13
- import ListSelectBanner from './ListView/ListSelectBanner.vue'
14
- import ListView from './ListView/ListView.vue'
3
+ import Avatar from '../Avatar.vue'
4
+ import Badge from '../Badge.vue'
5
+ import { Button } from '../Button'
6
+ import FeatherIcon from '../FeatherIcon.vue'
7
+ import ListHeader from './ListHeader.vue'
8
+ import ListHeaderItem from './ListHeaderItem.vue'
9
+ import ListRow from './ListRow.vue'
10
+ import ListRowItem from './ListRowItem.vue'
11
+ import ListRows from './ListRows.vue'
12
+ import ListSelectBanner from './ListSelectBanner.vue'
13
+ import ListView from './ListView.vue'
15
14
 
16
15
  const state = reactive({
17
16
  selectable: true,
@@ -143,4 +143,11 @@ provide(
143
143
  toggleAllRows,
144
144
  })),
145
145
  )
146
+
147
+ defineExpose({
148
+ selections,
149
+ allRowsSelected,
150
+ toggleRow,
151
+ toggleAllRows,
152
+ })
146
153
  </script>
@@ -0,0 +1,81 @@
1
+ <template>
2
+ <TabList
3
+ class="relative flex"
4
+ :class="
5
+ vertical
6
+ ? 'flex-col border-r overflow-y-auto'
7
+ : 'gap-7.5 border-b overflow-x-auto items-center px-5'
8
+ "
9
+ >
10
+ <Tab
11
+ ref="tabRef"
12
+ as="template"
13
+ v-for="(tab, i) in tabs"
14
+ :key="i"
15
+ v-slot="{ selected }"
16
+ class="focus:outline-none focus:transition-none"
17
+ >
18
+ <slot v-bind="{ tab, selected }">
19
+ <button
20
+ class="flex items-center gap-1.5 text-base text-ink-gray-5 duration-300 ease-in-out hover:text-ink-gray-9"
21
+ :class="[
22
+ selected ? 'text-ink-gray-9' : '',
23
+ vertical
24
+ ? 'py-2.5 px-4 border-r border-transparent hover:border-outline-gray-3'
25
+ : 'py-3 border-b border-transparent hover:border-outline-gray-3',
26
+ ]"
27
+ >
28
+ <component v-if="tab.icon" :is="tab.icon" class="size-4" />
29
+ {{ tab.label }}
30
+ </button>
31
+ </slot>
32
+ </Tab>
33
+ <div
34
+ ref="indicator"
35
+ class="tab-indicator absolute bg-surface-gray-7"
36
+ :class="[vertical ? 'right-0 w-px' : 'bottom-0 h-px', transitionClass]"
37
+ />
38
+ </TabList>
39
+ </template>
40
+ <script setup>
41
+ import { TabList, Tab } from '@headlessui/vue'
42
+ import { ref, watch, computed, onMounted, nextTick, inject } from 'vue'
43
+
44
+ const tabIndex = inject('tabIndex')
45
+ const tabs = inject('tabs')
46
+ const vertical = inject('vertical')
47
+
48
+ const tabRef = ref([])
49
+ const indicator = ref(null)
50
+ const tabsLength = computed(() => tabs.value?.length)
51
+
52
+ const transitionClass = ref('')
53
+
54
+ function moveIndicator(index) {
55
+ if (index >= tabsLength.value) {
56
+ index = tabsLength.value - 1
57
+ }
58
+ const selectedTab = tabRef.value[index].el
59
+ if (vertical) {
60
+ indicator.value.style.height = `${selectedTab.offsetHeight}px`
61
+ indicator.value.style.top = `${selectedTab.offsetTop}px`
62
+ } else {
63
+ indicator.value.style.width = `${selectedTab.offsetWidth}px`
64
+ indicator.value.style.left = `${selectedTab.offsetLeft}px`
65
+ }
66
+ }
67
+
68
+ watch(tabIndex, (index) => {
69
+ if (index >= tabsLength.value) {
70
+ tabIndex.value = tabsLength.value - 1
71
+ }
72
+ transitionClass.value = 'transition-all duration-300 ease-in-out'
73
+ nextTick(() => moveIndicator(index))
74
+ })
75
+
76
+ onMounted(() => {
77
+ nextTick(() => moveIndicator(tabIndex.value))
78
+ // Fix for indicator not moving on initial load
79
+ setTimeout(() => moveIndicator(tabIndex.value), 100)
80
+ })
81
+ </script>
@@ -0,0 +1,17 @@
1
+ <template>
2
+ <TabPanels class="flex flex-1 overflow-hidden">
3
+ <TabPanel
4
+ class="flex flex-1 flex-col overflow-y-auto focus:outline-none"
5
+ v-for="(tab, i) in tabs"
6
+ :key="i"
7
+ >
8
+ <slot v-bind="{ tab }" />
9
+ </TabPanel>
10
+ </TabPanels>
11
+ </template>
12
+ <script setup>
13
+ import { TabPanels, TabPanel } from '@headlessui/vue'
14
+ import { inject } from 'vue'
15
+
16
+ const tabs = inject('tabs')
17
+ </script>
@@ -0,0 +1,97 @@
1
+ ## Props
2
+
3
+ ### tabs
4
+
5
+ It is an array of objects which contains the following attributes:
6
+
7
+ 1. `label` is the name of the tab, it is required.
8
+ 2. `icon` is the icon to be shown in the tab, it accept component and it is
9
+ optional.
10
+ 3. You can add more attributes which can be used for custom rendering in the tab
11
+ header or content.
12
+
13
+ ### v-model
14
+
15
+ It is used to set the active tab or change the active tab. It is required.
16
+
17
+ ### vertical
18
+
19
+ It is used to show the tabs vertically. It is optional.
20
+
21
+ ### as
22
+
23
+ You can set it to `div` to wrap tabs in a `div`. It can be any valid HTML tag.
24
+ This is useful to control the layout of the tabs. It is optional.
25
+
26
+ 1. `as="div"` or any valid HTML tag
27
+
28
+ ```html
29
+ <div>
30
+ <!-- container div -->
31
+ <div>
32
+ <div active>Tab 1</div>
33
+ <div>Tab 2</div>
34
+ <div>Tab 3</div>
35
+ </div>
36
+ <div>
37
+ <div active>Content 1</div>
38
+ <div>Content 2</div>
39
+ <div>Content 3</div>
40
+ </div>
41
+ </div>
42
+ ```
43
+
44
+ 2. `as` is not set
45
+
46
+ ```html
47
+ <div>
48
+ <div active>Tab 1</div>
49
+ <div>Tab 2</div>
50
+ <div>Tab 3</div>
51
+ </div>
52
+ <div>
53
+ <div active>Content 1</div>
54
+ <div>Content 2</div>
55
+ <div>Content 3</div>
56
+ </div>
57
+ ```
58
+
59
+ ## Slots
60
+
61
+ 1. **tab-item:** You can use this slot to render custom tab items. It is
62
+ optional.
63
+ 2. **tab-panel:** You can use this slot to render custom tab panels. It is
64
+ required. Example:
65
+
66
+ ```vue
67
+ <Tabs v-model="tabIndex" :tabs="tabs">
68
+ <template #tab-item="{ tab, selected }">
69
+ <div :class="{ 'text-gray-900 font-semibold': selected }">
70
+ <span>{{ tab.label }}</span>
71
+ <span>{{ tab.icon }}</span>
72
+ </div>
73
+ </template>
74
+ <template #tab-panel="{ tab }">
75
+ <div>{{ tab.content }}</div>
76
+ </template>
77
+ </Tabs>
78
+ ```
79
+
80
+ ## Layout Customization
81
+
82
+ You can customize the layout of the tabs by using `<TabList />` and `<TabPanels />`
83
+ components.
84
+
85
+ ```vue
86
+ <Tabs v-model="tabIndex" :tabs="tabs">
87
+ <TabList v-slot="{ tab, selected }">
88
+ <div :class="{ 'text-gray-900 font-semibold': selected }">
89
+ <span>{{ tab.label }}</span>
90
+ <span>{{ tab.icon }}</span>
91
+ </div>
92
+ </TabList>
93
+ <TabPanel v-slot="{ tab }">
94
+ <div>{{ tab.content }}</div>
95
+ </TabPanel>
96
+ </Tabs>
97
+ ```
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { h, reactive } from 'vue'
3
3
  import Tabs from './Tabs.vue'
4
- import FeatherIcon from './FeatherIcon.vue'
4
+ import FeatherIcon from '../FeatherIcon.vue'
5
5
  const state = reactive({
6
6
  index: 0,
7
7
  tabs_without_icon: [
@@ -48,20 +48,45 @@ const state = reactive({
48
48
  <Story :layout="{ type: 'grid', width: '80%' }">
49
49
  <Variant title="Without Icon">
50
50
  <Tabs
51
- v-slot="{ tab }"
51
+ as="div"
52
+ class="border rounded"
52
53
  v-model="state.index"
53
54
  :tabs="state.tabs_without_icon"
54
55
  >
55
- <div class="p-5">
56
- {{ tab.content }}
57
- </div>
56
+ <template #tab-panel="{ tab }">
57
+ <div class="p-5">
58
+ {{ tab.content }}
59
+ </div>
60
+ </template>
58
61
  </Tabs>
59
62
  </Variant>
60
63
  <Variant title="With Icon">
61
- <Tabs v-slot="{ tab }" v-model="state.index" :tabs="state.tabs_with_icon">
62
- <div class="p-5">
63
- {{ tab.content }}
64
- </div>
64
+ <Tabs
65
+ as="div"
66
+ class="border rounded"
67
+ v-model="state.index"
68
+ :tabs="state.tabs_with_icon"
69
+ >
70
+ <template #tab-panel="{ tab }">
71
+ <div class="p-5">
72
+ {{ tab.content }}
73
+ </div>
74
+ </template>
75
+ </Tabs>
76
+ </Variant>
77
+ <Variant title="Vertical Tabs">
78
+ <Tabs
79
+ as="div"
80
+ class="border rounded"
81
+ v-model="state.index"
82
+ :tabs="state.tabs_with_icon"
83
+ vertical
84
+ >
85
+ <template #tab-panel="{ tab }">
86
+ <div class="p-5">
87
+ {{ tab.content }}
88
+ </div>
89
+ </template>
65
90
  </Tabs>
66
91
  </Variant>
67
92
 
@@ -0,0 +1,52 @@
1
+ <template>
2
+ <TabGroup
3
+ v-bind="
4
+ as !== 'template'
5
+ ? {
6
+ as,
7
+ class: ['flex flex-1 overflow-hidden', vertical ? '' : 'flex-col '],
8
+ }
9
+ : {}
10
+ "
11
+ :defaultIndex="tabIndex"
12
+ :selectedIndex="tabIndex"
13
+ @change="(idx) => (tabIndex = idx)"
14
+ >
15
+ <slot>
16
+ <TabList v-slot="{ tab, selected }">
17
+ <slot name="tab-item" v-bind="{ tab, selected }" />
18
+ </TabList>
19
+ <TabPanel v-slot="{ tab }">
20
+ <slot name="tab-panel" v-bind="{ tab }" />
21
+ </TabPanel>
22
+ </slot>
23
+ </TabGroup>
24
+ </template>
25
+
26
+ <script setup>
27
+ import TabList from './TabList.vue'
28
+ import TabPanel from './TabPanel.vue'
29
+ import { TabGroup } from '@headlessui/vue'
30
+ import { provide } from 'vue'
31
+
32
+ const props = defineProps({
33
+ as: {
34
+ type: String,
35
+ default: 'template',
36
+ },
37
+ tabs: {
38
+ type: Array,
39
+ required: true,
40
+ },
41
+ vertical: {
42
+ type: Boolean,
43
+ default: false,
44
+ },
45
+ })
46
+
47
+ const tabIndex = defineModel()
48
+
49
+ provide('tabIndex', tabIndex)
50
+ provide('tabs', props.tabs)
51
+ provide('vertical', props.vertical)
52
+ </script>
@@ -22,6 +22,7 @@
22
22
  :disabled="disabled"
23
23
  :id="id"
24
24
  :value="modelValue"
25
+ :required="required"
25
26
  @input="handleChange"
26
27
  @change="handleChange"
27
28
  v-bind="attrsWithoutClassStyle"
@@ -53,6 +54,7 @@ interface TextInputProps {
53
54
  id?: string
54
55
  modelValue?: string | number
55
56
  debounce?: number
57
+ required?: boolean
56
58
  }
57
59
 
58
60
  defineOptions({
@@ -1,4 +1,4 @@
1
- import { type Ref, type ComputedRef, unref, ref } from 'vue'
1
+ import { Ref, ref, MaybeRefOrGetter, toValue } from 'vue'
2
2
  import { idbStore } from './idbStore'
3
3
 
4
4
  type Doc = {
@@ -49,11 +49,8 @@ class DocStore {
49
49
  }
50
50
  }
51
51
 
52
- getDoc(
53
- doctype: string,
54
- name: string | Ref<string> | ComputedRef<string>,
55
- ): Ref<Doc | null> {
56
- const nameStr = unref(name)
52
+ getDoc(doctype: string, name: MaybeRefOrGetter<string>): Ref<Doc | null> {
53
+ const nameStr = toValue(name)
57
54
  if (!doctype || !nameStr) {
58
55
  throw new Error('doctype and name are required')
59
56
  }
@@ -114,6 +111,10 @@ class DocStore {
114
111
  await this.cleanup(key)
115
112
  }
116
113
 
114
+ removeDoc(doctype: string, name: string) {
115
+ return this.invalidateDoc(doctype, name)
116
+ }
117
+
117
118
  private getKey(doctype: string, name: string): DocKey {
118
119
  return `${doctype.trim()}/${name.trim()}` as DocKey
119
120
  }
@@ -145,7 +145,7 @@ describe('useCall', () => {
145
145
  const call = useCall<Response>({
146
146
  url: url('/api/v2/method/post'),
147
147
  method: 'POST',
148
- refetch: true,
148
+ refetch: false,
149
149
  immediate: false,
150
150
  })
151
151
 
@@ -88,11 +88,15 @@ export function useCall<TResponse, TParams extends BasicParams = undefined>(
88
88
  canAbort,
89
89
  aborted,
90
90
  abort,
91
- execute,
91
+ execute: _execute,
92
92
  onFetchResponse,
93
93
  onFetchError,
94
94
  } = result
95
95
 
96
+ function execute(): Promise<TResponse | null> {
97
+ return _execute().then((r) => data.value)
98
+ }
99
+
96
100
  onFetchResponse(() => {
97
101
  resolve()
98
102
  promise.value = makePromise()
@@ -107,7 +111,9 @@ export function useCall<TResponse, TParams extends BasicParams = undefined>(
107
111
  if (beforeSubmit) {
108
112
  beforeSubmit(params)
109
113
  }
110
- submitParams.value = params
114
+ if (params != null) {
115
+ submitParams.value = params
116
+ }
111
117
  if (!refetch) {
112
118
  return execute()
113
119
  }
@@ -122,14 +128,14 @@ export function useCall<TResponse, TParams extends BasicParams = undefined>(
122
128
 
123
129
  const _data = computed(() => {
124
130
  if (normalizedCacheKey && (out.loading || !out.isFinished)) {
125
- let data = cachedResponse.value as TResponse
131
+ let cachedData = cachedResponse.value as TResponse
126
132
  if (transform) {
127
- let returnValue = transform(data)
133
+ let returnValue = transform(cachedData)
128
134
  if (returnValue !== undefined) {
129
- data = returnValue
135
+ cachedData = returnValue
130
136
  }
131
137
  }
132
- return data
138
+ return cachedData
133
139
  }
134
140
  return data.value
135
141
  })
@@ -1,4 +1,11 @@
1
- import { computed, MaybeRef, reactive, readonly, unref, Ref } from 'vue'
1
+ import {
2
+ computed,
3
+ reactive,
4
+ readonly,
5
+ Ref,
6
+ MaybeRefOrGetter,
7
+ toValue,
8
+ } from 'vue'
2
9
  import { UseFetchOptions } from '@vueuse/core'
3
10
  import { useFrappeFetch } from '../useFrappeFetch'
4
11
  import { useCall } from '../useCall/useCall'
@@ -24,7 +31,7 @@ interface DocMethodOption<T = any>
24
31
 
25
32
  interface UseDocOptions {
26
33
  doctype: string
27
- name: string | MaybeRef<string>
34
+ name: MaybeRefOrGetter<string>
28
35
  baseUrl?: string
29
36
  methods?: Record<string, string | DocMethodOption>
30
37
  immediate?: boolean
@@ -42,15 +49,28 @@ export function useDoc<TDoc extends { name: string }, TMethods = {}>(
42
49
  } = options
43
50
 
44
51
  const url = computed(
45
- () => `${baseUrl}/api/v2/document/${doctype}/${unref(name)}`,
52
+ () => `${baseUrl}/api/v2/document/${doctype}/${toValue(name)}`,
46
53
  )
47
54
 
55
+ type SuccessCallback = (doc: TDoc) => void
56
+ const successCallbacks: SuccessCallback[] = []
57
+ const triggerSuccessCallbacks = (doc: TDoc) => {
58
+ for (let cb of successCallbacks) {
59
+ try {
60
+ cb(doc)
61
+ } catch (e) {
62
+ console.error('Error in onSuccess hook:', e)
63
+ }
64
+ }
65
+ }
66
+
48
67
  const fetchOptions: UseFetchOptions = {
49
68
  immediate,
50
69
  refetch: true,
51
70
  afterFetch(ctx) {
52
71
  docStore.setDoc({ doctype, ...ctx.data })
53
72
  listStore.updateRow(doctype, ctx.data)
73
+ triggerSuccessCallbacks(ctx.data)
54
74
  return ctx
55
75
  },
56
76
  }
@@ -80,13 +100,13 @@ export function useDoc<TDoc extends { name: string }, TMethods = {}>(
80
100
 
81
101
  let callOptions: UseCallOptions = {
82
102
  immediate: false,
83
- refetch: true,
103
+ refetch: false,
84
104
  method: 'POST',
85
105
  ...option,
86
106
  baseUrl,
87
107
  url: computed(
88
108
  () =>
89
- `/api/v2/document/${doctype}/${unref(name)}/method/${option.name}`,
109
+ `/api/v2/document/${doctype}/${toValue(name)}/method/${option.name}`,
90
110
  ),
91
111
  }
92
112
 
@@ -95,17 +115,30 @@ export function useDoc<TDoc extends { name: string }, TMethods = {}>(
95
115
  }
96
116
 
97
117
  let setValue = useCall<TDoc, Partial<TDoc>>({
98
- immediate: false,
99
- refetch: false,
118
+ url: computed(() => `/api/v2/document/${doctype}/${toValue(name)}`),
100
119
  method: 'PUT',
101
120
  baseUrl,
102
- url: computed(() => `/api/v2/document/${doctype}/${unref(name)}`),
121
+ immediate: false,
122
+ refetch: false,
103
123
  onSuccess(data) {
104
124
  docStore.setDoc({ doctype, ...data })
105
125
  listStore.updateRow(doctype, data)
106
126
  },
107
127
  })
108
128
 
129
+ type DeleteResponse = 'ok'
130
+ const delete_ = useCall<DeleteResponse>({
131
+ url: computed(() => `/api/v2/document/${doctype}/${toValue(name)}`),
132
+ method: 'DELETE',
133
+ baseUrl,
134
+ immediate: false,
135
+ refetch: false,
136
+ onSuccess() {
137
+ docStore.removeDoc(doctype, toValue(name))
138
+ listStore.removeRow(doctype, toValue(name))
139
+ },
140
+ })
141
+
109
142
  const doc = docStore.getDoc(doctype, name) as Ref<TDoc | null>
110
143
 
111
144
  let out = reactive({
@@ -121,6 +154,17 @@ export function useDoc<TDoc extends { name: string }, TMethods = {}>(
121
154
  reload: execute,
122
155
  abort,
123
156
  setValue,
157
+ delete: delete_,
158
+ onSuccess: (callback: SuccessCallback) => {
159
+ successCallbacks.push(callback)
160
+ return () => {
161
+ // unsubscribe function
162
+ const index = successCallbacks.indexOf(callback)
163
+ if (index > -1) {
164
+ successCallbacks.splice(index, 1)
165
+ }
166
+ }
167
+ },
124
168
  ...docMethods,
125
169
  })
126
170
 
@@ -13,14 +13,16 @@ export function useDoctype<T>(
13
13
  ) {
14
14
  const insert = useInsert<T>(doctype, options)
15
15
  const delete_ = useDelete(doctype, options)
16
- const runDocMethod = useRunDocMethod(doctype, options)
17
16
  const setValue = useSetValue<T>(doctype, options)
17
+ const runDocMethod = useRunDocMethod(doctype, options)
18
+ const runMethod = useRunMethod(doctype, options)
18
19
 
19
20
  return reactive({
20
21
  insert,
21
22
  delete: delete_,
22
23
  setValue,
23
24
  runDocMethod,
25
+ runMethod,
24
26
  })
25
27
  }
26
28
 
@@ -107,6 +109,54 @@ function useRunDocMethod(doctype: string, options: UseDoctypeOptions = {}) {
107
109
  } as RunDocMethodReturnValue)
108
110
  }
109
111
 
112
+ function useRunMethod(doctype: string, options: UseDoctypeOptions = {}) {
113
+ let { baseUrl = '' } = options
114
+ let url = ref(`/api/v2/method/${doctype}/<method>`)
115
+
116
+ interface RunMethodParams {
117
+ method: string
118
+ validate?: () => string | void
119
+ params?: Record<string, any>
120
+ }
121
+
122
+ type RunMethodReturnValue = ReturnType<typeof useCall> & {
123
+ submit: (params: RunMethodParams) => Promise<any>
124
+ isLoading: (method: string) => boolean
125
+ }
126
+
127
+ let runMethod = useCall<any, RunMethodParams['params']>({
128
+ url,
129
+ method: 'POST',
130
+ immediate: false,
131
+ baseUrl,
132
+ })
133
+
134
+ let validateError = ref<Error | null>(null)
135
+
136
+ return reactive({
137
+ ...runMethod,
138
+ error: computed(() => validateError.value || runMethod.error),
139
+ submit: ({ method, validate, params }: RunMethodParams) => {
140
+ url.value = `/api/v2/method/${doctype}/${method}`
141
+ if (validate) {
142
+ const errorMessage = validate()
143
+ if (errorMessage) {
144
+ validateError.value = new Error(errorMessage)
145
+ return Promise.reject(validateError.value)
146
+ } else {
147
+ validateError.value = null
148
+ }
149
+ }
150
+ return runMethod.submit(params)
151
+ },
152
+ isLoading: (method: string) => {
153
+ return (
154
+ runMethod.loading && url.value === `/api/v2/method/${doctype}/${method}`
155
+ )
156
+ },
157
+ } as RunMethodReturnValue)
158
+ }
159
+
110
160
  function useSetValue<T>(doctype: string, options: UseDoctypeOptions = {}) {
111
161
  let { baseUrl = '' } = options
112
162
  let url = ref(`/api/v2/document/${doctype}/<name>`)
@@ -30,6 +30,13 @@ class ListStore {
30
30
  })
31
31
  }
32
32
 
33
+ removeRow(doctype: string, name: string) {
34
+ this.ensureList(doctype)
35
+ this.byDocType[doctype].forEach((list) => {
36
+ list.removeRow(name)
37
+ })
38
+ }
39
+
33
40
  ensureList(docType: string) {
34
41
  if (!this.byDocType[docType]) {
35
42
  this.byDocType[docType] = []
@@ -1,7 +1,9 @@
1
- import { Reactive, Ref } from 'vue'
1
+ import { MaybeRefOrGetter, Reactive, Ref } from 'vue'
2
2
  import { CacheKey } from '../useCall/types'
3
3
 
4
4
  export type Field = string
5
+ export type LinkField = `${Field}.${Field}` | `${Field}.${Field} as ${string}`
6
+ export type FieldWithAlias = `${Field} as ${string}`
5
7
 
6
8
  export type ChildTableField = {
7
9
  [key: string]: Field[]
@@ -13,7 +15,7 @@ export type FilterValue =
13
15
  | boolean
14
16
  | [string, string | number | boolean | Ref<string | number | boolean>]
15
17
 
16
- export interface ListFilters {
18
+ export interface Filters {
17
19
  [key: Field]: FilterValue
18
20
  }
19
21
 
@@ -25,9 +27,9 @@ export type OrderBy =
25
27
 
26
28
  export interface UseListOptions<T> {
27
29
  doctype: string
28
- fields?: Array<keyof T | ChildTableField>
29
- filters?: Reactive<ListFilters>
30
- orderBy?: OrderBy
30
+ fields?: Array<keyof T | ChildTableField | LinkField | FieldWithAlias | '*'>
31
+ filters?: MaybeRefOrGetter<Filters>
32
+ orderBy?: MaybeRefOrGetter<OrderBy>
31
33
  start?: number
32
34
  limit?: number
33
35
  groupBy?: Field
@@ -38,12 +40,10 @@ export interface UseListOptions<T> {
38
40
  immediate?: boolean
39
41
  refetch?: boolean
40
42
  baseUrl?: string
43
+ url?: `/${string}`
41
44
  transform?: (data: T[]) => T[]
42
45
  onSuccess?: (data: T[]) => void
43
46
  onError?: (error: Error) => void
44
47
  }
45
48
 
46
- export interface UseListResponse<T> {
47
- result: T[]
48
- has_next_page: boolean
49
- }
49
+ export type UseListResponse<T> = T[]
@@ -27,7 +27,7 @@ describe('useList', () => {
27
27
  // Verify initial state
28
28
  expect(users.data).toBe(null)
29
29
  expect(users.error).toBe(null)
30
- expect(users.hasNextPage).toBe(false)
30
+ expect(users.hasNextPage).toBe(true)
31
31
  expect(typeof users.fetch).toBe('function')
32
32
 
33
33
  // fetch
@@ -64,6 +64,8 @@ describe('useList', () => {
64
64
  expect(users.start).toBe(2)
65
65
  expect(users.hasPreviousPage).toBe(true)
66
66
  expect(users.data).toStrictEqual([
67
+ { name: 'User1', email: 'user1@example.com' },
68
+ { name: 'User2', email: 'user2@example.com' },
67
69
  { name: 'User3', email: 'user3@example.com' },
68
70
  { name: 'User4', email: 'user4@example.com' },
69
71
  ])
@@ -1,4 +1,12 @@
1
- import { computed, reactive, readonly, ref, triggerRef } from 'vue'
1
+ import {
2
+ computed,
3
+ MaybeRefOrGetter,
4
+ reactive,
5
+ readonly,
6
+ Ref,
7
+ ref,
8
+ toValue,
9
+ } from 'vue'
2
10
  import {
3
11
  AfterFetchContext,
4
12
  OnFetchErrorContext,
@@ -10,6 +18,7 @@ import { parseFilters, makeGetParams, normalizeCacheKey } from '../utils'
10
18
  import { UseListOptions, UseListResponse } from './types'
11
19
  import { idbStore } from '../idbStore'
12
20
  import { listStore } from './listStore'
21
+ import { docStore } from '../docStore'
13
22
 
14
23
  export function useList<T extends { name: string }>(
15
24
  options: UseListOptions<T>,
@@ -29,34 +38,47 @@ export function useList<T extends { name: string }>(
29
38
  refetch = true,
30
39
  cacheKey,
31
40
  baseUrl = '',
41
+ url = '',
32
42
  transform,
33
43
  } = options
34
44
 
35
45
  const _start = ref(start || 0)
36
46
  const _limit = ref(limit || 20)
37
47
 
38
- const url = computed(() => {
39
- const parsedFilters = parseFilters(filters || {})
48
+ const _url = computed(() => {
49
+ const parsedFilters = parseFilters(filters ? toValue(filters) : {})
50
+ const _fields = fields ? toValue(fields) : []
40
51
  const params = makeGetParams({
41
- fields: fields?.length ? JSON.stringify(fields) : null,
52
+ fields: _fields.length ? JSON.stringify(_fields) : null,
42
53
  filters: parsedFilters ? JSON.stringify(parsedFilters) : null,
43
- order_by: orderBy,
54
+ order_by: toValue(orderBy),
44
55
  start: _start.value,
45
56
  limit: _limit.value,
46
57
  group_by: groupBy,
47
58
  parent: parent,
48
59
  debug: debug,
49
60
  })
61
+ if (url) {
62
+ return `${baseUrl}${url}?${params}`
63
+ }
50
64
  return `${baseUrl}/api/v2/document/${doctype}?${params}`
51
65
  })
52
66
 
67
+ const allData: Ref<T[] | null> = ref(null)
68
+ const hasNextPage = ref(true)
69
+ const hasPreviousPage = computed(() => _start.value > 0)
70
+
53
71
  const fetchOptions: UseFetchOptions = {
54
72
  immediate,
55
73
  refetch,
56
- initialData: initialData
57
- ? { result: initialData, has_next_page: false }
58
- : null,
59
- afterFetch: handleAfterFetch<T>(options),
74
+ initialData: initialData || null,
75
+ afterFetch: handleAfterFetch<T>({
76
+ ...options,
77
+ allData,
78
+ _start,
79
+ _limit,
80
+ hasNextPage,
81
+ }),
60
82
  onFetchError: handleFetchError<T>(options),
61
83
  }
62
84
 
@@ -69,7 +91,7 @@ export function useList<T extends { name: string }>(
69
91
  aborted,
70
92
  abort,
71
93
  execute,
72
- } = useFrappeFetch<UseListResponse<T>>(url, fetchOptions).get()
94
+ } = useFrappeFetch<UseListResponse<T>>(_url, fetchOptions).get()
73
95
 
74
96
  let normalizedCacheKey = normalizeCacheKey(cacheKey, 'useList')
75
97
  let cachedResponse = ref<UseListResponse<T> | null>(null)
@@ -79,24 +101,16 @@ export function useList<T extends { name: string }>(
79
101
  let data = cachedResponse.value
80
102
  if (data) {
81
103
  if (transform) {
82
- let returnValue = transform(data.result as T[])
104
+ let returnValue = transform(data as T[])
83
105
  if (returnValue !== undefined) {
84
106
  return returnValue
85
107
  }
86
108
  }
87
- return data.result
109
+ return data
88
110
  }
89
111
  }
90
- return data.value?.result ?? null
91
- })
92
- const hasNextPage = computed(() => {
93
- if (normalizedCacheKey && (out.loading || !out.isFinished)) {
94
- let data = cachedResponse.value
95
- return data?.has_next_page ?? false
96
- }
97
- return data.value?.has_next_page ?? false
112
+ return allData.value
98
113
  })
99
- const hasPreviousPage = computed(() => _start.value > 0)
100
114
 
101
115
  if (normalizedCacheKey) {
102
116
  idbStore.get(normalizedCacheKey).then((data) => {
@@ -119,24 +133,31 @@ export function useList<T extends { name: string }>(
119
133
  type PartialDoc = Partial<T extends { name: string } ? T : { name: string }>
120
134
 
121
135
  const updateRow = (doc: PartialDoc) => {
122
- if (data.value?.result) {
123
- let changed = false
124
- for (let row of data.value.result) {
125
- if (doc.name && doc.name === row.name) {
126
- for (let key in doc) {
127
- if (key in row) {
128
- row[key] = doc[key]
129
- changed = true
130
- }
136
+ if (allData.value == null) return
137
+ let changed = false
138
+ for (let row of allData.value) {
139
+ if (doc.name && doc.name === row.name) {
140
+ for (let key in doc) {
141
+ if (key in row) {
142
+ row[key] = doc[key]
143
+ changed = true
131
144
  }
132
- break
133
145
  }
134
- }
135
- if (changed) {
136
- data.value.result = [...data.value.result]
137
- triggerRef(data)
146
+ break
138
147
  }
139
148
  }
149
+ if (changed) {
150
+ allData.value = [...allData.value]
151
+ }
152
+ }
153
+
154
+ const removeRow = (name: string) => {
155
+ if (allData.value == null) return
156
+ const index = allData.value.findIndex((row) => row.name === name)
157
+ if (index > -1) {
158
+ allData.value.splice(index, 1)
159
+ allData.value = [...allData.value]
160
+ }
140
161
  }
141
162
 
142
163
  const insert = useCall<T, Partial<T>>({
@@ -149,6 +170,26 @@ export function useList<T extends { name: string }>(
149
170
  },
150
171
  })
151
172
 
173
+ const setValueUrl = ref(`/api/v2/document/${doctype}/<name>`)
174
+
175
+ const setValue = useCall<T, Partial<T>>({
176
+ url: setValueUrl,
177
+ method: 'PUT',
178
+ baseUrl,
179
+ immediate: false,
180
+ refetch: false,
181
+ beforeSubmit(params) {
182
+ if (params?.name) {
183
+ setValueUrl.value = `/api/v2/document/${doctype}/${params.name}`
184
+ }
185
+ },
186
+ onSuccess(data) {
187
+ docStore.setDoc({ doctype, ...data })
188
+ listStore.updateRow(doctype, data)
189
+ if (refetch) execute()
190
+ },
191
+ })
192
+
152
193
  let deleteUrl = ref(`/api/v2/document/${doctype}/<name>`)
153
194
  type DeleteResponse = 'ok'
154
195
  type DeleteParams = { name: string }
@@ -167,9 +208,45 @@ export function useList<T extends { name: string }>(
167
208
  },
168
209
  })
169
210
 
211
+ function useEdit(name: MaybeRefOrGetter<string>) {
212
+ if (!allData.value) {
213
+ throw new Error('Data not found')
214
+ }
215
+ let row = allData.value.find((row) => row.name === toValue(name))
216
+ if (!row) {
217
+ throw new Error(`Couldn't find row with name ${toValue(name)}`)
218
+ }
219
+
220
+ let originalRow = JSON.parse(JSON.stringify(row))
221
+ let doc = reactive(row)
222
+
223
+ const setValue = useCall<T, Partial<T>>({
224
+ url: `/api/v2/document/${doctype}/${toValue(name)}`,
225
+ method: 'PUT',
226
+ baseUrl,
227
+ immediate: false,
228
+ refetch: false,
229
+ onSuccess(data) {
230
+ docStore.setDoc({ doctype, ...data })
231
+ listStore.updateRow(doctype, data)
232
+ },
233
+ })
234
+
235
+ return {
236
+ doc,
237
+ reset: () => {
238
+ for (let key in originalRow) {
239
+ doc[key] = originalRow[key]
240
+ }
241
+ },
242
+ setValue,
243
+ update: () => setValue.submit(doc),
244
+ }
245
+ }
246
+
170
247
  let out = reactive({
171
248
  data: result,
172
- hasNextPage,
249
+ hasNextPage: readonly(hasNextPage),
173
250
  hasPreviousPage,
174
251
  start: readonly(_start),
175
252
  limit: readonly(_limit),
@@ -179,7 +256,7 @@ export function useList<T extends { name: string }>(
179
256
  isFinished,
180
257
  canAbort,
181
258
  aborted,
182
- url,
259
+ url: _url,
183
260
  abort,
184
261
  next,
185
262
  previous,
@@ -187,8 +264,11 @@ export function useList<T extends { name: string }>(
187
264
  fetch: execute,
188
265
  reload: execute,
189
266
  updateRow,
267
+ removeRow,
190
268
  insert,
269
+ setValue,
191
270
  delete: delete_,
271
+ edit: useEdit,
192
272
  })
193
273
 
194
274
  listStore.addList(doctype, out)
@@ -196,19 +276,44 @@ export function useList<T extends { name: string }>(
196
276
  return out
197
277
  }
198
278
 
199
- function handleAfterFetch<T>({
279
+ function handleAfterFetch<T extends { name: string }>({
200
280
  transform,
201
281
  onSuccess,
202
282
  cacheKey,
203
- }: UseListOptions<T>) {
283
+ allData,
284
+ _start,
285
+ _limit,
286
+ hasNextPage,
287
+ }: UseListOptions<T> & {
288
+ allData: Ref<T[] | null>
289
+ _start: Ref<number>
290
+ _limit: Ref<number>
291
+ hasNextPage: Ref<boolean>
292
+ }) {
204
293
  return function (ctx: AfterFetchContext) {
294
+ let resultData = ctx.data
295
+ if (resultData[0]?.name) {
296
+ resultData = resultData.map((item) => ({
297
+ ...item,
298
+ name: String(item.name),
299
+ }))
300
+ }
301
+ hasNextPage.value = resultData.length < _limit.value ? false : true
302
+
205
303
  if (transform) {
206
- const returnValue = transform(ctx.data.result)
304
+ const returnValue = transform(resultData)
207
305
  if (Array.isArray(returnValue)) {
208
- ctx.data.result = returnValue
306
+ resultData = returnValue
209
307
  }
210
308
  }
211
309
 
310
+ if (_start.value === 0) {
311
+ allData.value = resultData as T[]
312
+ } else {
313
+ allData.value = [...(allData.value || []), ...resultData]
314
+ }
315
+ ctx.data = allData.value
316
+
212
317
  let normalizedCacheKey = normalizeCacheKey(cacheKey, 'useList')
213
318
  if (normalizedCacheKey) {
214
319
  idbStore.set(normalizedCacheKey, ctx.data)
@@ -216,7 +321,7 @@ function handleAfterFetch<T>({
216
321
 
217
322
  if (onSuccess) {
218
323
  try {
219
- onSuccess(ctx.data.result)
324
+ onSuccess(allData.value)
220
325
  } catch (e) {
221
326
  console.error('Error in onSuccess hook:', e)
222
327
  }
@@ -1,6 +1,7 @@
1
1
  import { reactive, unref } from 'vue'
2
2
  import { useCall } from '../useCall/useCall'
3
3
  import { UseCallOptions } from '../useCall/types'
4
+ import { docStore } from '../docStore'
4
5
 
5
6
  type UseNewDocOptions = Omit<
6
7
  UseCallOptions,
@@ -18,7 +19,11 @@ export function useNewDoc<T extends object>(
18
19
  ) {
19
20
  let doc = reactive<NewDoc<T>>(initialValues)
20
21
 
21
- const out = useCall<T>({
22
+ type DocResponse = T & {
23
+ name: string
24
+ }
25
+
26
+ const out = useCall<DocResponse>({
22
27
  url: `/api/v2/document/${doctype}`,
23
28
  method: 'POST',
24
29
  params() {
@@ -34,8 +39,19 @@ export function useNewDoc<T extends object>(
34
39
  ...options,
35
40
  })
36
41
 
42
+ function submit() {
43
+ return out
44
+ .submit()
45
+ .then((doc) =>
46
+ docStore
47
+ .setDoc({ doctype, ...(doc as DocResponse) })
48
+ .then(() => docStore.getDoc(doctype, doc.name.toString()).value as T),
49
+ )
50
+ }
51
+
37
52
  return reactive({
38
53
  ...out,
54
+ submit,
39
55
  doc,
40
56
  })
41
57
  }
@@ -1,5 +1,5 @@
1
1
  import { MaybeRef, unref } from 'vue'
2
- import { ListFilters } from './useList/types'
2
+ import { Filters } from './useList/types'
3
3
 
4
4
  export function makeGetParams(params: Record<string, any>) {
5
5
  let url = new URLSearchParams()
@@ -16,8 +16,8 @@ export function isEmptyObject(obj: any) {
16
16
  return Object.keys(obj).length === 0 && obj.constructor === Object
17
17
  }
18
18
 
19
- export function parseFilters(filters: ListFilters): ListFilters | null {
20
- let parsedFilters: ListFilters = {}
19
+ export function parseFilters(filters: Filters): Filters | null {
20
+ let parsedFilters: Filters = {}
21
21
  for (let key in filters) {
22
22
  let value = filters[key]
23
23
  if (Array.isArray(value)) {
package/src/index.js CHANGED
@@ -32,7 +32,9 @@ export { default as Select } from './components/Select.vue'
32
32
  export { default as Spinner } from './components/Spinner.vue'
33
33
  export { default as Switch } from './components/Switch.vue'
34
34
  export { default as TabButtons } from './components/TabButtons.vue'
35
- export { default as Tabs } from './components/Tabs.vue'
35
+ export { default as Tabs } from './components/Tabs/Tabs.vue'
36
+ export { default as TabList } from './components/Tabs/TabList.vue'
37
+ export { default as TabPanel } from './components/Tabs/TabPanel.vue'
36
38
  export { default as TextInput } from './components/TextInput.vue'
37
39
  export { default as Textarea } from './components/Textarea.vue'
38
40
  export {
@@ -59,10 +59,7 @@ export const handlers = [
59
59
  let result = getUsers(listParams)
60
60
 
61
61
  return HttpResponse.json({
62
- data: {
63
- result,
64
- has_next_page: true,
65
- },
62
+ data: result,
66
63
  })
67
64
  }),
68
65
 
@@ -1,15 +0,0 @@
1
- ## Props
2
-
3
- ### Tabs
4
-
5
- It is an array of objects which contains the following attributes:
6
-
7
- 1. `label` is the name of the tab, it is required.
8
- 2. `icon` is the icon to be shown in the tab, it accept component and it is
9
- optional.
10
- 3. You can add more attributes which can be used for custom rendering in the tab
11
- header or content.
12
-
13
- ## v-model
14
-
15
- It is used to set the active tab or change the active tab. It is required.
@@ -1,110 +0,0 @@
1
- <template>
2
- <TabGroup
3
- as="div"
4
- class="flex flex-1 flex-col overflow-y-hidden"
5
- :style="`height: calc(100vh - ${tabListRef?.$el.offsetTop}px)`"
6
- :defaultIndex="changedIndex"
7
- :selectedIndex="changedIndex"
8
- @change="(idx) => (changedIndex = idx)"
9
- >
10
- <TabList
11
- ref="tabListRef"
12
- class="relative flex items-center gap-7.5 overflow-x-auto border-b px-5"
13
- :class="tablistClass"
14
- >
15
- <Tab
16
- ref="tabRef"
17
- as="template"
18
- v-for="(tab, i) in tabs"
19
- :key="i"
20
- v-slot="{ selected }"
21
- class="focus:outline-none focus:transition-none"
22
- >
23
- <slot name="tab" v-bind="{ tab, selected }">
24
- <button
25
- class="flex items-center gap-1.5 border-b border-transparent py-3 text-base text-ink-gray-5 duration-300 ease-in-out hover:border-outline-gray-3 hover:text-ink-gray-9"
26
- :class="{ 'text-ink-gray-9': selected }"
27
- >
28
- <component v-if="tab.icon" :is="tab.icon" class="size-4" />
29
- {{ tab.label }}
30
- </button>
31
- </slot>
32
- </Tab>
33
- <div
34
- ref="indicator"
35
- class="tab-indicator absolute bottom-0 h-px bg-surface-gray-7"
36
- :class="transitionClass"
37
- />
38
- </TabList>
39
- <TabPanels class="flex flex-1 overflow-hidden" :class="tabPanelClass">
40
- <TabPanel
41
- class="flex flex-1 flex-col overflow-y-auto focus:outline-none"
42
- v-for="(tab, i) in tabs"
43
- :key="i"
44
- >
45
- <slot v-bind="{ tab }" />
46
- </TabPanel>
47
- </TabPanels>
48
- </TabGroup>
49
- </template>
50
-
51
- <script setup>
52
- import { TabGroup, TabList, Tab, TabPanels, TabPanel } from '@headlessui/vue'
53
- import { ref, watch, computed, onMounted, nextTick } from 'vue'
54
-
55
- const props = defineProps({
56
- tabs: {
57
- type: Array,
58
- required: true,
59
- },
60
- modelValue: {
61
- type: Number,
62
- default: 0,
63
- },
64
- tablistClass: {
65
- type: String,
66
- default: '',
67
- },
68
- tabPanelClass: {
69
- type: String,
70
- default: '',
71
- },
72
- })
73
-
74
- const emit = defineEmits(['update:modelValue'])
75
-
76
- const changedIndex = computed({
77
- get: () => props.modelValue,
78
- set: (index) => emit('update:modelValue', index),
79
- })
80
-
81
- const tabListRef = ref(null)
82
- const tabRef = ref([])
83
- const indicator = ref(null)
84
- const tabsLength = computed(() => props.tabs?.length)
85
-
86
- const transitionClass = ref('')
87
-
88
- function moveIndicator(index) {
89
- if (index >= tabsLength.value) {
90
- index = tabsLength.value - 1
91
- }
92
- const selectedTab = tabRef.value[index].el
93
- indicator.value.style.width = `${selectedTab.offsetWidth}px`
94
- indicator.value.style.left = `${selectedTab.offsetLeft}px`
95
- }
96
-
97
- watch(changedIndex, (index) => {
98
- if (index >= tabsLength.value) {
99
- changedIndex.value = tabsLength.value - 1
100
- }
101
- transitionClass.value = 'transition-all duration-300 ease-in-out'
102
- nextTick(() => moveIndicator(index))
103
- })
104
-
105
- onMounted(() => {
106
- nextTick(() => moveIndicator(changedIndex.value))
107
- // Fix for indicator not moving on initial load
108
- setTimeout(() => moveIndicator(changedIndex.value), 100)
109
- })
110
- </script>