frappe-ui 0.1.97 → 0.1.99

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.97",
3
+ "version": "0.1.99",
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": {
@@ -16,7 +16,7 @@
16
16
 
17
17
  <template #body>
18
18
  <MenuItems
19
- class="mt-2 min-w-40 divide-y divide-outline-gray-1 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
19
+ class="mt-2 min-w-40 divide-y divide-outline-gray-modals rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
20
20
  :class="{
21
21
  'left-0 origin-top-left': placement == 'left',
22
22
  'right-0 origin-top-right': placement == 'right',
@@ -69,7 +69,7 @@
69
69
  </div>
70
70
  <div
71
71
  v-if="!isLastRow"
72
- class="mx-2 h-px border-t border-outline-gray-modals"
72
+ class="mx-2 h-px border-t border-outline-gray-1"
73
73
  />
74
74
  </component>
75
75
  </component>
@@ -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>
@@ -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
  }
@@ -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
  }
@@ -86,7 +106,7 @@ export function useDoc<TDoc extends { name: string }, TMethods = {}>(
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] = []
@@ -13,7 +13,7 @@ export type FilterValue =
13
13
  | boolean
14
14
  | [string, string | number | boolean | Ref<string | number | boolean>]
15
15
 
16
- export interface ListFilters {
16
+ export interface Filters {
17
17
  [key: Field]: FilterValue
18
18
  }
19
19
 
@@ -26,7 +26,7 @@ export type OrderBy =
26
26
  export interface UseListOptions<T> {
27
27
  doctype: string
28
28
  fields?: Array<keyof T | ChildTableField>
29
- filters?: Reactive<ListFilters>
29
+ filters?: Reactive<Filters>
30
30
  orderBy?: OrderBy
31
31
  start?: number
32
32
  limit?: number
@@ -38,6 +38,7 @@ export interface UseListOptions<T> {
38
38
  immediate?: boolean
39
39
  refetch?: boolean
40
40
  baseUrl?: string
41
+ url?: `/${string}`
41
42
  transform?: (data: T[]) => T[]
42
43
  onSuccess?: (data: T[]) => void
43
44
  onError?: (error: Error) => void
@@ -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,13 +38,14 @@ 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(() => {
48
+ const _url = computed(() => {
39
49
  const parsedFilters = parseFilters(filters || {})
40
50
  const params = makeGetParams({
41
51
  fields: fields?.length ? JSON.stringify(fields) : null,
@@ -47,16 +57,21 @@ export function useList<T extends { name: string }>(
47
57
  parent: parent,
48
58
  debug: debug,
49
59
  })
60
+ if (url) {
61
+ return `${baseUrl}${url}?${params}`
62
+ }
50
63
  return `${baseUrl}/api/v2/document/${doctype}?${params}`
51
64
  })
52
65
 
66
+ const allData: Ref<T[] | null> = ref(null)
67
+
53
68
  const fetchOptions: UseFetchOptions = {
54
69
  immediate,
55
70
  refetch,
56
71
  initialData: initialData
57
72
  ? { result: initialData, has_next_page: false }
58
73
  : null,
59
- afterFetch: handleAfterFetch<T>(options),
74
+ afterFetch: handleAfterFetch<T>({ ...options, allData, _start }),
60
75
  onFetchError: handleFetchError<T>(options),
61
76
  }
62
77
 
@@ -69,7 +84,7 @@ export function useList<T extends { name: string }>(
69
84
  aborted,
70
85
  abort,
71
86
  execute,
72
- } = useFrappeFetch<UseListResponse<T>>(url, fetchOptions).get()
87
+ } = useFrappeFetch<UseListResponse<T>>(_url, fetchOptions).get()
73
88
 
74
89
  let normalizedCacheKey = normalizeCacheKey(cacheKey, 'useList')
75
90
  let cachedResponse = ref<UseListResponse<T> | null>(null)
@@ -87,7 +102,7 @@ export function useList<T extends { name: string }>(
87
102
  return data.result
88
103
  }
89
104
  }
90
- return data.value?.result ?? null
105
+ return allData.value
91
106
  })
92
107
  const hasNextPage = computed(() => {
93
108
  if (normalizedCacheKey && (out.loading || !out.isFinished)) {
@@ -119,23 +134,30 @@ export function useList<T extends { name: string }>(
119
134
  type PartialDoc = Partial<T extends { name: string } ? T : { name: string }>
120
135
 
121
136
  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
- }
137
+ if (allData.value == null) return
138
+ let changed = false
139
+ for (let row of allData.value) {
140
+ if (doc.name && doc.name === row.name) {
141
+ for (let key in doc) {
142
+ if (key in row) {
143
+ row[key] = doc[key]
144
+ changed = true
131
145
  }
132
- break
133
146
  }
147
+ break
134
148
  }
135
- if (changed) {
136
- data.value.result = [...data.value.result]
137
- triggerRef(data)
138
- }
149
+ }
150
+ if (changed) {
151
+ allData.value = [...allData.value]
152
+ }
153
+ }
154
+
155
+ const removeRow = (name: string) => {
156
+ if (allData.value == null) return
157
+ const index = allData.value.findIndex((row) => row.name === name)
158
+ if (index > -1) {
159
+ allData.value.splice(index, 1)
160
+ allData.value = [...allData.value]
139
161
  }
140
162
  }
141
163
 
@@ -149,6 +171,26 @@ export function useList<T extends { name: string }>(
149
171
  },
150
172
  })
151
173
 
174
+ const setValueUrl = ref(`/api/v2/document/${doctype}/<name>`)
175
+
176
+ const setValue = useCall<T, Partial<T>>({
177
+ url: setValueUrl,
178
+ method: 'PUT',
179
+ baseUrl,
180
+ immediate: false,
181
+ refetch: false,
182
+ beforeSubmit(params) {
183
+ if (params?.name) {
184
+ setValueUrl.value = `/api/v2/document/${doctype}/${params.name}`
185
+ }
186
+ },
187
+ onSuccess(data) {
188
+ docStore.setDoc({ doctype, ...data })
189
+ listStore.updateRow(doctype, data)
190
+ if (refetch) execute()
191
+ },
192
+ })
193
+
152
194
  let deleteUrl = ref(`/api/v2/document/${doctype}/<name>`)
153
195
  type DeleteResponse = 'ok'
154
196
  type DeleteParams = { name: string }
@@ -167,6 +209,42 @@ export function useList<T extends { name: string }>(
167
209
  },
168
210
  })
169
211
 
212
+ function useEdit(name: MaybeRefOrGetter<string>) {
213
+ if (!allData.value) {
214
+ throw new Error('Data not found')
215
+ }
216
+ let row = allData.value.find((row) => row.name === toValue(name))
217
+ if (!row) {
218
+ throw new Error(`Couldn't find row with name ${toValue(name)}`)
219
+ }
220
+
221
+ let originalRow = JSON.parse(JSON.stringify(row))
222
+ let doc = reactive(row)
223
+
224
+ const setValue = useCall<T, Partial<T>>({
225
+ url: `/api/v2/document/${doctype}/${toValue(name)}`,
226
+ method: 'PUT',
227
+ baseUrl,
228
+ immediate: false,
229
+ refetch: false,
230
+ onSuccess(data) {
231
+ docStore.setDoc({ doctype, ...data })
232
+ listStore.updateRow(doctype, data)
233
+ },
234
+ })
235
+
236
+ return {
237
+ doc,
238
+ reset: () => {
239
+ for (let key in originalRow) {
240
+ doc[key] = originalRow[key]
241
+ }
242
+ },
243
+ setValue,
244
+ update: () => setValue.submit(doc),
245
+ }
246
+ }
247
+
170
248
  let out = reactive({
171
249
  data: result,
172
250
  hasNextPage,
@@ -179,7 +257,7 @@ export function useList<T extends { name: string }>(
179
257
  isFinished,
180
258
  canAbort,
181
259
  aborted,
182
- url,
260
+ url: _url,
183
261
  abort,
184
262
  next,
185
263
  previous,
@@ -187,8 +265,11 @@ export function useList<T extends { name: string }>(
187
265
  fetch: execute,
188
266
  reload: execute,
189
267
  updateRow,
268
+ removeRow,
190
269
  insert,
270
+ setValue,
191
271
  delete: delete_,
272
+ edit: useEdit,
192
273
  })
193
274
 
194
275
  listStore.addList(doctype, out)
@@ -196,19 +277,35 @@ export function useList<T extends { name: string }>(
196
277
  return out
197
278
  }
198
279
 
199
- function handleAfterFetch<T>({
280
+ function handleAfterFetch<T extends { name: string }>({
200
281
  transform,
201
282
  onSuccess,
202
283
  cacheKey,
203
- }: UseListOptions<T>) {
284
+ allData,
285
+ _start,
286
+ }: UseListOptions<T> & {
287
+ allData: Ref<T[] | null>
288
+ _start: Ref<number>
289
+ }) {
204
290
  return function (ctx: AfterFetchContext) {
291
+ let resultData = (ctx.data.result as T[]).map((item) => ({
292
+ ...item,
293
+ name: String(item.name),
294
+ }))
205
295
  if (transform) {
206
- const returnValue = transform(ctx.data.result)
296
+ const returnValue = transform(resultData)
207
297
  if (Array.isArray(returnValue)) {
208
- ctx.data.result = returnValue
298
+ resultData = returnValue
209
299
  }
210
300
  }
211
301
 
302
+ if (_start.value === 0) {
303
+ allData.value = resultData
304
+ } else {
305
+ allData.value = [...(allData.value || []), ...resultData]
306
+ }
307
+ ctx.data.result = allData.value
308
+
212
309
  let normalizedCacheKey = normalizeCacheKey(cacheKey, 'useList')
213
310
  if (normalizedCacheKey) {
214
311
  idbStore.set(normalizedCacheKey, ctx.data)
@@ -216,7 +313,7 @@ function handleAfterFetch<T>({
216
313
 
217
314
  if (onSuccess) {
218
315
  try {
219
- onSuccess(ctx.data.result)
316
+ onSuccess(allData.value)
220
317
  } catch (e) {
221
318
  console.error('Error in onSuccess hook:', e)
222
319
  }
@@ -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 {
@@ -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>