frappe-ui 0.1.276 → 0.1.277

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.
@@ -443,13 +443,13 @@ function closeEntity(name) {
443
443
  }
444
444
 
445
445
  const moveFile = async () => {
446
- open.value = false
447
446
  emit('success')
448
447
  await move.submit({
449
448
  entity_names: props.entities.map((obj) => obj.name),
450
449
  new_parent: selected.value,
451
450
  team: chosenTeam.value,
452
451
  })
452
+ open.value = false
453
453
  emit('complete')
454
454
  }
455
455
  </script>
@@ -8,7 +8,7 @@
8
8
  {
9
9
  label: 'Confirm',
10
10
  variant: 'solid',
11
- disabled: !newTitle || newTitle === entity.title,
11
+ disabled: !newTitle || newTitle === entity.title || rename.loading,
12
12
  onClick: submit,
13
13
  },
14
14
  ],
@@ -42,7 +42,7 @@ import { Dialog } from '../../../src'
42
42
  import { rename } from '../js/resources'
43
43
 
44
44
  const props = defineProps({ entity: Object, modelValue: String })
45
- const emit = defineEmits(['update:modelValue', 'success'])
45
+ const emit = defineEmits(['success', 'complete'])
46
46
  const dialogType = defineModel()
47
47
  const open = ref(true)
48
48
 
@@ -64,10 +64,18 @@ if (props.entity.is_group || props.entity.doc || props.entity.is_link) {
64
64
  const submit = () => {
65
65
  const formattedTitle =
66
66
  newTitle.value + (file_ext.value ? '.' + file_ext.value : '')
67
- rename.submit({
68
- entity_name: props.entity.name,
69
- new_title: formattedTitle,
70
- })
67
+ rename.submit(
68
+ {
69
+ entity_name: props.entity.name,
70
+ new_title: formattedTitle,
71
+ },
72
+ {
73
+ onSuccess: () => {
74
+ open.value = false
75
+ emit('complete')
76
+ },
77
+ },
78
+ )
71
79
  emit('success', {
72
80
  name: props.entity.name,
73
81
  title: formattedTitle,
@@ -120,7 +120,7 @@ function removeTag(tag: string) {
120
120
  [data-state='active'] {
121
121
  background: var(--surface-gray-1);
122
122
  }
123
- [aria-label='Show popup'] {
123
+ :deep([aria-label='Show popup']) {
124
124
  display: none;
125
125
  }
126
126
  </style>
@@ -1,5 +1,6 @@
1
1
  import { createResource, toast } from '../../../src'
2
- import { prettyData } from '../js/utils'
2
+ import { prettyData, openEntity } from '../js/utils'
3
+
3
4
  export const getTeams = createResource({
4
5
  url: 'drive.api.permissions.get_teams',
5
6
  params: {
@@ -39,9 +40,10 @@ export const updateMoved = (team, new_parent, special) => {
39
40
  export const move = createResource({
40
41
  url: 'drive.api.files.move',
41
42
  onSuccess(data) {
43
+ console.log(data)
42
44
  toast.success('Moved to ' + data.title, {
43
45
  action: {
44
- label: 'Go',
46
+ label: 'Go to folder',
45
47
  onClick: () => {
46
48
  if (!data.special)
47
49
  openEntity({
@@ -26,7 +26,7 @@ export function getFileLink(entity, copy = true) {
26
26
  }
27
27
  if (!copy) return link
28
28
  try {
29
- copyToClipboard(link).then(() => toast.success('Copied to your clipboard!'))
29
+ copyToClipboard(link).then(() => toast.success('Copied to your clipboard.'))
30
30
  } catch (err) {
31
31
  console.error('Failed to copy link:', err)
32
32
  }
@@ -108,3 +108,34 @@ export const formatDate = (date) => {
108
108
 
109
109
  return `${formattedDate}, ${formattedTime}`
110
110
  }
111
+
112
+
113
+ export const openEntity = (entity, new_tab = false) => {
114
+ if (new_tab) {
115
+ return window.open(getFileLink(entity, false), '_blank')
116
+ }
117
+
118
+ if (entity.name === '') {
119
+ if (entity.is_private) window.location.href = '/drive/'
120
+ else window.location.href = '/drive/t/' + entity.team
121
+ } else if (entity.is_group) {
122
+ window.location.href = '/drive/d/' + entity.name
123
+ } else if (entity.is_link) {
124
+ const origin = new URL(entity.path).origin
125
+ if (
126
+ confirm(
127
+ `This will open an external link to ${origin} - are you sure you want to open?`,
128
+ )
129
+ )
130
+ window.open(entity.path, '_blank')
131
+ } else if (entity.mime_type === 'frappe/slides') {
132
+ window.location.href = '/slides/presentation/' + entity.path
133
+ } else if (
134
+ entity.mime_type === 'frappe_doc' ||
135
+ entity.mime_type === 'text/markdown'
136
+ ) {
137
+ window.location.href = '/writer/w/' + entity.name
138
+ } else {
139
+ window.location.href = '/drive/f/' + entity.name
140
+ }
141
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frappe-ui",
3
- "version": "0.1.276",
3
+ "version": "0.1.277",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -90,4 +90,25 @@ describe('Combobox', () => {
90
90
  cy.root().click(0, 0, { force: true })
91
91
  cy.get('@onBlurSpy').should('have.been.called')
92
92
  })
93
+
94
+ it('custom value', () => {
95
+ cy.mount(Combobox, {
96
+ props: {
97
+ options,
98
+ allowCustomValue: true,
99
+ openOnFocus: true,
100
+ 'onUpdate:modelValue': cy.spy().as('onUpdate'),
101
+ },
102
+ })
103
+
104
+ cy.get('[role=option]').should('have.length', 0)
105
+ cy.get('input').focus()
106
+ cy.get('[role=option]').should('have.length', options.length)
107
+
108
+ cy.get('input').type('..')
109
+ cy.get('[role=option]').should('have.length', 1)
110
+ cy.get('[role=option]:first').should('contain.text', 'Create ".."')
111
+ cy.get('[role=option]:first').click()
112
+ cy.get('@onUpdate').should('have.been.calledWith', '..')
113
+ })
93
114
  })
@@ -54,7 +54,6 @@ const emit = defineEmits<{
54
54
  input: (value: string) => void
55
55
  }>()
56
56
 
57
-
58
57
  const searchTerm = ref(getDisplayValue(props.modelValue))
59
58
  const internalModelValue = ref(props.modelValue)
60
59
  const isOpen = ref(false)
@@ -76,10 +75,13 @@ watch(
76
75
  )
77
76
 
78
77
  const onUpdateModelValue = (value: string | null) => {
79
- const selectedOpt = value
78
+ let selectedOpt = value
80
79
  ? allOptionsFlat.value.find((opt) => getKey(opt) === value) || null
81
80
  : null
82
81
 
82
+ selectedOpt =
83
+ !selectedOpt && props.allowCustomValue && value ? value : selectedOpt
84
+
83
85
  if (selectedOpt && isCustomOption(selectedOpt)) {
84
86
  const context = { searchTerm: lastSearchTerm.value }
85
87
  selectedOpt.onClick(context)
@@ -311,9 +313,11 @@ defineSlots<{
311
313
  prefix?: () => any
312
314
 
313
315
  /** Custom slot for individual options, only used if the option has `slotName` */
314
- [slotName: string]: (props: { option: SimpleOption; searchTerm: string }) => any
316
+ [slotName: string]: (props: {
317
+ option: SimpleOption
318
+ searchTerm: string
319
+ }) => any
315
320
  }>()
316
-
317
321
  </script>
318
322
 
319
323
  <template>
@@ -363,11 +367,29 @@ defineSlots<{
363
367
  class="max-h-60 overflow-auto pb-1.5"
364
368
  :class="{ 'px-1.5 pt-1.5': !isGroup(filteredOptions[0]) }"
365
369
  >
370
+ <ComboboxItem
371
+ v-if="
372
+ filteredOptions?.length == 0 && allowCustomValue && searchTerm
373
+ "
374
+ :value="searchTerm"
375
+ class="text-base leading-none text-ink-gray-7 rounded flex items-center h-7 px-2.5 py-1.5 select-none data-[highlighted]:bg-surface-gray-3"
376
+ >
377
+ <span class="flex items-center gap-2">
378
+ Create "{{ searchTerm }}"
379
+ </span>
380
+ </ComboboxItem>
381
+
366
382
  <ComboboxEmpty
367
383
  class="text-ink-gray-5 text-base text-center py-1.5 px-2.5"
384
+ v-else
368
385
  >
369
- {{ searchTerm ? `No results found for "${searchTerm}"` : "No results found" }}
386
+ {{
387
+ searchTerm
388
+ ? `No results found for "${searchTerm}"`
389
+ : 'No results found'
390
+ }}
370
391
  </ComboboxEmpty>
392
+
371
393
  <template
372
394
  v-for="(optionOrGroup, index) in filteredOptions"
373
395
  :key="index"
@@ -0,0 +1,20 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import { Combobox } from 'frappe-ui'
4
+
5
+ const value = ref('')
6
+
7
+ const options = ['John Doe', 'Jane Doe', 'John Smith', 'Jane Smith']
8
+ </script>
9
+
10
+ <template>
11
+ <Combobox
12
+ v-model="value"
13
+ :options="options"
14
+ placeholder="Select an option"
15
+ show-cancel
16
+ :allowCustomValue="true"
17
+ />
18
+
19
+ <div class="text-sm text-gray-600">Selected: {{ value || 'None' }}</div>
20
+ </template>
@@ -51,4 +51,7 @@ export interface ComboboxProps {
51
51
 
52
52
  /** Dropdown placement relative to the input */
53
53
  placement?: 'start' | 'center' | 'end'
54
+
55
+ /** Custom Value if no results found & based on searchterm*/
56
+ allowCustomValue?: boolean
54
57
  }
@@ -4,17 +4,21 @@
4
4
  :class="[
5
5
  roundedClass,
6
6
  isSelected || isActive ? 'bg-surface-gray-2' : '',
7
- isHoverable ? 'cursor-pointer' : '',
8
- isHoverable
7
+ isHoverable && !row.disabled ? 'cursor-pointer' : '',
8
+ isHoverable && !row.disabled
9
9
  ? isSelected || isActive
10
10
  ? 'hover:bg-surface-gray-3'
11
11
  : 'hover:bg-surface-menu-bar'
12
12
  : '',
13
+ row.disabled ? 'pointer-events-none' : '',
13
14
  ]"
14
15
  class="flex flex-col transition-all duration-300 ease-in-out"
15
16
  v-bind="{
16
17
  ...getLinkBindings(),
17
18
  onClick: onRowClick,
19
+ ...(row.disabled
20
+ ? { 'aria-disabled': 'true', tabindex: -1 }
21
+ : {}),
18
22
  }"
19
23
  >
20
24
  <component
@@ -23,6 +27,10 @@
23
27
  >
24
28
  <div
25
29
  class="grid items-center gap-4 px-2"
30
+ :class="{
31
+ 'cursor-not-allowed': row.disabled,
32
+ 'opacity-50': row.disabled,
33
+ }"
26
34
  :style="{
27
35
  height: rowHeight,
28
36
  gridTemplateColumns: getGridTemplateColumns(
@@ -39,16 +47,19 @@
39
47
  >
40
48
  <Checkbox
41
49
  :modelValue="isSelected"
50
+ :disabled="row.disabled"
42
51
  class="cursor-pointer duration-300"
43
52
  @click.stop="handleCheckboxClick"
44
53
  />
45
54
  </div>
55
+
46
56
  <div
47
57
  v-for="(column, i) in list.columns"
48
58
  :key="column.key"
49
59
  :class="[
50
60
  alignmentMap[column.align],
51
61
  i == 0 ? 'text-ink-gray-9' : 'text-ink-gray-7',
62
+ 'overflow-x-hidden',
52
63
  ]"
53
64
  >
54
65
  <slot v-bind="{ idx: i, column, item: row[column.key], isActive }">
@@ -72,6 +83,7 @@
72
83
  </slot>
73
84
  </div>
74
85
  </div>
86
+
75
87
  <div
76
88
  v-if="!isLastRow"
77
89
  class="h-px border-t"
@@ -107,17 +119,16 @@ const rowRoute = computed(
107
119
 
108
120
  const isExternalRoute = computed(() => {
109
121
  if (!rowRoute.value) return false
110
- // Check if it's a URL (string starting with http/https or /)
111
122
  return typeof rowRoute.value === 'string' && rowRoute.value.startsWith('http')
112
123
  })
113
124
 
114
125
  const getLinkComponent = () => {
115
- if (!rowRoute.value) return 'div'
126
+ if (!rowRoute.value || props.row.disabled) return 'div'
116
127
  return isExternalRoute.value ? 'a' : 'router-link'
117
128
  }
118
129
 
119
130
  const getLinkBindings = () => {
120
- if (!rowRoute.value) return {}
131
+ if (!rowRoute.value || props.row.disabled) return {}
121
132
  return isExternalRoute.value
122
133
  ? {
123
134
  href: rowRoute.value,
@@ -136,6 +147,7 @@ const isLastRow = computed(() => {
136
147
  const isSelected = computed(() => {
137
148
  return list.value.selections.has(props.row[list.value.rowKey])
138
149
  })
150
+
139
151
  const isActive = computed(
140
152
  () =>
141
153
  list.value.options.enableActive &&
@@ -155,6 +167,7 @@ const rowHeight = computed(() => {
155
167
 
156
168
  const roundedClass = computed(() => {
157
169
  if (!isSelected.value) return 'rounded'
170
+
158
171
  const selections = [...list.value.selections]
159
172
  let groups = list.value.rows[0]?.group
160
173
  ? list.value.rows.map((k) => k.rows)
@@ -163,15 +176,20 @@ const roundedClass = computed(() => {
163
176
  for (let rows of groups) {
164
177
  let currentIndex = rows.findIndex((k) => k == props.row)
165
178
  if (currentIndex === -1) continue
179
+
166
180
  let atBottom = !selections.includes(rows[currentIndex + 1]?.name)
167
181
  let atTop = !selections.includes(rows[currentIndex - 1]?.name)
182
+
168
183
  return (atBottom ? 'rounded-b ' : '') + (atTop ? 'rounded-t' : '')
169
184
  }
170
185
  })
171
186
 
172
187
  const onRowClick = (event) => {
188
+ if (props.row.disabled) return
189
+
173
190
  if (list.value.options.onRowClick)
174
191
  list.value.options.onRowClick(props.row, event)
192
+
175
193
  if (list.value.activeRow.value === props.row.name) {
176
194
  list.value.activeRow.value = null
177
195
  } else {
@@ -180,22 +198,31 @@ const onRowClick = (event) => {
180
198
  }
181
199
 
182
200
  const handleCheckboxClick = (event) => {
201
+ if (props.row.disabled) return
202
+
183
203
  const value = props.row[list.value.rowKey]
204
+
184
205
  if (event.shiftKey && !list.value.selections.has(value)) {
185
206
  const lastSelected = Array.from(list.value.selections).pop()
207
+
186
208
  const rows = list.value.rows.find((k) => k.group)
187
209
  ? list.value.rows.reduce((acc, curr) => acc.concat(curr.rows), [])
188
210
  : list.value.rows
211
+
189
212
  const lastIndex = rows.findIndex(
190
213
  (k) => lastSelected === k[list.value.rowKey]
191
214
  )
192
215
  const curIndex = rows.findIndex((k) => value === k[list.value.rowKey])
216
+
193
217
  const start = Math.min(lastIndex, curIndex)
194
218
  const end = Math.max(lastIndex, curIndex)
219
+
195
220
  for (let i = start; i <= end; i++) {
221
+ if (rows[i].disabled) continue
196
222
  list.value.selections.add(rows[i][list.value.rowKey])
197
223
  }
198
224
  } else {
225
+ if (props.row.disabled) return
199
226
  list.value.toggleRow(value)
200
227
  }
201
228
  }
@@ -1,10 +1,6 @@
1
1
  <template>
2
2
  <div class="relative flex w-full flex-1 flex-col overflow-x-auto">
3
- <div
4
- class="flex w-max min-w-full flex-col overflow-y-hidden"
5
- :class="$attrs.class"
6
- :style="$attrs.style"
7
- >
3
+ <div class="flex w-max min-w-full flex-col overflow-y-hidden" :class="$attrs.class" :style="$attrs.style">
8
4
  <slot v-bind="{ showGroupedRows, selectable }">
9
5
  <ListHeader />
10
6
  <template v-if="props.rows.length">
@@ -17,6 +13,7 @@
17
13
  </div>
18
14
  </div>
19
15
  </template>
16
+
20
17
  <script setup>
21
18
  import ListEmptyState from './ListEmptyState.vue'
22
19
  import ListHeader from './ListHeader.vue'
@@ -103,13 +100,14 @@ let _options = computed(() => {
103
100
 
104
101
  const allRowsSelected = computed(() => {
105
102
  if (!props.rows.length) return false
106
- if (showGroupedRows.value) {
107
- return (
108
- selections.size ===
109
- props.rows.reduce((acc, row) => acc + row.rows.length, 0)
110
- )
111
- }
112
- return selections.size === props.rows.length
103
+
104
+ const rows = showGroupedRows.value
105
+ ? props.rows.flatMap(r => r.rows)
106
+ : props.rows
107
+
108
+ const total = rows.filter(r => !r.disabled).length
109
+
110
+ return total > 0 && selections.size === total
113
111
  })
114
112
 
115
113
  const selectable = computed(() => {
@@ -123,7 +121,7 @@ let showGroupedRows = computed(() => {
123
121
  })
124
122
 
125
123
  function toggleRow(row) {
126
- if (!selections.delete(row)) {
124
+ if (!selections.delete(row) && !row.disabled) {
127
125
  selections.add(row)
128
126
  }
129
127
  }
@@ -135,11 +133,19 @@ function toggleAllRows(select) {
135
133
  }
136
134
  if (showGroupedRows.value) {
137
135
  props.rows.forEach((row) => {
138
- row.rows.forEach((r) => selections.add(r[props.rowKey]))
136
+ row.rows.forEach((r) => {
137
+ if (!r.disabled) {
138
+ selections.add(r[props.rowKey])
139
+ }
140
+ })
139
141
  })
140
142
  return
141
143
  }
142
- props.rows.forEach((row) => selections.add(row[props.rowKey]))
144
+ props.rows.forEach((row) => {
145
+ if (!row.disabled) {
146
+ selections.add(row[props.rowKey])
147
+ }
148
+ })
143
149
  }
144
150
 
145
151
  provide(
@@ -0,0 +1,57 @@
1
+ <script setup>
2
+ import { h, reactive } from 'vue'
3
+
4
+ import { Avatar, ListView } from 'frappe-ui'
5
+
6
+ const columns = reactive([
7
+ {
8
+ label: 'Name',
9
+ key: 'name',
10
+ width: 3,
11
+ getLabel: ({ row }) => row.name,
12
+ prefix: ({ row }) =>
13
+ h(Avatar, { shape: 'circle', image: row.user_image, size: 'sm' }),
14
+ },
15
+ { label: 'Email', key: 'email', width: '200px' },
16
+ { label: 'Role', key: 'role' },
17
+ { label: 'Status', key: 'status' },
18
+ ])
19
+
20
+ const rows = [
21
+ {
22
+ id: 1,
23
+ disabled: true,
24
+ name: 'John Doe',
25
+ email: 'john@doe.com',
26
+ status: 'Active',
27
+ role: 'Developer',
28
+ user_image: 'https://avatars.githubusercontent.com/u/499550',
29
+ },
30
+ {
31
+ id: 2,
32
+ name: 'Jane Doe',
33
+ email: 'jane@doe.com',
34
+ status: 'Inactive',
35
+ role: 'HR',
36
+ user_image: 'https://avatars.githubusercontent.com/u/499120',
37
+ },
38
+ ]
39
+ </script>
40
+
41
+ <template>
42
+ <ListView
43
+ class="h-[150px]"
44
+ :columns="columns"
45
+ :rows="rows"
46
+ :options="{
47
+ getRowRoute: (row) => ({
48
+ name: 'User',
49
+ params: { userId: row.id },
50
+ }),
51
+ selectable: true,
52
+ showTooltip: true,
53
+ resizeColumn: true,
54
+ }"
55
+ row-key="id"
56
+ />
57
+ </template>
@@ -0,0 +1,7 @@
1
+ <script setup>
2
+ import LucideLoaderCircle from '~icons/lucide/loader-circle'
3
+ </script>
4
+
5
+ <template>
6
+ <LucideLoaderCircle class='animate-spin size-4' />
7
+ </template>
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ export { default as ListItem } from './components/ListItem.vue'
28
28
  export { default as LoadingIndicator } from './components/LoadingIndicator.vue'
29
29
  export { default as LoadingText } from './components/LoadingText.vue'
30
30
  export * from './components/Progress'
31
+ export { default as Spinner } from './components/Spinner.vue'
31
32
  export * from './components/Popover'
32
33
  export * from './components/Rating'
33
34
  export { default as Resource } from './components/Resource.vue'
@@ -78,6 +79,7 @@ export { default as NumberChart } from './components/Charts/NumberChart.vue'
78
79
  export { default as DonutChart } from './components/Charts/DonutChart.vue'
79
80
  export { default as FunnelChart } from './components/Charts/FunnelChart.vue'
80
81
  export { default as ECharts } from './components/Charts/ECharts.vue'
82
+ export { default as useAxisChartOptions } from './components/Charts/axisChartOptions'
81
83
 
82
84
  // directives
83
85
  export { default as onOutsideClickDirective } from './directives/onOutsideClick'