frappe-ui 0.0.2 → 0.0.6

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,17 +1,23 @@
1
1
  {
2
2
  "name": "frappe-ui",
3
- "version": "0.0.2",
3
+ "version": "0.0.6",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "main": "./src/index.js",
6
6
  "scripts": {
7
+ "test": "npx prettier --check ./src",
7
8
  "prettier": "npx prettier -w ./src"
8
9
  },
9
10
  "files": [
10
11
  "src"
11
12
  ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/frappe/frappe-ui.git"
16
+ },
12
17
  "author": "Frappe Technologies Pvt. Ltd.",
13
18
  "license": "MIT",
14
19
  "dependencies": {
20
+ "@headlessui/vue": "^1.4.3",
15
21
  "@popperjs/core": "^2.11.2",
16
22
  "@tailwindcss/forms": "^0.4.0",
17
23
  "@tailwindcss/typography": "^0.5.0",
package/readme.md CHANGED
@@ -5,6 +5,11 @@ A set of components and utilities for rapid UI development.
5
5
  Frappe UI components are built using Vue 3 and Tailwind. Along with components,
6
6
  it also ships with directives and utilities that make UI development easier.
7
7
 
8
+ <details>
9
+ <summary>Show components</summary>
10
+ <img src="https://user-images.githubusercontent.com/9355208/152111395-a62e1599-112c-450f-a32a-5debc791eb2e.png" width="80%" />
11
+ </details>
12
+
8
13
  ## Installation
9
14
  ```sh
10
15
  npm install frappe-ui
@@ -310,4 +315,4 @@ export default {
310
315
  ```
311
316
 
312
317
  ## License
313
- MIT
318
+ MIT
@@ -8,7 +8,7 @@
8
8
  />
9
9
  <div
10
10
  v-else
11
- class="flex items-center justify-center w-full h-full text-green-800 uppercase bg-green-200"
11
+ class="flex items-center justify-center w-full h-full text-gray-600 uppercase bg-gray-200"
12
12
  :class="{ sm: 'text-xs', md: 'text-base', lg: 'text-lg' }[size]"
13
13
  >
14
14
  {{ label && label[0] }}
@@ -8,26 +8,38 @@
8
8
  <LoadingIndicator
9
9
  v-if="loading"
10
10
  :class="{
11
- 'text-white': type == 'primary',
12
- 'text-gray-600': type == 'secondary',
13
- 'text-red-200': type == 'danger',
11
+ 'text-white': appearance == 'primary',
12
+ 'text-gray-600': appearance == 'secondary',
13
+ 'text-red-200': appearance == 'danger',
14
14
  }"
15
15
  />
16
- <FeatherIcon v-else-if="iconLeft" :name="iconLeft" class="w-4 h-4 mr-1.5" />
16
+ <FeatherIcon
17
+ v-else-if="iconLeft"
18
+ :name="iconLeft"
19
+ class="w-4 h-4 mr-1.5"
20
+ aria-hidden="true"
21
+ />
17
22
  <template v-if="loading && loadingText">{{ loadingText }}</template>
18
23
  <template v-else-if="icon">
19
- <FeatherIcon :name="icon" class="w-4 h-4" />
24
+ <FeatherIcon :name="icon" class="w-4 h-4" :aria-label="label" />
20
25
  </template>
21
26
  <span :class="icon ? 'sr-only' : ''">
22
27
  <slot></slot>
23
28
  </span>
24
- <FeatherIcon v-if="iconRight" :name="iconRight" class="w-4 h-4 ml-2" />
29
+ <FeatherIcon
30
+ v-if="iconRight"
31
+ :name="iconRight"
32
+ class="w-4 h-4 ml-2"
33
+ aria-hidden="true"
34
+ />
25
35
  </button>
26
36
  </template>
27
37
  <script>
28
38
  import FeatherIcon from './FeatherIcon.vue'
29
39
  import LoadingIndicator from './LoadingIndicator.vue'
30
40
 
41
+ const ValidAppearances = ['primary', 'secondary', 'danger', 'white', 'minimal']
42
+
31
43
  export default {
32
44
  name: 'Button',
33
45
  components: {
@@ -35,9 +47,16 @@ export default {
35
47
  LoadingIndicator,
36
48
  },
37
49
  props: {
38
- type: {
50
+ label: {
51
+ type: String,
52
+ default: null,
53
+ },
54
+ appearance: {
39
55
  type: String,
40
56
  default: 'secondary',
57
+ validator: (value) => {
58
+ return ValidAppearances.includes(value)
59
+ },
41
60
  },
42
61
  disabled: {
43
62
  type: Boolean,
@@ -71,20 +90,25 @@ export default {
71
90
  },
72
91
  computed: {
73
92
  buttonClasses() {
93
+ let appearanceClasses = {
94
+ primary:
95
+ 'bg-blue-500 hover:bg-blue-600 text-white focus:ring-2 focus:ring-offset-2 focus:ring-blue-500',
96
+ secondary:
97
+ 'bg-gray-100 hover:bg-gray-200 text-gray-900 focus:ring-2 focus:ring-offset-2 focus:ring-gray-500',
98
+ danger:
99
+ 'bg-red-500 hover:bg-red-400 text-white focus:ring-2 focus:ring-offset-2 focus:ring-red-500',
100
+ white:
101
+ 'bg-white text-gray-900 border hover:bg-gray-50 focus:ring-2 focus:ring-offset-2 focus:ring-gray-400',
102
+ minimal:
103
+ 'bg-transparent hover:bg-gray-200 active:bg-gray-200 text-gray-900',
104
+ }
74
105
  return [
75
- 'inline-flex items-center justify-center text-base leading-5 rounded-md focus:outline-none',
106
+ 'inline-flex items-center justify-center text-base leading-5 rounded-md transition-colors focus:outline-none',
76
107
  this.icon ? 'p-1.5' : 'px-3 py-1',
77
- {
78
- 'opacity-50 cursor-not-allowed pointer-events-none': this.isDisabled,
79
- 'bg-gradient-blue hover:bg-gradient-none hover:bg-blue-500 text-white focus:shadow-outline-blue':
80
- this.type === 'primary',
81
- 'bg-gray-100 hover:bg-gray-200 text-gray-900 focus:shadow-outline-gray':
82
- this.type === 'secondary',
83
- 'bg-red-500 hover:bg-red-400 text-white focus:shadow-outline-red':
84
- this.type === 'danger',
85
- 'bg-white text-gray-900 shadow focus:ring focus:ring-gray-400':
86
- this.type === 'white',
87
- },
108
+ this.isDisabled
109
+ ? 'opacity-50 cursor-not-allowed pointer-events-none'
110
+ : '',
111
+ appearanceClasses[this.appearance],
88
112
  ]
89
113
  },
90
114
  isDisabled() {
@@ -1,58 +1,184 @@
1
1
  <template>
2
- <Modal
3
- :show="modelValue"
4
- @change="handleChange"
5
- :dismissable="dismissable"
6
- :full="full"
7
- >
8
- <div class="px-4 pb-4 bg-white sm:px-6 sm:pb-4">
9
- <div class="sm:flex sm:items-start">
10
- <div class="relative w-full sm:text-left">
11
- <div class="sticky top-0 py-4 bg-white">
12
- <h3 class="text-xl font-medium leading-6 text-gray-900">
13
- {{ title }}
14
- </h3>
15
- </div>
16
- <button
17
- v-if="dismissable"
18
- class="absolute top-0 right-0"
19
- @click="handleChange(false)"
2
+ <TransitionRoot as="template" :show="open">
3
+ <HDialog
4
+ as="div"
5
+ class="fixed inset-0 z-10 overflow-y-auto"
6
+ @close="open = false"
7
+ >
8
+ <div
9
+ class="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0"
10
+ >
11
+ <TransitionChild
12
+ as="template"
13
+ enter="ease-out duration-300"
14
+ enter-from="opacity-0"
15
+ enter-to="opacity-100"
16
+ leave="ease-in duration-200"
17
+ leave-from="opacity-100"
18
+ leave-to="opacity-0"
19
+ >
20
+ <DialogOverlay
21
+ class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75"
22
+ />
23
+ </TransitionChild>
24
+
25
+ <!-- This element is to trick the browser into centering the modal contents. -->
26
+ <span
27
+ class="hidden sm:inline-block sm:align-middle sm:h-screen"
28
+ aria-hidden="true"
29
+ >
30
+ &#8203;
31
+ </span>
32
+ <TransitionChild
33
+ as="template"
34
+ enter="ease-out duration-300"
35
+ enter-from="opacity-0 translate-y-4 sm:-translate-y-12 sm:scale-95"
36
+ enter-to="opacity-100 translate-y-0 sm:scale-100"
37
+ leave="ease-in duration-200"
38
+ leave-from="opacity-100 translate-y-0 sm:scale-100"
39
+ leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
40
+ >
41
+ <div
42
+ class="inline-block overflow-hidden text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
20
43
  >
21
- <FeatherIcon name="x" class="w-4 h-4 mt-4" />
22
- </button>
23
- <div class="leading-5 text-gray-800">
24
- <slot></slot>
44
+ <slot name="body">
45
+ <slot name="body-main">
46
+ <div class="px-4 py-5 bg-white sm:p-6">
47
+ <div>
48
+ <div
49
+ v-if="icon"
50
+ class="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto mb-3 rounded-full sm:mx-0 sm:h-10 sm:w-10 sm:mb-0 sm:mr-4"
51
+ :class="{
52
+ 'bg-yellow-100': icon.type === 'warning',
53
+ 'bg-blue-100': icon.type === 'info',
54
+ 'bg-red-100': icon.type === 'danger',
55
+ 'bg-green-100': icon.type === 'success',
56
+ }"
57
+ >
58
+ <FeatherIcon
59
+ :name="icon.name"
60
+ class="w-6 h-6 text-red-600"
61
+ :class="{
62
+ 'text-yellow-600': icon.type === 'warning',
63
+ 'text-blue-600': icon.type === 'info',
64
+ 'text-red-600': icon.type === 'danger',
65
+ 'text-green-600': icon.type === 'success',
66
+ }"
67
+ aria-hidden="true"
68
+ />
69
+ </div>
70
+ <div class="text-center sm:text-left">
71
+ <DialogTitle as="header">
72
+ <slot name="body-title">
73
+ <h3
74
+ class="mb-2 text-lg font-medium leading-6 text-gray-900"
75
+ >
76
+ {{ options.title || 'Untitled' }}
77
+ </h3>
78
+ </slot>
79
+ </DialogTitle>
80
+
81
+ <slot name="body-content">
82
+ <p class="text-sm text-gray-500" v-if="options.message">
83
+ {{ options.message }}
84
+ </p>
85
+ </slot>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ </slot>
90
+ <div
91
+ class="px-4 py-3 space-y-2 sm:space-x-reverse sm:space-x-3 sm:space-y-0 bg-gray-50 sm:px-6 sm:flex sm:flex-row-reverse"
92
+ v-if="options?.actions || $slots.actions"
93
+ >
94
+ <slot name="actions" v-bind="{ close: () => (open = false) }">
95
+ <Button
96
+ v-for="action in options.actions"
97
+ :key="action.label"
98
+ :appearance="action.appearance"
99
+ @click="
100
+ () => {
101
+ if (action.handler && action.handler === 'cancel') {
102
+ open = false
103
+ } else {
104
+ action.handler()
105
+ }
106
+ }
107
+ "
108
+ >
109
+ {{ action.label }}
110
+ </Button>
111
+ </slot>
112
+ </div>
113
+ </slot>
25
114
  </div>
26
- </div>
115
+ </TransitionChild>
27
116
  </div>
28
- </div>
29
- <div
30
- class="sticky bottom-0 flex items-center justify-end p-4 bg-white sm:px-6 sm:py-4"
31
- v-if="$slots.actions"
32
- >
33
- <slot name="actions"></slot>
34
- </div>
35
- </Modal>
117
+ </HDialog>
118
+ </TransitionRoot>
36
119
  </template>
37
120
 
38
121
  <script>
39
- import Modal from './Modal.vue'
40
- import FeatherIcon from './FeatherIcon.vue'
122
+ import { computed } from 'vue'
123
+ import {
124
+ Dialog as HDialog,
125
+ DialogOverlay,
126
+ DialogTitle,
127
+ TransitionChild,
128
+ TransitionRoot,
129
+ } from '@headlessui/vue'
130
+ import { Button, FeatherIcon } from 'frappe-ui'
41
131
 
42
132
  export default {
43
133
  name: 'Dialog',
44
- props: ['title', 'modelValue', 'dismissable', 'full'],
134
+ props: {
135
+ modelValue: {
136
+ type: Boolean,
137
+ required: true,
138
+ },
139
+ options: {
140
+ type: Object,
141
+ default() {
142
+ return {}
143
+ },
144
+ },
145
+ },
45
146
  emits: ['update:modelValue', 'close'],
46
147
  components: {
47
- Modal,
148
+ HDialog,
149
+ DialogOverlay,
150
+ DialogTitle,
151
+ TransitionChild,
152
+ TransitionRoot,
153
+ Button,
48
154
  FeatherIcon,
49
155
  },
50
- methods: {
51
- handleChange(value) {
52
- this.$emit('update:modelValue', value)
53
- if (value === false) {
54
- this.$emit('close')
156
+ setup(props, { emit }) {
157
+ let open = computed({
158
+ get: () => props.modelValue,
159
+ set: (val) => {
160
+ emit('update:modelValue', val)
161
+ if (!val) {
162
+ emit('close')
163
+ }
164
+ },
165
+ })
166
+ return {
167
+ open,
168
+ }
169
+ },
170
+ computed: {
171
+ icon() {
172
+ if (!this.options?.icon) return null
173
+
174
+ let icon = this.options.icon
175
+ if (typeof icon === 'string') {
176
+ icon = {
177
+ name: icon,
178
+ type: 'info',
179
+ }
55
180
  }
181
+ return icon
56
182
  },
57
183
  },
58
184
  }
@@ -1,220 +1,113 @@
1
1
  <template>
2
- <Popover
3
- :show-popup="isShown"
4
- :hide-arrow="true"
5
- :placement="right ? 'bottom-end' : 'bottom-start'"
6
- @init="updateTargetWidth"
7
- >
8
- <template v-slot:target>
9
- <div
10
- class="h-full"
11
- ref="target"
12
- v-on-outside-click="() => (isShown = false)"
13
- >
14
- <slot
15
- :toggleDropdown="toggleDropdown"
16
- :highlightItemUp="highlightItemUp"
17
- :highlightItemDown="highlightItemDown"
18
- :selectHighlightedItem="selectHighlightedItem"
19
- ></slot>
20
- </div>
21
- </template>
22
- <template v-slot:content>
23
- <div
24
- class="z-10 rounded-md w-fullbg-white min-w-40"
25
- :style="{ width: dropdownWidthFull ? targetWidth + 'px' : undefined }"
2
+ <Menu as="div" class="relative inline-block text-left">
3
+ <MenuButton>
4
+ <slot v-if="$slots.default"></slot>
5
+ <Button v-else v-bind="button">
6
+ {{ button ? button?.label || null : 'Options' }}
7
+ </Button>
8
+ </MenuButton>
9
+
10
+ <transition
11
+ enter-active-class="transition duration-100 ease-out"
12
+ enter-from-class="transform scale-95 opacity-0"
13
+ enter-to-class="transform scale-100 opacity-100"
14
+ leave-active-class="transition duration-75 ease-in"
15
+ leave-from-class="transform scale-100 opacity-100"
16
+ leave-to-class="transform scale-95 opacity-0"
17
+ >
18
+ <MenuItems
19
+ class="absolute z-10 mt-2 bg-white divide-y divide-gray-100 rounded-md shadow-lg min-w-40 ring-1 ring-black ring-opacity-5 focus:outline-none"
20
+ :class="
21
+ placement === 'left'
22
+ ? 'left-0 origin-top-left'
23
+ : 'right-0 origin-top-right'
24
+ "
26
25
  >
27
- <div class="p-1 overflow-auto text-sm max-h-64">
28
- <div v-if="isLoading" class="p-2 text-gray-600">
29
- {{ _('Loading...') }}
26
+ <div v-for="group in groups" :key="group.key" class="px-1 py-1">
27
+ <div
28
+ v-if="group.group && !group.hideLabel"
29
+ class="px-2 py-1 text-xs font-semibold tracking-wider text-gray-500 uppercase"
30
+ >
31
+ {{ group.group }}
30
32
  </div>
31
- <template v-else>
32
- <div v-for="d in dropdownItems" :key="d.label">
33
- <div
34
- v-if="d.isGroup"
35
- class="px-2 pt-2 pb-1 text-xs font-semibold tracking-wider text-gray-500 uppercase"
36
- >
37
- {{ d.label }}
38
- </div>
39
- <a
40
- v-else
41
- :ref="setItemRef"
42
- class="block p-2 truncate rounded-md cursor-pointer first:mt-0"
43
- :class="d.index === highlightedIndex ? 'bg-gray-100' : ''"
44
- @mouseenter="highlightedIndex = d.index"
45
- @mouseleave="highlightedIndex = -1"
46
- @click="selectItem(d)"
47
- >
48
- <component :is="d.component" v-if="d.component" />
49
- <template v-else>{{ d.label }}</template>
50
- </a>
51
- </div>
52
- </template>
33
+ <MenuItem
34
+ v-for="item in group.items"
35
+ :key="item.label"
36
+ v-slot="{ active }"
37
+ >
38
+ <button
39
+ :class="[
40
+ active ? 'bg-gray-100' : 'text-gray-900',
41
+ 'group flex rounded-md items-center w-full px-2 py-2 text-sm',
42
+ ]"
43
+ :onClick="item.onClick"
44
+ >
45
+ <FeatherIcon
46
+ v-if="item.icon"
47
+ :name="item.icon"
48
+ class="flex-shrink-0 w-4 h-4 mr-2 text-gray-500"
49
+ aria-hidden="true"
50
+ />
51
+ <span class="whitespace-nowrap">
52
+ {{ item.label }}
53
+ </span>
54
+ </button>
55
+ </MenuItem>
53
56
  </div>
54
- </div>
55
- </template>
56
- </Popover>
57
+ </MenuItems>
58
+ </transition>
59
+ </Menu>
57
60
  </template>
58
61
 
59
62
  <script>
60
- import Popover from './Popover.vue'
61
- import onOutsideClick from '../directives/onOutsideClick'
63
+ import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
64
+ import { FeatherIcon } from 'frappe-ui'
62
65
 
63
66
  export default {
64
- name: 'Dropdown',
65
- directives: {
66
- onOutsideClick,
67
- },
68
- props: {
69
- items: {
70
- type: Array,
71
- default: () => [],
72
- },
73
- groups: {
74
- type: Array,
75
- default: null,
76
- },
77
- right: {
78
- type: Boolean,
79
- default: false,
80
- },
81
- isLoading: {
82
- type: Boolean,
83
- default: false,
84
- },
85
- dropdownWidthFull: {
86
- type: Boolean,
87
- default: false,
88
- },
89
- },
67
+ name: 'NewDropdown',
68
+ props: ['button', 'options', 'placement'],
90
69
  components: {
91
- Popover,
70
+ Menu,
71
+ MenuButton,
72
+ MenuItems,
73
+ MenuItem,
74
+ FeatherIcon,
92
75
  },
93
- data() {
94
- return {
95
- targetWidth: undefined,
96
- isShown: false,
97
- itemRefs: [],
98
- highlightedIndex: -1,
99
- }
100
- },
101
- computed: {
102
- sortedGroups() {
103
- if (Array.isArray(this.groups)) {
104
- return this.groups
76
+ methods: {
77
+ normalizeDropdownItem(option) {
78
+ let onClick = option.handler || null
79
+ if (!onClick && option.route && this.$router) {
80
+ onClick = () => this.$router.push(option.route)
105
81
  }
106
- let groupNames = [
107
- ...new Set(
108
- this.items
109
- .map((d) => d.group)
110
- .filter(Boolean)
111
- .sort()
112
- ),
113
- ]
114
- if (groupNames.length > 0) {
115
- return groupNames
82
+ return {
83
+ label: option.label,
84
+ icon: option.icon,
85
+ group: option.group,
86
+ onClick,
116
87
  }
117
- return null
118
88
  },
89
+ },
90
+ computed: {
119
91
  dropdownItems() {
120
- let items = this.items
92
+ return (this.options || [])
121
93
  .filter(Boolean)
122
- .filter((d) => (d.condition ? d.condition() : true))
123
-
124
- if (this.sortedGroups) {
125
- let itemsByGroup = {}
126
-
127
- for (let item of items) {
128
- let group = item.group || ''
129
- itemsByGroup[group] = itemsByGroup[group] || []
130
- itemsByGroup[group].push(item)
131
- }
132
-
133
- let items = []
134
- let i = 0
135
- for (let group of this.sortedGroups) {
136
- let groupItems = itemsByGroup[group]
137
- groupItems = groupItems.map((d) => {
138
- d.index = i
139
- i++
140
- return d
141
- })
142
- items = items.concat(
143
- {
144
- label: group,
145
- isGroup: true,
146
- },
147
- groupItems
148
- )
149
- }
150
-
151
- return items
152
- }
153
-
154
- return items.filter(Boolean).map((d, i) => {
155
- d.index = i
156
- return d
157
- })
158
- },
159
- },
160
- methods: {
161
- selectItem(d) {
162
- if (d.action) {
163
- d.action()
164
- }
94
+ .filter((option) => (option.condition ? option.condition() : true))
95
+ .map((option) => this.normalizeDropdownItem(option))
165
96
  },
166
- toggleDropdown(flag) {
167
- if (flag == null) {
168
- this.isShown = !this.isShown
169
- } else {
170
- this.isShown = Boolean(flag)
171
- }
172
- },
173
- selectHighlightedItem() {
174
- if (![-1, this.items.length].includes(this.highlightedIndex)) {
175
- // valid selection
176
- let item = this.items[this.highlightedIndex]
177
- this.selectItem(item)
178
- }
179
- },
180
- highlightItemUp() {
181
- this.highlightedIndex -= 1
182
- if (this.highlightedIndex < 0) {
183
- this.highlightedIndex = 0
184
- }
185
- this.$nextTick(() => {
186
- let index = this.highlightedIndex
187
- if (index !== 0) {
188
- index -= 1
189
- }
190
- this.scrollToHighlighted()
191
- })
192
- },
193
- highlightItemDown() {
194
- this.highlightedIndex += 1
195
- if (this.highlightedIndex > this.items.length) {
196
- this.highlightedIndex = this.items.length
197
- }
97
+ groups() {
98
+ let groups = this.options[0]?.group
99
+ ? this.options
100
+ : [{ group: '', items: this.options }]
198
101
 
199
- this.$nextTick(() => {
200
- this.scrollToHighlighted()
201
- })
202
- },
203
- scrollToHighlighted() {
204
- let highlightedElement = this.$refs.items[this.highlightedIndex]
205
- highlightedElement &&
206
- highlightedElement.scrollIntoView({ block: 'nearest' })
207
- },
208
- updateTargetWidth() {
209
- this.$nextTick(() => {
210
- this.targetWidth = this.$refs.target.clientWidth
102
+ return groups.map((group, i) => {
103
+ return {
104
+ key: i,
105
+ group: group.group,
106
+ hideLabel: group.hideLabel || false,
107
+ items: group.items.map((item) => this.normalizeDropdownItem(item)),
108
+ }
211
109
  })
212
110
  },
213
- setItemRef(el) {
214
- if (el) {
215
- this.itemRefs.push(el)
216
- }
217
- },
218
111
  },
219
112
  }
220
113
  </script>
@@ -0,0 +1,220 @@
1
+ <template>
2
+ <div>
3
+ <input
4
+ ref="input"
5
+ type="file"
6
+ :accept="fileTypes"
7
+ class="hidden"
8
+ @change="onFileAdd"
9
+ />
10
+ <slot
11
+ v-bind="{
12
+ file,
13
+ uploading,
14
+ progress,
15
+ uploaded,
16
+ message,
17
+ error,
18
+ total,
19
+ success,
20
+ openFileSelector,
21
+ }"
22
+ />
23
+ </div>
24
+ </template>
25
+
26
+ <script>
27
+ class FileUploader {
28
+ constructor() {
29
+ this.listeners = {}
30
+ }
31
+
32
+ on(event, handler) {
33
+ this.listeners[event] = this.listeners[event] || []
34
+ this.listeners[event].push(handler)
35
+ }
36
+
37
+ trigger(event, data) {
38
+ let handlers = this.listeners[event] || []
39
+ handlers.forEach((handler) => {
40
+ handler.call(this, data)
41
+ })
42
+ }
43
+
44
+ upload(file, options) {
45
+ return new Promise((resolve, reject) => {
46
+ let xhr = new XMLHttpRequest()
47
+ xhr.upload.addEventListener('loadstart', () => {
48
+ this.trigger('start')
49
+ })
50
+ xhr.upload.addEventListener('progress', (e) => {
51
+ if (e.lengthComputable) {
52
+ this.trigger('progress', {
53
+ uploaded: e.loaded,
54
+ total: e.total,
55
+ })
56
+ }
57
+ })
58
+ xhr.upload.addEventListener('load', () => {
59
+ this.trigger('finish')
60
+ })
61
+ xhr.addEventListener('error', () => {
62
+ this.trigger('error')
63
+ reject()
64
+ })
65
+ xhr.onreadystatechange = () => {
66
+ if (xhr.readyState == XMLHttpRequest.DONE) {
67
+ let error
68
+ if (xhr.status === 200) {
69
+ let r = null
70
+ try {
71
+ r = JSON.parse(xhr.responseText)
72
+ } catch (e) {
73
+ r = xhr.responseText
74
+ }
75
+ let out = r.message || r
76
+ resolve(out)
77
+ } else if (xhr.status === 403) {
78
+ error = JSON.parse(xhr.responseText)
79
+ } else {
80
+ this.failed = true
81
+ try {
82
+ error = JSON.parse(xhr.responseText)
83
+ } catch (e) {
84
+ // pass
85
+ }
86
+ }
87
+ if (error && error.exc) {
88
+ console.error(JSON.parse(error.exc)[0])
89
+ }
90
+ reject(error)
91
+ }
92
+ }
93
+ xhr.open('POST', '/api/method/upload_file', true)
94
+ xhr.setRequestHeader('Accept', 'application/json')
95
+ if (window.csrf_token && window.csrf_token !== '{{ csrf_token }}') {
96
+ xhr.setRequestHeader('X-Frappe-CSRF-Token', window.csrf_token)
97
+ }
98
+
99
+ let form_data = new FormData()
100
+ if (file) {
101
+ form_data.append('file', file, file.name)
102
+ }
103
+ form_data.append('is_private', +(options.private || 0))
104
+ form_data.append('folder', options.folder || 'Home')
105
+
106
+ if (options.file_url) {
107
+ form_data.append('file_url', options.file_url)
108
+ }
109
+
110
+ if (options.doctype && options.docname) {
111
+ form_data.append('doctype', options.doctype)
112
+ form_data.append('docname', options.docname)
113
+ if (options.fieldname) {
114
+ form_data.append('fieldname', options.fieldname)
115
+ }
116
+ }
117
+
118
+ if (options.method) {
119
+ form_data.append('method', options.method)
120
+ }
121
+
122
+ if (options.type) {
123
+ form_data.append('type', options.type)
124
+ }
125
+
126
+ xhr.send(form_data)
127
+ })
128
+ }
129
+ }
130
+
131
+ export default {
132
+ name: 'FileUploader',
133
+ props: ['fileTypes', 'uploadArgs', 'type', 'validateFile'],
134
+ data() {
135
+ return {
136
+ uploader: null,
137
+ uploading: false,
138
+ uploaded: 0,
139
+ error: null,
140
+ message: '',
141
+ total: 0,
142
+ file: null,
143
+ finishedUploading: false,
144
+ }
145
+ },
146
+ computed: {
147
+ progress() {
148
+ let value = Math.floor((this.uploaded / this.total) * 100)
149
+ return isNaN(value) ? 0 : value
150
+ },
151
+ success() {
152
+ return this.finishedUploading && !this.error
153
+ },
154
+ },
155
+ methods: {
156
+ openFileSelector() {
157
+ this.$refs['input'].click()
158
+ },
159
+ async onFileAdd(e) {
160
+ this.error = null
161
+ this.file = e.target.files[0]
162
+
163
+ if (this.file && this.validateFile) {
164
+ try {
165
+ let message = await this.validateFile(this.file)
166
+ if (message) {
167
+ this.error = message
168
+ }
169
+ } catch (error) {
170
+ this.error = error
171
+ }
172
+ }
173
+
174
+ if (!this.error) {
175
+ this.uploadFile(this.file)
176
+ }
177
+ },
178
+ async uploadFile(file) {
179
+ this.error = null
180
+ this.uploaded = 0
181
+ this.total = 0
182
+
183
+ this.uploader = new FileUploader()
184
+ this.uploader.on('start', () => {
185
+ this.uploading = true
186
+ })
187
+ this.uploader.on('progress', (data) => {
188
+ this.uploaded = data.uploaded
189
+ this.total = data.total
190
+ })
191
+ this.uploader.on('error', () => {
192
+ this.uploading = false
193
+ this.error = 'Error Uploading File'
194
+ })
195
+ this.uploader.on('finish', () => {
196
+ this.uploading = false
197
+ this.finishedUploading = true
198
+ })
199
+ this.uploader
200
+ .upload(file, this.uploadArgs || {})
201
+ .then((data) => {
202
+ this.$emit('success', data)
203
+ })
204
+ .catch((error) => {
205
+ this.uploading = false
206
+ let errorMessage = 'Error Uploading File'
207
+ if (error._server_messages) {
208
+ errorMessage = JSON.parse(
209
+ JSON.parse(error._server_messages)[0]
210
+ ).message
211
+ } else if (error.exc) {
212
+ errorMessage = JSON.parse(error.exc)[0].split('\n').slice(-2, -1)[0]
213
+ }
214
+ this.error = errorMessage
215
+ this.$emit('failure', error)
216
+ })
217
+ },
218
+ },
219
+ }
220
+ </script>
package/src/index.js CHANGED
@@ -8,6 +8,7 @@ export { default as Dialog } from './components/Dialog.vue'
8
8
  export { default as Dropdown } from './components/Dropdown.vue'
9
9
  export { default as ErrorMessage } from './components/ErrorMessage.vue'
10
10
  export { default as FeatherIcon } from './components/FeatherIcon.vue'
11
+ export { default as FileUploader } from './components/FileUploader.vue'
11
12
  export { default as GreenCheckIcon } from './components/GreenCheckIcon.vue'
12
13
  export { default as Input } from './components/Input.vue'
13
14
  export { default as Link } from './components/Link.vue'
@@ -23,7 +24,8 @@ export { default as SuccessMessage } from './components/SuccessMessage.vue'
23
24
  export { default as onOutsideClickDirective } from './directives/onOutsideClick.js'
24
25
 
25
26
  // utilities
26
- export { default as call } from './utils/call.js'
27
+ export { default as call, createCall } from './utils/call.js'
28
+ export { default as debounce } from './utils/debounce.js'
27
29
 
28
30
  // plugin
29
31
  export { default as FrappeUI } from './utils/plugin.js'
package/src/utils/call.js CHANGED
@@ -1,4 +1,4 @@
1
- export default async function call(method, args, _headers) {
1
+ export default async function call(method, args, options = {}) {
2
2
  if (!args) {
3
3
  args = {}
4
4
  }
@@ -9,7 +9,7 @@ export default async function call(method, args, _headers) {
9
9
  'Content-Type': 'application/json; charset=utf-8',
10
10
  'X-Frappe-Site-Name': window.location.hostname,
11
11
  },
12
- _headers
12
+ options.headers || {}
13
13
  )
14
14
 
15
15
  if (window.csrf_token && window.csrf_token !== '{{ csrf_token }}') {
@@ -27,6 +27,20 @@ export default async function call(method, args, _headers) {
27
27
  if (data.docs || method === 'login') {
28
28
  return data
29
29
  }
30
+ if (data.exc) {
31
+ try {
32
+ console.groupCollapsed(method)
33
+ console.log(`method: ${method}`)
34
+ console.log(`params:`, args)
35
+ let warning = JSON.parse(data.exc)
36
+ for (let text of warning) {
37
+ console.log(text)
38
+ }
39
+ console.groupEnd()
40
+ } catch (e) {
41
+ console.warn('Error printing debug messages', e)
42
+ }
43
+ }
30
44
  return data.message
31
45
  } else {
32
46
  let response = await res.text()
@@ -49,6 +63,7 @@ export default async function call(method, args, _headers) {
49
63
  let e = new Error(errorParts.join('\n'))
50
64
  e.exc_type = error.exc_type
51
65
  e.exc = exception
66
+ e.status = res.status
52
67
  e.messages = error._server_messages
53
68
  ? JSON.parse(error._server_messages)
54
69
  : []
@@ -67,6 +82,16 @@ export default async function call(method, args, _headers) {
67
82
  : ['Internal Server Error']
68
83
  }
69
84
 
85
+ if (options.onError) {
86
+ options.onError({ response: res, status: res.status, error: e })
87
+ }
88
+
70
89
  throw e
71
90
  }
72
91
  }
92
+
93
+ export function createCall(options) {
94
+ return function customCall(method, args) {
95
+ return call(method, args, options)
96
+ }
97
+ }
@@ -0,0 +1,15 @@
1
+ export default function debounce(func, wait, immediate) {
2
+ var timeout
3
+ return function () {
4
+ var context = this,
5
+ args = arguments
6
+ var later = function () {
7
+ timeout = null
8
+ if (!immediate) func.apply(context, args)
9
+ }
10
+ var callNow = immediate && !timeout
11
+ clearTimeout(timeout)
12
+ timeout = setTimeout(later, wait)
13
+ if (callNow) func.apply(context, args)
14
+ }
15
+ }
@@ -11,10 +11,11 @@ let defaultOptions = {
11
11
  export default {
12
12
  install(app, options = {}) {
13
13
  options = Object.assign({}, defaultOptions, options)
14
- options.resources && app.use(resources)
14
+ options.resources && app.use(resources, options.resources)
15
15
 
16
16
  if (options.call) {
17
- app.config.globalProperties.$call = call
17
+ let callFunction = typeof options.call == 'function' ? options.call : call
18
+ app.config.globalProperties.$call = callFunction
18
19
  }
19
20
  if (options.socketio) {
20
21
  app.config.globalProperties.$socket = socket
@@ -1,11 +1,19 @@
1
- import call from './call'
2
- import { onMounted, reactive, watchEffect } from 'vue'
1
+ import { call, debounce } from 'frappe-ui'
2
+ import { reactive, watch } from 'vue'
3
3
 
4
4
  let cached = {}
5
5
 
6
- function createResource(options, vm) {
7
- if (options.cache && cached[options.cache]) {
8
- return cached[options.cache]
6
+ function createResource(options, vm, getResource) {
7
+ let cacheKey = null
8
+ if (options.cache) {
9
+ cacheKey = options.cache
10
+ if (typeof cacheKey === 'string') {
11
+ cacheKey = [cacheKey]
12
+ }
13
+ cacheKey = JSON.stringify(cacheKey)
14
+ if (cached[cacheKey]) {
15
+ return cached[cacheKey]
16
+ }
9
17
  }
10
18
 
11
19
  if (typeof options == 'string') {
@@ -15,79 +23,149 @@ function createResource(options, vm) {
15
23
  }
16
24
  }
17
25
 
26
+ let resourceFetcher = getResource || call
27
+ let fetchFunction = options.debounce
28
+ ? debounce(fetch, options.debounce)
29
+ : fetch
30
+
18
31
  let out = reactive({
19
32
  data: options.initialData || null,
33
+ previousData: null,
20
34
  loading: false,
21
35
  fetched: false,
22
36
  error: null,
37
+ auto: options.auto,
23
38
  params: null,
24
- fetch,
25
- submit,
39
+ fetch: fetchFunction,
40
+ submit: fetchFunction,
41
+ update,
26
42
  })
27
43
 
28
- if (typeof options.params == 'function') {
29
- watchEffect(() => {
30
- out.params = options.params(vm)
31
- if (options.auto) {
32
- fetch()
33
- }
34
- })
35
- } else {
36
- out.params = options.params
37
- }
44
+ async function fetch(params) {
45
+ if (params instanceof Event) {
46
+ params = null
47
+ }
48
+ out.params = params || options.params
49
+ out.previousData = out.data ? JSON.parse(JSON.stringify(out.data)) : null
50
+ out.loading = true
38
51
 
39
- onMounted(() => {
40
- if (options.auto) {
41
- fetch()
52
+ if (options.onFetch) {
53
+ options.onFetch.call(vm, out.params)
54
+ }
55
+
56
+ if (options.validate) {
57
+ let invalidMessage
58
+ try {
59
+ invalidMessage = await options.validate.call(vm, out.params)
60
+ if (invalidMessage && typeof invalidMessage == 'string') {
61
+ let error = new Error(invalidMessage)
62
+ handleError(error)
63
+ out.loading = false
64
+ return
65
+ }
66
+ } catch (error) {
67
+ handleError(error)
68
+ out.loading = false
69
+ return
70
+ }
42
71
  }
43
- })
44
72
 
45
- async function fetch() {
46
- out.loading = true
47
- out.data = options.initialData || null
48
73
  try {
49
- let data = await call(options.method, out.params)
74
+ let data = await resourceFetcher(options.method, params || options.params)
50
75
  out.data = data
51
76
  out.fetched = true
52
77
  if (options.onSuccess) {
53
78
  options.onSuccess.call(vm, data)
54
79
  }
55
80
  } catch (error) {
56
- out.error = error
57
- if (options.onError) {
58
- options.onError.call(vm, error)
59
- }
81
+ handleError(error)
60
82
  }
61
83
  out.loading = false
62
84
  }
63
85
 
64
- function submit(params) {
65
- out.params = params
66
- return fetch()
86
+ function update({ method, params, auto }) {
87
+ if (method && method !== options.method) {
88
+ options.method = method
89
+ }
90
+ if (params && params !== options.params) {
91
+ options.params = params
92
+ }
93
+ if (auto !== undefined && auto !== out.auto) {
94
+ out.auto = auto
95
+ }
96
+ }
97
+
98
+ function handleError(error) {
99
+ console.error(error)
100
+ if (out.previousData) {
101
+ out.data = out.previousData
102
+ }
103
+ out.error = error
104
+ if (options.onError) {
105
+ options.onError.call(vm, error)
106
+ }
67
107
  }
68
108
 
69
- if (options.cache && !cached[options.cache]) {
70
- cached[options.cache] = out
109
+ if (cacheKey && !cached[cacheKey]) {
110
+ cached[cacheKey] = out
71
111
  }
72
112
 
73
113
  return out
74
114
  }
75
115
 
76
- let Resources = {
116
+ let createMixin = (mixinOptions) => ({
77
117
  created() {
78
118
  if (this.$options.resources) {
79
- this._resources = {}
119
+ this._resources = reactive({})
80
120
  for (let key in this.$options.resources) {
81
- this._resources[key] = createResource(
82
- this.$options.resources[key],
83
- this
84
- )
121
+ let options = this.$options.resources[key]
122
+
123
+ if (typeof options == 'function') {
124
+ watch(
125
+ () => options.call(this),
126
+ (updatedOptions, oldVal) => {
127
+ let changed =
128
+ !oldVal ||
129
+ JSON.stringify(updatedOptions) !== JSON.stringify(oldVal)
130
+
131
+ if (!changed) return
132
+
133
+ let resource = this._resources[key]
134
+ if (!resource) {
135
+ resource = createResource(
136
+ updatedOptions,
137
+ this,
138
+ mixinOptions.getResource
139
+ )
140
+ this._resources[key] = resource
141
+ } else {
142
+ resource.update(updatedOptions)
143
+ }
144
+ if (resource.auto) {
145
+ resource.fetch()
146
+ }
147
+ },
148
+ {
149
+ immediate: true,
150
+ }
151
+ )
152
+ } else {
153
+ let resource = createResource(options, this, mixinOptions.getResource)
154
+ this._resources[key] = resource
155
+ if (resource.auto) {
156
+ resource.fetch()
157
+ }
158
+ }
85
159
  }
86
160
  }
87
161
  },
88
162
  methods: {
89
- $resource(key) {
90
- return this._resources[key]
163
+ $refetchResource(cacheKey) {
164
+ let key = JSON.stringify(cacheKey)
165
+ if (cached[key]) {
166
+ let resource = cached[key]
167
+ resource.fetch()
168
+ }
91
169
  },
92
170
  },
93
171
  computed: {
@@ -95,10 +173,11 @@ let Resources = {
95
173
  return this._resources
96
174
  },
97
175
  },
98
- }
176
+ })
99
177
 
100
178
  export default {
101
179
  install(app, options) {
102
- app.mixin(Resources)
180
+ let resourceMixin = createMixin(options)
181
+ app.mixin(resourceMixin)
103
182
  },
104
183
  }
@@ -1,5 +1,3 @@
1
- const plugin = require('tailwindcss/plugin')
2
-
3
1
  module.exports = {
4
2
  theme: {
5
3
  extend: {
@@ -88,7 +86,7 @@ module.exports = {
88
86
  plugins: [
89
87
  require('@tailwindcss/forms'),
90
88
  require('@tailwindcss/typography'),
91
- plugin(function ({ addUtilities, theme }) {
89
+ require('tailwindcss/plugin')(function ({ addUtilities, theme }) {
92
90
  addUtilities({
93
91
  '.bg-gradient-blue': {
94
92
  'background-image': `linear-gradient(180deg,#2c9af1 0%, ${theme(