snapshot of https://github.com/frappe/crm.git (develop, 2026-09-18; blobs > 131072B omitted)
Some checks failed
Frontend / Unit Tests & Coverage (push) Has been skipped
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1m13s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 6s
Server / Tests (frappe ${{ matrix.frappe-branch }}) (push) Has been skipped
Server / Resolve frappe matrix (push) Failing after 1s
Frontend / Lint (push) Failing after 1s
Frontend / Production Build (push) Failing after 1s
Server / Coverage Wrap Up (push) Has been skipped
Frontend / Coverage Wrap Up (push) Has been skipped
Some checks failed
Frontend / Unit Tests & Coverage (push) Has been skipped
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1m13s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 6s
Server / Tests (frappe ${{ matrix.frappe-branch }}) (push) Has been skipped
Server / Resolve frappe matrix (push) Failing after 1s
Frontend / Lint (push) Failing after 1s
Frontend / Production Build (push) Failing after 1s
Server / Coverage Wrap Up (push) Has been skipped
Frontend / Coverage Wrap Up (push) Has been skipped
This commit is contained in:
196
frontend/src/components/Controls/AttachControl.vue
Normal file
196
frontend/src/components/Controls/AttachControl.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<!-- Empty + editable -->
|
||||
<div
|
||||
v-if="!value && !disabled"
|
||||
:class="[containerClasses, 'cursor-pointer']"
|
||||
@click="showUploader = true"
|
||||
>
|
||||
<span
|
||||
class="lucide-paperclip size-4"
|
||||
:class="[iconClasses, 'text-ink-gray-5']"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="whitespace-nowrap text-ink-gray-4">{{
|
||||
__('Attach file…')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Empty + disabled -->
|
||||
<div v-else-if="!value && disabled" :class="containerClasses">
|
||||
<span class="text-ink-gray-4">—</span>
|
||||
</div>
|
||||
|
||||
<!-- Has value -->
|
||||
<div v-else :class="[containerClasses, '!pr-1']">
|
||||
<span
|
||||
class="lucide-paperclip size-4"
|
||||
:class="[iconClasses, 'text-ink-gray-7']"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Tooltip class="min-w-0 flex-1">
|
||||
<template #body>
|
||||
<div v-if="isImage" class="overflow-hidden rounded shadow-xl">
|
||||
<img
|
||||
:src="value"
|
||||
class="max-h-40 max-w-xs object-contain"
|
||||
:alt="filename"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="rounded bg-surface-gray-10 px-2 py-1.5 text-xs text-ink-base shadow-xl"
|
||||
>
|
||||
{{ filename }}
|
||||
</div>
|
||||
</template>
|
||||
<a
|
||||
class="block min-w-0 truncate text-ink-gray-8 hover:underline"
|
||||
:href="value"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ filename }}
|
||||
</a>
|
||||
</Tooltip>
|
||||
<button
|
||||
v-if="!disabled"
|
||||
class="ml-auto flex h-5 w-5 shrink-0 items-center justify-center rounded text-ink-gray-4 hover:bg-surface-gray-2 hover:text-ink-gray-7 dark:hover:bg-surface-gray-4"
|
||||
:title="__('Clear')"
|
||||
@click.prevent="clearAttachment"
|
||||
>
|
||||
<span class="lucide-x h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<FilesUploader
|
||||
v-if="showUploader"
|
||||
v-model="showUploader"
|
||||
:doctype="doctype"
|
||||
:docname="docname"
|
||||
:fieldname="fieldname"
|
||||
:options="uploaderOptions"
|
||||
@after="onAfterUpload"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, useAttrs } from 'vue'
|
||||
import { Tooltip } from 'frappe-ui'
|
||||
import FilesUploader from '@/components/FilesUploader/FilesUploader.vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const IMAGE_EXTENSIONS =
|
||||
/\.(jpe?g|png|gif|webp|svg|avif|bmp|ico|tiff?)(\?.*)?$/i
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: String, default: null },
|
||||
doctype: { type: String, default: '' },
|
||||
docname: { type: String, default: '' },
|
||||
fieldname: { type: String, default: '' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
imageOnly: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
const attrs = useAttrs()
|
||||
|
||||
const showUploader = ref(false)
|
||||
|
||||
// Mirror frappe-ui TextInput size classes
|
||||
const sizeClasses = computed(
|
||||
() =>
|
||||
({
|
||||
sm: 'h-7 text-base rounded',
|
||||
md: 'h-8 text-base rounded',
|
||||
lg: 'h-10 text-lg rounded-md',
|
||||
xl: 'h-10 text-2xl rounded-md',
|
||||
})[attrs.size || 'sm'],
|
||||
)
|
||||
|
||||
// Mirror frappe-ui TextInput padding by size
|
||||
const paddingClasses = computed(
|
||||
() =>
|
||||
({
|
||||
sm: 'px-2',
|
||||
md: 'px-2.5',
|
||||
lg: 'px-3',
|
||||
xl: 'px-3',
|
||||
})[attrs.size || 'sm'],
|
||||
)
|
||||
|
||||
// Mirror frappe-ui TextInput variant + disabled classes
|
||||
const variantClasses = computed(() => {
|
||||
if (props.disabled) {
|
||||
return [
|
||||
'border bg-surface-gray-1 text-ink-gray-5',
|
||||
(attrs.variant || 'subtle') === 'outline'
|
||||
? 'border-outline-gray-2'
|
||||
: 'border-transparent',
|
||||
].join(' ')
|
||||
}
|
||||
return {
|
||||
subtle:
|
||||
'border border-[--surface-gray-2] bg-surface-gray-2 hover:border-outline-elevation-2 hover:bg-surface-gray-3',
|
||||
outline:
|
||||
'border border-outline-gray-2 bg-surface-base hover:border-outline-gray-3 hover:shadow-sm',
|
||||
ghost: 'border-0',
|
||||
}[attrs.variant || 'subtle']
|
||||
})
|
||||
|
||||
const containerClasses = computed(() =>
|
||||
[
|
||||
'flex w-full items-center gap-1.5 overflow-hidden transition-colors',
|
||||
sizeClasses.value,
|
||||
paddingClasses.value,
|
||||
variantClasses.value,
|
||||
attrs.class,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
)
|
||||
|
||||
const iconClasses = computed(
|
||||
() =>
|
||||
({
|
||||
sm: 'h-3 w-3 shrink-0',
|
||||
md: 'h-3.5 w-3.5 shrink-0',
|
||||
lg: 'h-4 w-4 shrink-0',
|
||||
xl: 'h-4 w-4 shrink-0',
|
||||
})[attrs.size || 'sm'],
|
||||
)
|
||||
|
||||
const uploaderOptions = computed(() => {
|
||||
return {
|
||||
folder: 'Home/Attachments',
|
||||
allowMultiple: false,
|
||||
restrictions: {
|
||||
maxNumberOfFiles: 1,
|
||||
...(props.imageOnly ? { allowedFileTypes: ['image/*'] } : {}),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const filename = computed(() => {
|
||||
if (!props.value) return ''
|
||||
const clean = props.value.split('?')[0].split('#')[0]
|
||||
const raw = clean.split('/').pop() || props.value
|
||||
try {
|
||||
return decodeURIComponent(raw)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
})
|
||||
|
||||
const isImage = computed(() => IMAGE_EXTENSIONS.test(props.value || ''))
|
||||
|
||||
function onAfterUpload(uploadedFiles) {
|
||||
if (uploadedFiles && uploadedFiles.length) {
|
||||
emit('change', uploadedFiles[0].file_url)
|
||||
}
|
||||
}
|
||||
|
||||
function clearAttachment() {
|
||||
emit('change', null)
|
||||
}
|
||||
</script>
|
||||
48
frontend/src/components/Controls/ButtonControl.vue
Normal file
48
frontend/src/components/Controls/ButtonControl.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<Button
|
||||
:label="__(label)"
|
||||
:theme="theme"
|
||||
:variant="variant"
|
||||
:icon-left="lucide - icon"
|
||||
:disabled="disabled"
|
||||
v-bind="$attrs"
|
||||
@click.stop="emit('click', $event)"
|
||||
/>
|
||||
</template>
|
||||
<script>
|
||||
export function getButtonTheme(buttonColor) {
|
||||
const themeMap = {
|
||||
Primary: 'gray',
|
||||
Info: 'blue',
|
||||
Success: 'green',
|
||||
Warning: 'gray',
|
||||
Danger: 'red',
|
||||
}
|
||||
return themeMap[buttonColor] || 'gray'
|
||||
}
|
||||
|
||||
export function getButtonVariant(buttonColor) {
|
||||
const variantMap = {
|
||||
Primary: 'solid',
|
||||
Info: 'subtle',
|
||||
Success: 'solid',
|
||||
Warning: 'subtle',
|
||||
Danger: 'solid',
|
||||
}
|
||||
return variantMap[buttonColor] || 'subtle'
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup>
|
||||
import { Button } from 'frappe-ui'
|
||||
|
||||
defineProps({
|
||||
label: { type: String, required: true },
|
||||
icon: { type: String, default: null },
|
||||
theme: { type: String, default: 'gray' },
|
||||
variant: { type: String, default: 'subtle' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
</script>
|
||||
174
frontend/src/components/Controls/DurationInput.vue
Normal file
174
frontend/src/components/Controls/DurationInput.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<TextInput
|
||||
ref="inputRef"
|
||||
:value="displayValue"
|
||||
v-bind="attrs"
|
||||
:placeholder="placeholder"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@input="handleInput"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
<p
|
||||
v-if="errorMessage || attrs.description"
|
||||
class="mt-1.5"
|
||||
:class="[sizeClass, errorMessage ? 'text-ink-red-6' : 'text-ink-gray-5']"
|
||||
>
|
||||
{{ errorMessage || attrs.description }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { formatDuration } from '@/utils'
|
||||
import { TextInput } from 'frappe-ui'
|
||||
import { ref, computed, nextTick, useAttrs } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: Number, default: null },
|
||||
placeholder: { type: String, default: '1h 30m 45s' },
|
||||
longForm: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const attrs = useAttrs()
|
||||
const inputRef = ref(null)
|
||||
const isFocused = ref(false)
|
||||
const editValue = ref('')
|
||||
const errorMessage = ref('')
|
||||
const isCommitting = ref(false)
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
return { sm: 'text-xs', md: 'text-base' }[attrs.size || 'sm']
|
||||
})
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (isFocused.value) return editValue.value
|
||||
return formatDuration(props.value, props.longForm)
|
||||
})
|
||||
|
||||
function handleFocus() {
|
||||
isFocused.value = true
|
||||
editValue.value = formatDuration(props.value, props.longForm) || ''
|
||||
errorMessage.value = ''
|
||||
nextTick(() => {
|
||||
inputRef.value?.el?.select()
|
||||
})
|
||||
}
|
||||
|
||||
function handleInput(e) {
|
||||
editValue.value = e.target.value
|
||||
errorMessage.value = ''
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
if (isCommitting.value) return
|
||||
commit()
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
isCommitting.value = true
|
||||
commit()
|
||||
inputRef.value?.el?.blur()
|
||||
isCommitting.value = false
|
||||
} else if (e.key === 'Escape') {
|
||||
revert()
|
||||
inputRef.value?.el?.blur()
|
||||
}
|
||||
}
|
||||
|
||||
function commit() {
|
||||
const raw = editValue.value.trim()
|
||||
if (raw === '') {
|
||||
// Allow clearing the field
|
||||
errorMessage.value = ''
|
||||
isFocused.value = false
|
||||
emit('change', null)
|
||||
return
|
||||
}
|
||||
const seconds = parseDuration(raw)
|
||||
if (seconds === null) {
|
||||
errorMessage.value = props.longForm
|
||||
? __(
|
||||
'Invalid format. Try: 1 hour 30 minutes, 2 hours, 45 seconds, 1:30:45, 90s',
|
||||
)
|
||||
: __('Invalid format. Try: 1h 30m 45s, 1:30:45, 90s')
|
||||
isFocused.value = false
|
||||
} else {
|
||||
errorMessage.value = ''
|
||||
isFocused.value = false
|
||||
emit('change', seconds)
|
||||
}
|
||||
}
|
||||
|
||||
function revert() {
|
||||
errorMessage.value = ''
|
||||
isFocused.value = false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse: human-readable string → seconds (integer) or null if invalid
|
||||
//
|
||||
// Supported formats (case-insensitive, spaces optional, any unit order):
|
||||
// 1h 30m 45s | 4m 3h 4s | 4sec 3hour 4min | 1h30m45s
|
||||
// 1:30:45 | 1:30 | :45
|
||||
// 90 | 90s (bare integer treated as seconds)
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseDuration(str) {
|
||||
const input = str.trim().toLowerCase()
|
||||
if (!input) return null
|
||||
|
||||
// Colon-separated: h:m:s or m:s or :s
|
||||
const colonRegex = /^(\d+):(\d+):(\d+)$|^(\d+):(\d+)$|^:(\d+)$/
|
||||
const colonMatch = input.match(colonRegex)
|
||||
if (colonMatch) {
|
||||
if (colonMatch[1] !== undefined) {
|
||||
return (
|
||||
parseInt(colonMatch[1], 10) * 3600 +
|
||||
parseInt(colonMatch[2], 10) * 60 +
|
||||
parseInt(colonMatch[3], 10)
|
||||
)
|
||||
} else if (colonMatch[4] !== undefined) {
|
||||
return parseInt(colonMatch[4], 10) * 60 + parseInt(colonMatch[5], 10)
|
||||
} else {
|
||||
return parseInt(colonMatch[6], 10)
|
||||
}
|
||||
}
|
||||
|
||||
// Bare integer: treated as seconds
|
||||
if (/^\d+$/.test(input)) return parseInt(input, 10)
|
||||
|
||||
// Named units in any order: scan (number)(unit) tokens left-to-right.
|
||||
// Verify no unexpected text exists between tokens.
|
||||
const tokenRe = /(\d+)\s*(hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)/g
|
||||
const tokens = []
|
||||
let lastEnd = 0
|
||||
let match
|
||||
|
||||
while ((match = tokenRe.exec(input)) !== null) {
|
||||
if (input.slice(lastEnd, match.index).trim() !== '') return null
|
||||
tokens.push({ value: parseInt(match[1], 10), unit: match[2][0] })
|
||||
lastEnd = match.index + match[0].length
|
||||
}
|
||||
|
||||
if (input.slice(lastEnd).trim() !== '') return null
|
||||
if (tokens.length === 0) return null
|
||||
|
||||
// Reject duplicate units (e.g. "1h 2h")
|
||||
const seen = new Set()
|
||||
for (const t of tokens) {
|
||||
if (seen.has(t.unit)) return null
|
||||
seen.add(t.unit)
|
||||
}
|
||||
|
||||
let total = 0
|
||||
for (const { value, unit } of tokens) {
|
||||
if (unit === 'h') total += value * 3600
|
||||
else if (unit === 'm') total += value * 60
|
||||
else total += value
|
||||
}
|
||||
return total
|
||||
}
|
||||
</script>
|
||||
316
frontend/src/components/Controls/EmailMultiSelect.vue
Normal file
316
frontend/src/components/Controls/EmailMultiSelect.vue
Normal file
@@ -0,0 +1,316 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Button
|
||||
v-for="value in values"
|
||||
ref="emails"
|
||||
:key="value"
|
||||
:label="value"
|
||||
theme="gray"
|
||||
variant="subtle"
|
||||
:class="{
|
||||
'rounded bg-surface-base hover:!bg-surface-gray-1 focus-visible:ring-outline-gray-4':
|
||||
variant === 'subtle',
|
||||
}"
|
||||
@keydown.delete.capture.stop="removeLastValue"
|
||||
>
|
||||
<template #suffix>
|
||||
<span
|
||||
class="lucide-x h-3.5"
|
||||
aria-hidden="true"
|
||||
@click.stop="removeValue(value)"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
<div class="flex-1">
|
||||
<ComboboxRoot
|
||||
:model-value="tempSelection"
|
||||
:open="showOptions"
|
||||
:ignore-filter="true"
|
||||
@update:open="(o) => (showOptions = o)"
|
||||
@update:modelValue="onSelect"
|
||||
>
|
||||
<ComboboxAnchor
|
||||
class="flex h-7 max-w-full w-auto items-center gap-2 rounded px-2 py-1 border border-transparent"
|
||||
:class="[
|
||||
variant == 'ghost'
|
||||
? 'bg-surface-base hover:bg-surface-base'
|
||||
: 'bg-surface-gray-2 hover:bg-surface-gray-3',
|
||||
inputClass,
|
||||
]"
|
||||
>
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
:value="query"
|
||||
autocomplete="off"
|
||||
class="bg-transparent p-0 outline-none border-0 text-base text-ink-gray-8 h-full placeholder:text-ink-gray-4 w-full focus:outline-none focus:ring-0 focus:border-0"
|
||||
:placeholder="placeholder"
|
||||
@focus="showOptions = true"
|
||||
@input="onInput"
|
||||
@keydown.delete.capture.stop="removeLastValue"
|
||||
@keydown.enter.prevent="handleEnter"
|
||||
/>
|
||||
</ComboboxAnchor>
|
||||
<ComboboxPortal>
|
||||
<ComboboxContent
|
||||
class="z-10 mt-1 min-w-48 w-auto max-w-96 bg-surface-elevation-2 overflow-hidden rounded-lg shadow-2xl ring-1 ring-black ring-opacity-5"
|
||||
position="popper"
|
||||
:align="'start'"
|
||||
@openAutoFocus.prevent
|
||||
@closeAutoFocus.prevent
|
||||
>
|
||||
<ComboboxViewport class="max-h-60 overflow-auto p-1.5">
|
||||
<ComboboxEmpty
|
||||
class="flex gap-2 rounded px-2 py-1 text-base text-ink-gray-5"
|
||||
>
|
||||
<span
|
||||
v-if="showSearchIcon"
|
||||
class="lucide-search h-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ emptyStateText }}
|
||||
</ComboboxEmpty>
|
||||
<ComboboxItem
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="text-base leading-none text-ink-gray-7 rounded flex items-center px-2 py-1 relative select-none data-[highlighted]:outline-none data-[highlighted]:bg-surface-gray-3 cursor-pointer"
|
||||
@mousedown.prevent="onSelect(option.value)"
|
||||
>
|
||||
<UserAvatar class="mr-2" :user="option.value" size="lg" />
|
||||
<div class="flex flex-col gap-1 p-1 text-ink-gray-8">
|
||||
<div class="text-base-medium">{{ option.label }}</div>
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</ComboboxItem>
|
||||
</ComboboxViewport>
|
||||
</ComboboxContent>
|
||||
</ComboboxPortal>
|
||||
</ComboboxRoot>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage v-if="error" class="mt-2 pl-2" :message="error" />
|
||||
<div
|
||||
v-if="info"
|
||||
class="whitespace-pre-line text-sm text-ink-blue-6 mt-2 pl-2"
|
||||
>
|
||||
{{ info }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Generic multi-source (users / contacts / free) multi-select email-like input
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { createResource } from 'frappe-ui'
|
||||
import {
|
||||
ComboboxRoot,
|
||||
ComboboxAnchor,
|
||||
ComboboxInput,
|
||||
ComboboxPortal,
|
||||
ComboboxContent,
|
||||
ComboboxViewport,
|
||||
ComboboxItem,
|
||||
ComboboxEmpty,
|
||||
} from 'reka-ui'
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
|
||||
const props = defineProps({
|
||||
// Behaviour
|
||||
mode: { type: String, default: null }, // 'users' | 'contacts' | 'free' (fallback to legacy flags)
|
||||
fetchUsers: { type: Boolean, default: false },
|
||||
fetchContacts: { type: Boolean, default: false },
|
||||
existingEmails: { type: Array, default: () => [] },
|
||||
validate: { type: Function, default: null },
|
||||
errorMessage: {
|
||||
type: Function,
|
||||
default: (value) => __('{0} is an Invalid Value', [value]),
|
||||
},
|
||||
emptyPlaceholder: { type: String, default: __('No results found') },
|
||||
// UI
|
||||
variant: { type: String, default: 'subtle' },
|
||||
placeholder: { type: String, default: '' },
|
||||
inputClass: { type: String, default: '' },
|
||||
})
|
||||
|
||||
// v-model values
|
||||
const values = defineModel({ type: Array, default: () => [] })
|
||||
|
||||
// Determine effective mode (backwards compatibility with old components)
|
||||
const effectiveMode = computed(() => {
|
||||
if (props.mode) return props.mode
|
||||
if (props.fetchUsers) return 'users'
|
||||
if (props.fetchContacts) return 'contacts'
|
||||
return 'free'
|
||||
})
|
||||
|
||||
// Common state
|
||||
const emails = ref([])
|
||||
const search = ref(null)
|
||||
const error = ref(null)
|
||||
const info = ref(null)
|
||||
const query = ref('')
|
||||
const showOptions = ref(false)
|
||||
const tempSelection = ref(null)
|
||||
|
||||
// Users data — use the store's `allUsers` computed so we get the full
|
||||
// list once the background fetch lands, falling back to the smaller
|
||||
// crm-users-only payload during initial load.
|
||||
const { users, allUsers } = usersStore()
|
||||
|
||||
// Contacts resource (only if needed)
|
||||
const filterOptions = ref(null)
|
||||
const lastLoadedQuery = ref('')
|
||||
|
||||
if (effectiveMode.value === 'contacts') {
|
||||
filterOptions.value = createResource({
|
||||
url: 'crm.api.contact.search_emails',
|
||||
method: 'POST',
|
||||
cache: ['ContactEmails'],
|
||||
params: { txt: '' },
|
||||
transform: (data) => {
|
||||
let allData = (data || []).map((option) => {
|
||||
const fullName = option[0]
|
||||
const email = option[1]
|
||||
const name = option[2]
|
||||
return { label: fullName || name || email, value: email }
|
||||
})
|
||||
if (props.existingEmails?.length) {
|
||||
allData = allData.filter((o) => !props.existingEmails.includes(o.value))
|
||||
}
|
||||
return allData
|
||||
},
|
||||
})
|
||||
|
||||
watchDebounced(
|
||||
query,
|
||||
(val) => {
|
||||
val = val || ''
|
||||
if (lastLoadedQuery.value === val && options.value?.length) return
|
||||
lastLoadedQuery.value = val
|
||||
reload(val)
|
||||
},
|
||||
{ debounce: 300, immediate: true },
|
||||
)
|
||||
}
|
||||
|
||||
function reload(val) {
|
||||
if (effectiveMode.value !== 'contacts' || !filterOptions.value) return
|
||||
filterOptions.value.update({ params: { txt: val } })
|
||||
filterOptions.value.reload()
|
||||
}
|
||||
|
||||
// Options computed
|
||||
const options = computed(() => {
|
||||
const mode = effectiveMode.value
|
||||
if (mode === 'users') {
|
||||
let list = allUsers.value || []
|
||||
list = list.map((u) => ({
|
||||
label: u.full_name || u.name || u.email,
|
||||
value: u.email,
|
||||
}))
|
||||
if (props.existingEmails?.length) {
|
||||
list = list.filter((o) => !props.existingEmails.includes(o.value))
|
||||
}
|
||||
if (query.value) {
|
||||
const q = query.value.toLowerCase()
|
||||
list = list.filter(
|
||||
(o) =>
|
||||
o.label?.toLowerCase().includes(q) ||
|
||||
o.value?.toLowerCase().includes(q),
|
||||
)
|
||||
}
|
||||
return list
|
||||
}
|
||||
if (mode === 'contacts') {
|
||||
const list = filterOptions.value?.data ? [...filterOptions.value.data] : []
|
||||
if (!list.length && query.value) {
|
||||
list.push({ label: query.value, value: query.value })
|
||||
}
|
||||
return list
|
||||
}
|
||||
// Free / manual mode
|
||||
return query.value ? [{ label: query.value, value: query.value }] : []
|
||||
})
|
||||
|
||||
const showSearchIcon = computed(() => effectiveMode.value !== 'free')
|
||||
const emptyStateText = computed(() => {
|
||||
if (effectiveMode.value === 'free') return __(props.emptyPlaceholder)
|
||||
return options.value.length ? '' : __(props.emptyPlaceholder)
|
||||
})
|
||||
|
||||
function addValue(input) {
|
||||
if (!input) return
|
||||
error.value = null
|
||||
info.value = null
|
||||
const parts = input
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
for (const email of parts) {
|
||||
if (values.value?.includes(email)) {
|
||||
info.value = __('Email already exists')
|
||||
continue
|
||||
}
|
||||
if (props.validate && !props.validate(email)) {
|
||||
error.value = props.errorMessage(email)
|
||||
query.value = email
|
||||
break
|
||||
}
|
||||
if (!values.value) values.value = [email]
|
||||
else values.value.push(email)
|
||||
}
|
||||
}
|
||||
|
||||
function removeValue(value) {
|
||||
values.value = values.value.filter((v) => v !== value)
|
||||
}
|
||||
|
||||
function removeLastValue() {
|
||||
if (query.value) return
|
||||
let emailRef = emails.value[emails.value.length - 1]?.rootRef
|
||||
if (document.activeElement === emailRef) {
|
||||
values.value.pop()
|
||||
nextTick(() => {
|
||||
if (values.value.length) {
|
||||
emailRef = emails.value[emails.value.length - 1].rootRef
|
||||
emailRef?.focus()
|
||||
} else {
|
||||
setFocus()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
emailRef?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function setFocus() {
|
||||
search.value?.focus?.()
|
||||
}
|
||||
|
||||
defineExpose({ setFocus })
|
||||
|
||||
function onInput(e) {
|
||||
query.value = e.target.value
|
||||
showOptions.value = true
|
||||
}
|
||||
|
||||
function onSelect(val) {
|
||||
if (!val) return
|
||||
addValue(val)
|
||||
if (!error.value) {
|
||||
query.value = ''
|
||||
tempSelection.value = null
|
||||
showOptions.value = false
|
||||
nextTick(() => setFocus())
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnter() {
|
||||
if (query.value) onSelect(query.value)
|
||||
}
|
||||
</script>
|
||||
37
frontend/src/components/Controls/FormattedInput.vue
Normal file
37
frontend/src/components/Controls/FormattedInput.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<TextInput
|
||||
ref="inputRef"
|
||||
:value="displayValue"
|
||||
v-bind="$attrs"
|
||||
@focus="handleFocus"
|
||||
@blur="isFocused = false"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { TextInput } from 'frappe-ui'
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: [String, Number], default: '' },
|
||||
formattedValue: { type: [String, Number], default: '' },
|
||||
})
|
||||
|
||||
const isFocused = ref(false)
|
||||
const inputRef = ref(null)
|
||||
|
||||
function handleFocus() {
|
||||
isFocused.value = true
|
||||
|
||||
nextTick(() => {
|
||||
if (inputRef.value) {
|
||||
inputRef.value.el?.select()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const displayValue = computed(() => {
|
||||
return isFocused.value ? props.value : props.formattedValue || props.value
|
||||
})
|
||||
</script>
|
||||
420
frontend/src/components/Controls/GeolocationControl.vue
Normal file
420
frontend/src/components/Controls/GeolocationControl.vue
Normal file
@@ -0,0 +1,420 @@
|
||||
<template>
|
||||
<!-- Empty + editable -->
|
||||
<div
|
||||
v-if="!value && !disabled"
|
||||
:class="[containerClasses, 'cursor-pointer']"
|
||||
@click="openModal"
|
||||
>
|
||||
<span
|
||||
class="lucide-map-pin size-4"
|
||||
:class="[iconClasses, 'text-ink-gray-5']"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="whitespace-nowrap text-ink-gray-4">{{
|
||||
__('Set location…')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Empty + disabled -->
|
||||
<div v-else-if="!value && disabled" :class="containerClasses">
|
||||
<span class="text-ink-gray-4">—</span>
|
||||
</div>
|
||||
|
||||
<!-- Has value -->
|
||||
<div
|
||||
v-else
|
||||
:class="[containerClasses, '!pr-1', 'cursor-pointer']"
|
||||
@click="openModal"
|
||||
>
|
||||
<span
|
||||
class="lucide-map-pin size-4"
|
||||
:class="[iconClasses, 'text-ink-gray-7']"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate text-ink-gray-8 text-sm">
|
||||
{{ coordinateSummary }}
|
||||
</span>
|
||||
<button
|
||||
v-if="!disabled"
|
||||
class="flex h-5 w-5 shrink-0 items-center justify-center rounded text-ink-gray-4 hover:bg-surface-gray-2 hover:text-ink-gray-7 dark:hover:bg-surface-gray-4"
|
||||
:title="__('Clear')"
|
||||
@click.prevent="clearLocation"
|
||||
>
|
||||
<span class="lucide-x h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Map Dialog -->
|
||||
<Dialog
|
||||
v-model:open="showModal"
|
||||
:title="disabled ? __('View Location') : __('Set Location')"
|
||||
:size="'4xl'"
|
||||
>
|
||||
<template #default>
|
||||
<div :id="mapId" class="h-[500px] w-full rounded" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
:label="disabled ? __('Close') : __('Cancel')"
|
||||
@click="showModal = false"
|
||||
/>
|
||||
<Button
|
||||
v-if="!disabled"
|
||||
variant="solid"
|
||||
:label="__('Save')"
|
||||
@click="saveLocation"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Static ?url imports so Vite bundles the images and resolves paths correctly
|
||||
import leafletIconUrl from 'leaflet/dist/images/marker-icon.png?url'
|
||||
import leafletIconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png?url'
|
||||
import leafletShadowUrl from 'leaflet/dist/images/marker-shadow.png?url'
|
||||
import { useGeolocation } from '@vueuse/core'
|
||||
import { Dialog, Button } from 'frappe-ui'
|
||||
import { ref, computed, watch, nextTick, useAttrs, onBeforeUnmount } from 'vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: String, default: null },
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
const attrs = useAttrs()
|
||||
|
||||
const { coords: geoCoords } = useGeolocation()
|
||||
|
||||
const showModal = ref(false)
|
||||
const mapId = `geo-map-${Math.random().toString(36).slice(2)}`
|
||||
|
||||
// Leaflet instances — populated after dynamic import
|
||||
let L = null
|
||||
let mapInstance = null
|
||||
let editableLayers = null
|
||||
let drawControl = null
|
||||
|
||||
// ─── Computed ──────────────────────────────────────────────────────────────
|
||||
|
||||
const coordinateSummary = computed(() => {
|
||||
if (!props.value) return ''
|
||||
try {
|
||||
const geo = JSON.parse(props.value)
|
||||
// Single Point feature or FeatureCollection with one Point
|
||||
const features = geo.type === 'FeatureCollection' ? geo.features : [geo]
|
||||
const points = features.filter((f) => f.geometry?.type === 'Point')
|
||||
if (features.length === 1 && points.length === 1) {
|
||||
const [lng, lat] = points[0].geometry.coordinates
|
||||
const latStr = `${Math.abs(lat).toFixed(5)}°${lat >= 0 ? 'N' : 'S'}`
|
||||
const lngStr = `${Math.abs(lng).toFixed(5)}°${lng >= 0 ? 'E' : 'W'}`
|
||||
return `${latStr}, ${lngStr}`
|
||||
}
|
||||
const count = features.length
|
||||
return __(`{0} ${count === 1 ? 'feature' : 'features'}`, [count])
|
||||
} catch {
|
||||
return __('Invalid GeoJSON')
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Size / variant classes (mirrors AttachControl / frappe-ui TextInput) ──
|
||||
|
||||
const sizeClasses = computed(
|
||||
() =>
|
||||
({
|
||||
sm: 'h-7 text-base rounded',
|
||||
md: 'h-8 text-base rounded',
|
||||
lg: 'h-10 text-lg rounded-md',
|
||||
xl: 'h-10 text-2xl rounded-md',
|
||||
})[attrs.size || 'sm'],
|
||||
)
|
||||
|
||||
const paddingClasses = computed(
|
||||
() =>
|
||||
({
|
||||
sm: 'px-2',
|
||||
md: 'px-2.5',
|
||||
lg: 'px-3',
|
||||
xl: 'px-3',
|
||||
})[attrs.size || 'sm'],
|
||||
)
|
||||
|
||||
const variantClasses = computed(() => {
|
||||
if (props.disabled) {
|
||||
return [
|
||||
'border bg-surface-gray-1 text-ink-gray-5',
|
||||
(attrs.variant || 'subtle') === 'outline'
|
||||
? 'border-outline-gray-2'
|
||||
: 'border-transparent',
|
||||
].join(' ')
|
||||
}
|
||||
return {
|
||||
subtle:
|
||||
'border border-[--surface-gray-2] bg-surface-gray-2 hover:border-outline-elevation-2 hover:bg-surface-gray-3',
|
||||
outline:
|
||||
'border border-outline-gray-2 bg-surface-base hover:border-outline-gray-3 hover:shadow-sm',
|
||||
ghost: 'border-0',
|
||||
}[attrs.variant || 'subtle']
|
||||
})
|
||||
|
||||
const iconClasses = computed(
|
||||
() =>
|
||||
({
|
||||
sm: 'h-3 w-3 shrink-0',
|
||||
md: 'h-3.5 w-3.5 shrink-0',
|
||||
lg: 'h-4 w-4 shrink-0',
|
||||
xl: 'h-4 w-4 shrink-0',
|
||||
})[attrs.size || 'sm'],
|
||||
)
|
||||
|
||||
const containerClasses = computed(() =>
|
||||
[
|
||||
'flex w-full items-center gap-1.5 overflow-hidden transition-colors',
|
||||
sizeClasses.value,
|
||||
paddingClasses.value,
|
||||
variantClasses.value,
|
||||
attrs.class,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
)
|
||||
|
||||
// ─── Map lifecycle ──────────────────────────────────────────────────────────
|
||||
|
||||
function openModal() {
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
watch(showModal, (visible) => {
|
||||
if (!visible) {
|
||||
destroyMap()
|
||||
return
|
||||
}
|
||||
nextTick(() => initMap())
|
||||
})
|
||||
|
||||
async function initMap() {
|
||||
// Lazy-load Leaflet + leaflet-draw + their CSS on first open
|
||||
if (!L) {
|
||||
await import('leaflet/dist/leaflet.css')
|
||||
await import('leaflet-draw/dist/leaflet.draw.css')
|
||||
const leafletModule = await import('leaflet')
|
||||
L = leafletModule.default ?? leafletModule
|
||||
await import('leaflet-draw')
|
||||
|
||||
// Fix Vite marker image paths — delete the built-in resolver first,
|
||||
// then supply the already-resolved ?url import strings
|
||||
delete L.Icon.Default.prototype._getIconUrl
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconUrl: leafletIconUrl,
|
||||
iconRetinaUrl: leafletIconRetinaUrl,
|
||||
shadowUrl: leafletShadowUrl,
|
||||
})
|
||||
|
||||
// Patch Circle/CircleMarker toGeoJSON to include radius in properties
|
||||
// (same patch as Frappe's customize_draw_controls)
|
||||
const circleToGeoJSON = L.Circle.prototype.toGeoJSON
|
||||
L.Circle.include({
|
||||
toGeoJSON() {
|
||||
const feature = circleToGeoJSON.call(this)
|
||||
feature.properties = { point_type: 'circle', radius: this.getRadius() }
|
||||
return feature
|
||||
},
|
||||
})
|
||||
L.CircleMarker.include({
|
||||
toGeoJSON() {
|
||||
const feature = circleToGeoJSON.call(this)
|
||||
feature.properties = {
|
||||
point_type: 'circlemarker',
|
||||
radius: this.getRadius(),
|
||||
}
|
||||
return feature
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Dialog uses v-if internally — the map div is destroyed on close and
|
||||
// recreated on open. Always create a fresh instance; destroyMap() on close
|
||||
// ensures mapInstance is null here.
|
||||
mapInstance = L.map(mapId)
|
||||
|
||||
// ── Tile layers (mirrors Frappe's map_defaults) ─────────────────────────
|
||||
const streetLayer = L.tileLayer(
|
||||
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
{
|
||||
maxZoom: 19,
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
},
|
||||
)
|
||||
const satelliteLayer = L.tileLayer(
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
{ attribution: '© Esri © OpenStreetMap Contributors' },
|
||||
)
|
||||
const labelsLayer = L.tileLayer(
|
||||
'https://tiles.stadiamaps.com/tiles/stamen_toner_labels/{z}/{x}/{y}{r}.png',
|
||||
{
|
||||
attribution:
|
||||
'© <a href="https://www.stadiamaps.com/">Stadia Maps</a> © <a href="https://www.stamen.com/">Stamen Design</a>',
|
||||
},
|
||||
)
|
||||
const terrainLayer = L.tileLayer(
|
||||
'https://tiles.stadiamaps.com/tiles/stamen_terrain_lines/{z}/{x}/{y}{r}.png',
|
||||
{
|
||||
attribution:
|
||||
'© <a href="https://www.stadiamaps.com/">Stadia Maps</a> © <a href="https://www.stamen.com/">Stamen Design</a>',
|
||||
},
|
||||
)
|
||||
|
||||
streetLayer.addTo(mapInstance)
|
||||
|
||||
L.control
|
||||
.layers(
|
||||
{ Default: streetLayer, Satellite: satelliteLayer },
|
||||
{ Labels: labelsLayer, Terrain: terrainLayer },
|
||||
)
|
||||
.addTo(mapInstance)
|
||||
|
||||
// ── Locate control ───────────────────────────────────────────────────────
|
||||
// Lazy-load leaflet.locatecontrol CSS + plugin
|
||||
await import('leaflet.locatecontrol/dist/L.Control.Locate.min.css')
|
||||
const { locate: leafletLocate } = await import('leaflet.locatecontrol')
|
||||
leafletLocate({ position: 'topright' }).addTo(mapInstance)
|
||||
|
||||
editableLayers = new L.FeatureGroup()
|
||||
editableLayers.addTo(mapInstance)
|
||||
|
||||
// Draw controls — only in edit mode
|
||||
if (!props.disabled) {
|
||||
drawControl = new L.Control.Draw({
|
||||
position: 'topleft',
|
||||
draw: {
|
||||
polyline: { shapeOptions: { color: '#4f46e5', weight: 4 } },
|
||||
polygon: {
|
||||
allowIntersection: false,
|
||||
shapeOptions: { color: '#4f46e5' },
|
||||
},
|
||||
circle: true,
|
||||
rectangle: { shapeOptions: { clickable: false } },
|
||||
circlemarker: true,
|
||||
marker: true,
|
||||
},
|
||||
edit: { featureGroup: editableLayers, remove: true },
|
||||
})
|
||||
drawControl.addTo(mapInstance)
|
||||
|
||||
mapInstance.on('draw:created', (e) => {
|
||||
editableLayers.addLayer(e.layer)
|
||||
})
|
||||
|
||||
mapInstance.on('draw:deleted', (e) => {
|
||||
e.layers.eachLayer((l) => editableLayers.removeLayer(l))
|
||||
})
|
||||
}
|
||||
|
||||
reloadData()
|
||||
}
|
||||
|
||||
function reloadData() {
|
||||
if (!L || !editableLayers || !mapInstance) return
|
||||
|
||||
// Clear existing layers
|
||||
editableLayers.clearLayers()
|
||||
|
||||
// Use device location if available, otherwise fall back to Mumbai
|
||||
const { latitude, longitude } = geoCoords.value
|
||||
const defaultCenter =
|
||||
isFinite(latitude) && isFinite(longitude)
|
||||
? [latitude, longitude]
|
||||
: [19.08, 72.8961]
|
||||
mapInstance.setView(defaultCenter, 13)
|
||||
|
||||
if (!props.value) return
|
||||
|
||||
try {
|
||||
const geoData = JSON.parse(props.value)
|
||||
const dataGroup = new L.FeatureGroup().addLayer(
|
||||
L.geoJSON(geoData, {
|
||||
pointToLayer(geoJsonPoint, latlng) {
|
||||
const pt = geoJsonPoint.properties?.point_type
|
||||
if (pt === 'circle') {
|
||||
return L.circle(latlng, {
|
||||
radius: geoJsonPoint.properties.radius,
|
||||
})
|
||||
}
|
||||
if (pt === 'circlemarker') {
|
||||
return L.circleMarker(latlng, {
|
||||
radius: geoJsonPoint.properties.radius,
|
||||
})
|
||||
}
|
||||
return L.marker(latlng)
|
||||
},
|
||||
}),
|
||||
)
|
||||
addNonGroupLayers(dataGroup, editableLayers)
|
||||
fitMap()
|
||||
} catch {
|
||||
// Invalid JSON — ignore
|
||||
}
|
||||
}
|
||||
|
||||
function addNonGroupLayers(source, target) {
|
||||
if (source instanceof L.LayerGroup) {
|
||||
source.eachLayer((l) => addNonGroupLayers(l, target))
|
||||
} else {
|
||||
target.addLayer(source)
|
||||
}
|
||||
}
|
||||
|
||||
function fitMap() {
|
||||
mapInstance.invalidateSize()
|
||||
const bounds = editableLayers.getBounds()
|
||||
if (bounds.isValid()) {
|
||||
// fitBounds works for multi-point layers; for a single point the bounds
|
||||
// have zero area so we zoom manually instead.
|
||||
const ne = bounds.getNorthEast()
|
||||
const sw = bounds.getSouthWest()
|
||||
if (ne.equals(sw)) {
|
||||
mapInstance.setView(ne, 14)
|
||||
} else {
|
||||
mapInstance.fitBounds(bounds, { padding: [50, 50] })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Actions ────────────────────────────────────────────────────────────────
|
||||
|
||||
function saveLocation() {
|
||||
if (!editableLayers) {
|
||||
showModal.value = false
|
||||
return
|
||||
}
|
||||
const geoJson = editableLayers.toGeoJSON()
|
||||
const hasFeatures = geoJson.features?.length > 0
|
||||
emit('change', hasFeatures ? JSON.stringify(geoJson) : null)
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
function clearLocation() {
|
||||
emit('change', null)
|
||||
}
|
||||
|
||||
// ─── Cleanup ────────────────────────────────────────────────────────────────
|
||||
|
||||
function destroyMap() {
|
||||
if (mapInstance) {
|
||||
mapInstance.remove()
|
||||
mapInstance = null
|
||||
editableLayers = null
|
||||
drawControl = null
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => destroyMap())
|
||||
</script>
|
||||
850
frontend/src/components/Controls/Grid.vue
Normal file
850
frontend/src/components/Controls/Grid.vue
Normal file
@@ -0,0 +1,850 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 text-base">
|
||||
<div v-if="label" class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="fields?.length"
|
||||
class="rounded border border-outline-elevation-2"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="grid-header flex items-center rounded-t-[7px] bg-surface-gray-2 text-ink-gray-5 truncate"
|
||||
>
|
||||
<div
|
||||
class="inline-flex items-center justify-center border-r border-outline-gray-2 h-8 p-2 w-12"
|
||||
>
|
||||
<Checkbox
|
||||
class="cursor-pointer duration-300"
|
||||
:modelValue="allRowsSelected"
|
||||
@click.stop="toggleSelectAllRows($event.target.checked)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="inline-flex items-center justify-center border-r border-outline-gray-2 py-2 px-1 w-12"
|
||||
>
|
||||
{{ __('Number') }}
|
||||
</div>
|
||||
<div
|
||||
class="grid w-full truncate"
|
||||
:style="{ gridTemplateColumns: gridTemplateColumns }"
|
||||
>
|
||||
<div
|
||||
v-for="field in fields"
|
||||
:key="field.fieldname"
|
||||
class="border-r border-outline-gray-2 p-2 truncate"
|
||||
:class="
|
||||
['Int', 'Float', 'Currency', 'Percent'].includes(field.fieldtype)
|
||||
? 'text-right'
|
||||
: ''
|
||||
"
|
||||
:title="field.label"
|
||||
>
|
||||
{{ __(field.label) }}
|
||||
<span
|
||||
v-if="
|
||||
field.reqd ||
|
||||
(field.mandatory_depends_on && field.mandatory_via_depends_on)
|
||||
"
|
||||
class="text-ink-red-5"
|
||||
>*</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-center w-12">
|
||||
<Button
|
||||
:tooltip="__('Edit Grid Fields')"
|
||||
class="rounded !bg-surface-gray-2 border-0 !text-ink-gray-5"
|
||||
variant="outline"
|
||||
icon="lucide-settings"
|
||||
@click="showGridFieldsEditorModal = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Rows -->
|
||||
<template v-if="rows?.length">
|
||||
<Draggable
|
||||
v-model="rows"
|
||||
class="w-full"
|
||||
:delay="isTouchScreenDevice() ? 200 : 0"
|
||||
group="rows"
|
||||
item-key="name"
|
||||
@end="reorder"
|
||||
>
|
||||
<template #item="{ element: row, index }">
|
||||
<div
|
||||
class="grid-row flex cursor-pointer items-center border-b border-outline-elevation-2 bg-surface-modals last:rounded-b last:border-b-0"
|
||||
@click.stop="
|
||||
() => {
|
||||
if (!gridSettings.editable_grid) {
|
||||
showRowList[index] = true
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="grid-row-checkbox inline-flex h-9.5 items-center bg-surface-base justify-center border-r border-outline-elevation-2 p-2 w-12"
|
||||
>
|
||||
<Checkbox
|
||||
class="cursor-pointer duration-300"
|
||||
:modelValue="selectedRows.has(row.name)"
|
||||
@click.stop="toggleSelectRow(row)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-9.5 items-center justify-center bg-surface-base border-r border-outline-elevation-2 py-2 px-1 text-sm text-ink-gray-8 w-12"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<div
|
||||
class="grid w-full h-9.5"
|
||||
:style="{ gridTemplateColumns: gridTemplateColumns }"
|
||||
>
|
||||
<template
|
||||
v-for="baseField in fields"
|
||||
:key="baseField.fieldname"
|
||||
>
|
||||
<!-- Resolve per-row field overrides -->
|
||||
<div
|
||||
v-if="!getRowFieldObj(baseField, row).hidden"
|
||||
class="border-r border-outline-elevation-2 h-9.5"
|
||||
>
|
||||
<template
|
||||
v-for="field in [getRowFieldObj(baseField, row)]"
|
||||
:key="field.fieldname + '-inner'"
|
||||
>
|
||||
<FormControl
|
||||
v-if="
|
||||
field.read_only &&
|
||||
![
|
||||
'Int',
|
||||
'Float',
|
||||
'Currency',
|
||||
'Percent',
|
||||
'Check',
|
||||
'Duration',
|
||||
'Rating',
|
||||
'Button',
|
||||
'Attach',
|
||||
'Attach Image',
|
||||
'HTML',
|
||||
'Geolocation',
|
||||
'Text Editor',
|
||||
].includes(field.fieldtype)
|
||||
"
|
||||
v-model="row[field.fieldname]"
|
||||
type="text"
|
||||
:placeholder="field.placeholder"
|
||||
:disabled="true"
|
||||
/>
|
||||
<Link
|
||||
v-else-if="
|
||||
['Link', 'Dynamic Link'].includes(field.fieldtype)
|
||||
"
|
||||
class="text-sm text-ink-gray-8"
|
||||
:value="row[field.fieldname]"
|
||||
:doctype="
|
||||
field.fieldtype == 'Link'
|
||||
? field.options
|
||||
: row[field.options]
|
||||
"
|
||||
:filters="field.filters"
|
||||
:disabled="Boolean(field.disabled)"
|
||||
:onCreate="
|
||||
(value, close) => field.create(v, field, row, close)
|
||||
"
|
||||
@change="(v) => fieldChange(v, field, row)"
|
||||
/>
|
||||
<Link
|
||||
v-else-if="field.fieldtype === 'User'"
|
||||
class="form-control"
|
||||
:value="getUser(row[field.fieldname]).full_name"
|
||||
:doctype="field.options"
|
||||
:filters="field.filters"
|
||||
:placeholder="field.placeholder"
|
||||
:disabled="Boolean(field.disabled)"
|
||||
:hideMe="true"
|
||||
@change="(v) => fieldChange(v, field, row)"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserAvatar
|
||||
class="mr-2"
|
||||
:user="row[field.fieldname]"
|
||||
size="sm"
|
||||
/>
|
||||
</template>
|
||||
<template #item-prefix="{ option }">
|
||||
<UserAvatar
|
||||
class="mr-2"
|
||||
:user="option.value"
|
||||
size="sm"
|
||||
/>
|
||||
</template>
|
||||
<template #item-label="{ option }">
|
||||
<Tooltip :text="option.value">
|
||||
<div class="cursor-pointer text-ink-gray-9">
|
||||
{{ getUser(option.value).full_name }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</Link>
|
||||
<div
|
||||
v-else-if="field.fieldtype === 'Check'"
|
||||
class="flex h-full bg-surface-base justify-center items-center"
|
||||
>
|
||||
<Checkbox
|
||||
v-model="row[field.fieldname]"
|
||||
class="cursor-pointer duration-300"
|
||||
:disabled="
|
||||
!gridSettings.editable_grid ||
|
||||
Boolean(field.disabled)
|
||||
"
|
||||
@change="
|
||||
(e) => fieldChange(e.target.checked, field, row)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<TimePicker
|
||||
v-else-if="field.fieldtype === 'Time'"
|
||||
:value="row[field.fieldname]"
|
||||
variant="outline"
|
||||
:format="getFormat('', '', false, true, false)"
|
||||
input-class="border-none text-sm text-ink-gray-8"
|
||||
@change="(v) => fieldChange(v, field, row)"
|
||||
/>
|
||||
<DatePicker
|
||||
v-else-if="field.fieldtype === 'Date'"
|
||||
:value="row[field.fieldname]"
|
||||
variant="outline"
|
||||
:format="getFormat('', '', true, false, false)"
|
||||
input-class="border-none text-sm text-ink-gray-8"
|
||||
@change="(v) => fieldChange(v, field, row)"
|
||||
/>
|
||||
<DateTimePicker
|
||||
v-else-if="field.fieldtype === 'Datetime'"
|
||||
:value="row[field.fieldname]"
|
||||
variant="outline"
|
||||
:format="getFormat('', '', true, true, false)"
|
||||
input-class="border-none text-sm text-ink-gray-8"
|
||||
@change="(v) => fieldChange(v, field, row)"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="
|
||||
['Small Text', 'Text', 'Long Text', 'Code'].includes(
|
||||
field.fieldtype,
|
||||
)
|
||||
"
|
||||
rows="1"
|
||||
type="textarea"
|
||||
variant="outline"
|
||||
:value="row[field.fieldname]"
|
||||
@change="fieldChange($event.target.value, field, row)"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="field.fieldtype === 'Select'"
|
||||
v-model="row[field.fieldname]"
|
||||
class="text-sm text-ink-gray-8"
|
||||
type="select"
|
||||
variant="outline"
|
||||
:options="field.options"
|
||||
:disabled="Boolean(field.disabled)"
|
||||
@update:modelValue="(e) => fieldChange(e, field, row)"
|
||||
/>
|
||||
<Password
|
||||
v-else-if="field.fieldtype === 'Password'"
|
||||
variant="outline"
|
||||
:value="row[field.fieldname]"
|
||||
:disabled="Boolean(field.disabled)"
|
||||
@change="fieldChange($event.target.value, field, row)"
|
||||
/>
|
||||
<FormattedInput
|
||||
v-else-if="field.fieldtype === 'Int'"
|
||||
class="[&_input]:text-right"
|
||||
type="text"
|
||||
variant="outline"
|
||||
:value="row[field.fieldname] || '0'"
|
||||
:disabled="Boolean(field.disabled)"
|
||||
@change="fieldChange($event.target.value, field, row)"
|
||||
/>
|
||||
<FormattedInput
|
||||
v-else-if="field.fieldtype === 'Percent'"
|
||||
class="[&_input]:text-right"
|
||||
type="text"
|
||||
variant="outline"
|
||||
:value="getFloatWithPrecision(field.fieldname, row)"
|
||||
:formattedValue="(row[field.fieldname] || '0') + '%'"
|
||||
:disabled="Boolean(field.disabled)"
|
||||
@change="
|
||||
fieldChange(flt($event.target.value), field, row)
|
||||
"
|
||||
/>
|
||||
<FormattedInput
|
||||
v-else-if="field.fieldtype === 'Float'"
|
||||
class="[&_input]:text-right"
|
||||
type="text"
|
||||
variant="outline"
|
||||
:value="getFloatWithPrecision(field.fieldname, row)"
|
||||
:formattedValue="row[field.fieldname]"
|
||||
:disabled="Boolean(field.disabled)"
|
||||
@change="
|
||||
fieldChange(flt($event.target.value), field, row)
|
||||
"
|
||||
/>
|
||||
<FormattedInput
|
||||
v-else-if="field.fieldtype === 'Currency'"
|
||||
class="[&_input]:text-right"
|
||||
type="text"
|
||||
variant="outline"
|
||||
:value="getCurrencyWithPrecision(field.fieldname, row)"
|
||||
:formattedValue="
|
||||
getFormattedCurrency(field.fieldname, row, parentDoc)
|
||||
"
|
||||
:disabled="Boolean(field.disabled)"
|
||||
@change="
|
||||
fieldChange(flt($event.target.value), field, row)
|
||||
"
|
||||
/>
|
||||
<DurationInput
|
||||
v-else-if="field.fieldtype === 'Duration'"
|
||||
:value="row[field.fieldname]"
|
||||
variant="outline"
|
||||
:disabled="Boolean(field.disabled)"
|
||||
@change="(v) => fieldChange(v, field, row)"
|
||||
/>
|
||||
<div
|
||||
v-else-if="field.fieldtype === 'Rating'"
|
||||
class="flex h-full w-full items-center overflow-hidden [&_::-webkit-scrollbar]:h-0"
|
||||
>
|
||||
<RatingInput
|
||||
class="flex-nowrap overflow-x-auto px-2"
|
||||
:value="row[field.fieldname]"
|
||||
:disabled="Boolean(field.disabled)"
|
||||
:max="field.options || 5"
|
||||
@change="(v) => fieldChange(v, field, row)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="field.fieldtype === 'Button'"
|
||||
class="flex items-center px-1 h-full"
|
||||
>
|
||||
<ButtonControl
|
||||
class="button-control"
|
||||
:label="field.label"
|
||||
:icon="field.icon"
|
||||
:theme="getButtonTheme(field.button_color)"
|
||||
:variant="getButtonVariant(field.button_color)"
|
||||
:disabled="Boolean(field.disabled)"
|
||||
@click="handleButtonClick(field, row)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
['Attach', 'Attach Image'].includes(field.fieldtype)
|
||||
"
|
||||
class="flex h-full w-full items-center"
|
||||
>
|
||||
<AttachControl
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
:value="row[field.fieldname]"
|
||||
:doctype="doctype"
|
||||
:docname="row.name"
|
||||
:fieldname="field.fieldname"
|
||||
:imageOnly="field.fieldtype === 'Attach Image'"
|
||||
:disabled="Boolean(field.disabled)"
|
||||
@change="(v) => fieldChange(v, field, row)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="field.fieldtype === 'HTML'"
|
||||
class="px-2 py-1 overflow-hidden"
|
||||
>
|
||||
<HtmlControl
|
||||
:html="interpolateTemplate(field.options || '', row)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="field.fieldtype === 'Geolocation'"
|
||||
class="flex h-full w-full items-center"
|
||||
>
|
||||
<GeolocationControl
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
:value="row[field.fieldname]"
|
||||
:disabled="Boolean(field.disabled)"
|
||||
@change="(v) => fieldChange(v, field, row)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="field.fieldtype === 'Text Editor'"
|
||||
class="flex h-full w-full items-center"
|
||||
>
|
||||
<TextEditorControl
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:fixed-menu="false"
|
||||
:bubble-menu="true"
|
||||
editorClass="w-full !min-h-[38px] !h-[38px]"
|
||||
:value="row[field.fieldname]"
|
||||
:placeholder="field.placeholder"
|
||||
:disabled="Boolean(field.disabled)"
|
||||
@change="(v) => fieldChange(v, field, row)"
|
||||
/>
|
||||
</div>
|
||||
<Combobox
|
||||
v-else-if="field.fieldtype === 'Autocomplete'"
|
||||
v-model="row[field.fieldname]"
|
||||
class="combobox"
|
||||
variant="outline"
|
||||
:options="getOptions(field.options)"
|
||||
:placeholder="field.placeholder"
|
||||
:disabled="Boolean(field.disabled)"
|
||||
@update:modelValue="(v) => fieldChange(v, field, row)"
|
||||
/>
|
||||
<FormControl
|
||||
v-else
|
||||
v-model="row[field.fieldname]"
|
||||
class="text-sm text-ink-gray-8"
|
||||
type="text"
|
||||
variant="outline"
|
||||
:options="field.options"
|
||||
@change="fieldChange($event.target.value, field, row)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="edit-row flex items-center justify-center w-12">
|
||||
<Button
|
||||
:tooltip="__('Edit Row')"
|
||||
class="rounded border-0 !text-ink-gray-7"
|
||||
variant="outline"
|
||||
:icon="EditIcon"
|
||||
@click="showRowList[index] = true"
|
||||
/>
|
||||
</div>
|
||||
<GridRowModal
|
||||
v-if="showRowList[index]"
|
||||
v-model="showRowList[index]"
|
||||
v-model:showGridRowFieldsModal="showGridRowFieldsModal"
|
||||
:index="index"
|
||||
:data="row"
|
||||
:doctype="doctype"
|
||||
:parentDoctype="parentDoctype"
|
||||
:parentFieldname="parentFieldname"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center rounded p-5 text-sm text-ink-gray-5"
|
||||
>
|
||||
{{ __('No Data') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="fields?.length" class="mt-2 flex flex-row gap-2">
|
||||
<Button
|
||||
v-if="showDeleteBtn"
|
||||
:label="__('Delete')"
|
||||
variant="solid"
|
||||
theme="red"
|
||||
@click="deleteRows"
|
||||
/>
|
||||
<Button :label="__('Add Row')" @click="addRow" />
|
||||
</div>
|
||||
<GridRowFieldsModal
|
||||
v-if="showGridRowFieldsModal"
|
||||
v-model="showGridRowFieldsModal"
|
||||
:doctype="doctype"
|
||||
:parentDoctype="parentDoctype"
|
||||
/>
|
||||
<GridFieldsEditorModal
|
||||
v-if="showGridFieldsEditorModal"
|
||||
v-model="showGridFieldsEditorModal"
|
||||
:doctype="doctype"
|
||||
:parentDoctype="parentDoctype"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Password from '@/components/Controls/Password.vue'
|
||||
import DurationInput from '@/components/Controls/DurationInput.vue'
|
||||
import RatingInput from '@/components/Controls/RatingInput.vue'
|
||||
import AttachControl from '@/components/Controls/AttachControl.vue'
|
||||
import HtmlControl from '@/components/Controls/HtmlControl.vue'
|
||||
import GeolocationControl from '@/components/Controls/GeolocationControl.vue'
|
||||
import TextEditorControl from '@/components/Controls/TextEditorControl.vue'
|
||||
import ButtonControl, {
|
||||
getButtonTheme,
|
||||
getButtonVariant,
|
||||
} from '@/components/Controls/ButtonControl.vue'
|
||||
import FormattedInput from '@/components/Controls/FormattedInput.vue'
|
||||
import GridFieldsEditorModal from '@/components/Controls/GridFieldsEditorModal.vue'
|
||||
import GridRowFieldsModal from '@/components/Controls/GridRowFieldsModal.vue'
|
||||
import GridRowModal from '@/components/Controls/GridRowModal.vue'
|
||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import {
|
||||
getRandom,
|
||||
getFormat,
|
||||
isTouchScreenDevice,
|
||||
interpolateTemplate,
|
||||
} from '@/utils'
|
||||
import { flt } from '@/utils/numberFormat.js'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { getMeta } from '@/stores/meta'
|
||||
import { parseLinkFilters } from '@/utils/fieldTransforms'
|
||||
import { isFetchedFromLink } from '@/utils/fetchFrom'
|
||||
import { createDocument } from '@/composables/document'
|
||||
import {
|
||||
FormControl,
|
||||
Checkbox,
|
||||
TimePicker,
|
||||
DateTimePicker,
|
||||
DatePicker,
|
||||
Tooltip,
|
||||
dayjs,
|
||||
Combobox,
|
||||
} from 'frappe-ui'
|
||||
import Draggable from 'vuedraggable'
|
||||
import { ref, reactive, computed, inject, provide } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, default: '' },
|
||||
doctype: { type: String, required: true },
|
||||
parentDoctype: { type: String, required: true },
|
||||
parentFieldname: { type: String, required: true },
|
||||
overrides: { type: Object, default: () => ({}) },
|
||||
})
|
||||
|
||||
const restrictedFieldTypes = [
|
||||
'Section Break',
|
||||
'Column Break',
|
||||
'Tab Break',
|
||||
'Table',
|
||||
'Table MultiSelect',
|
||||
'Image',
|
||||
]
|
||||
|
||||
const triggerOnChange = inject('triggerOnChange', () => {})
|
||||
const triggerButton = inject('triggerButton', () => {})
|
||||
const triggerOnRowAdd = inject('triggerOnRowAdd', () => {})
|
||||
const triggerOnRowRemove = inject('triggerOnRowRemove', () => {})
|
||||
const parentFieldPropertyOverrides = inject(
|
||||
'fieldPropertyOverrides',
|
||||
computed(() => ({})),
|
||||
)
|
||||
|
||||
const {
|
||||
getGridViewSettings,
|
||||
getFields,
|
||||
getFloatWithPrecision,
|
||||
getCurrencyWithPrecision,
|
||||
getFormattedCurrency,
|
||||
getGridSettings,
|
||||
} = getMeta(props.doctype)
|
||||
getMeta(props.parentDoctype)
|
||||
const { users, getUser } = usersStore()
|
||||
|
||||
const rows = defineModel({ type: Array, default: () => [] })
|
||||
const parentDoc = defineModel('parent', { type: Object, default: () => ({}) })
|
||||
|
||||
provide('parentDoc', parentDoc)
|
||||
provide('fieldPropertyOverrides', parentFieldPropertyOverrides)
|
||||
provide('parentFieldname', props.parentFieldname)
|
||||
|
||||
/**
|
||||
* Resolve field overrides for a specific row.
|
||||
* Priority: row-specific (products.qty:row_name) > column-level (products.qty) > base meta
|
||||
*/
|
||||
function getRowFieldObj(field, row) {
|
||||
const ov = parentFieldPropertyOverrides.value || {}
|
||||
const colKey = `${props.parentFieldname}.${field.fieldname}`
|
||||
const rowKey = row?.name ? `${colKey}:${row.name}` : null
|
||||
const colOverrides = ov[colKey]
|
||||
const rowOverrides = rowKey ? ov[rowKey] : null
|
||||
|
||||
let merged = { ...field }
|
||||
if (colOverrides) Object.assign(merged, colOverrides)
|
||||
if (rowOverrides) Object.assign(merged, rowOverrides)
|
||||
|
||||
merged.disabled = Boolean(merged.read_only || isFetchedFromLink(merged, row))
|
||||
return merged
|
||||
}
|
||||
|
||||
const showRowList = ref(new Array(rows.value?.length || []).fill(false))
|
||||
const selectedRows = reactive(new Set())
|
||||
|
||||
const showGridFieldsEditorModal = ref(false)
|
||||
const showGridRowFieldsModal = ref(false)
|
||||
|
||||
const gridSettings = computed(() => getGridSettings())
|
||||
|
||||
const fields = computed(() => {
|
||||
let gridViewSettings = getGridViewSettings(props.parentDoctype)
|
||||
let gridFields = getFields({
|
||||
restrictNoValueFields: false,
|
||||
restrictedFieldTypes,
|
||||
})
|
||||
|
||||
if (!gridFields?.length) return []
|
||||
|
||||
let processed
|
||||
if (gridViewSettings.length) {
|
||||
processed = gridViewSettings.map((gs) =>
|
||||
getFieldObj(gridFields.find((f) => f.fieldname === gs.fieldname)),
|
||||
)
|
||||
} else {
|
||||
processed =
|
||||
gridFields?.filter((f) => f.in_list_view).map((f) => getFieldObj(f)) || []
|
||||
}
|
||||
|
||||
// Filter out hidden columns (from script overrides)
|
||||
return processed.filter((f) => !f.hidden)
|
||||
})
|
||||
|
||||
const allFields = computed(() => {
|
||||
return getFields()?.map((f) => getFieldObj(f)) || []
|
||||
})
|
||||
|
||||
function getFieldObj(field) {
|
||||
// Clone to avoid mutating cached meta
|
||||
field = { ...field }
|
||||
|
||||
// Merge script property overrides (dot notation: parentFieldname.childFieldname)
|
||||
const overrideKey = `${props.parentFieldname}.${field.fieldname}`
|
||||
const scriptOverrides = parentFieldPropertyOverrides.value?.[overrideKey]
|
||||
if (scriptOverrides) {
|
||||
Object.assign(field, scriptOverrides)
|
||||
}
|
||||
|
||||
if (field.fieldtype === 'Link' && field.options !== 'User') {
|
||||
if (!field.create) {
|
||||
field.create = (value, field, row, close) => {
|
||||
const callback = (d) => {
|
||||
if (d) fieldChange(d.name, field, row)
|
||||
}
|
||||
createDocument(field.options, value, close, callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (field.fieldtype === 'Link' && field.options === 'User') {
|
||||
field.fieldtype = 'User'
|
||||
field.link_filters = JSON.stringify({
|
||||
name: ['in', users.data.crmUsers?.map((user) => user.name)],
|
||||
ignore_user_type: 1,
|
||||
...(parseLinkFilters(field.link_filters) || {}),
|
||||
})
|
||||
}
|
||||
|
||||
const fieldObjWithFilters = {
|
||||
...field,
|
||||
filters: parseLinkFilters(field.link_filters),
|
||||
placeholder: field.placeholder || field.label,
|
||||
}
|
||||
|
||||
return {
|
||||
...fieldObjWithFilters,
|
||||
...props.overrides.fields?.find((f) => f.fieldname === field.fieldname),
|
||||
}
|
||||
}
|
||||
|
||||
const gridTemplateColumns = computed(() => {
|
||||
if (!fields.value?.length) return '1fr'
|
||||
let gridViewSettings = getGridViewSettings(props.parentDoctype)
|
||||
return fields.value
|
||||
.map((f) => {
|
||||
const gs = gridViewSettings.length
|
||||
? gridViewSettings.find((g) => g.fieldname === f.fieldname)
|
||||
: f
|
||||
return `minmax(0, ${gs?.columns || 2}fr)`
|
||||
})
|
||||
.join(' ')
|
||||
})
|
||||
|
||||
const allRowsSelected = computed(() => {
|
||||
if (!rows.value?.length) return false
|
||||
return rows.value.length === selectedRows.size
|
||||
})
|
||||
|
||||
const showDeleteBtn = computed(() => selectedRows.size > 0)
|
||||
|
||||
const toggleSelectAllRows = (iSelected) => {
|
||||
if (iSelected) {
|
||||
rows.value?.forEach((row) => selectedRows.add(row.name))
|
||||
} else {
|
||||
selectedRows.clear()
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelectRow = (row) => {
|
||||
if (selectedRows.has(row.name)) {
|
||||
selectedRows.delete(row.name)
|
||||
} else {
|
||||
selectedRows.add(row.name)
|
||||
}
|
||||
}
|
||||
|
||||
const addRow = () => {
|
||||
const newRow = {}
|
||||
allFields.value?.forEach((field) => {
|
||||
if (field.fieldtype === 'Check') {
|
||||
newRow[field.fieldname] = false
|
||||
} else {
|
||||
newRow[field.fieldname] = ''
|
||||
}
|
||||
|
||||
if (field.default) {
|
||||
newRow[field.fieldname] = getDefaultValue(field.default, field.fieldtype)
|
||||
}
|
||||
})
|
||||
newRow.name = getRandom(10)
|
||||
showRowList.value.push(false)
|
||||
newRow['__islocal'] = true
|
||||
newRow['idx'] = rows.value.length + 1
|
||||
newRow['doctype'] = props.doctype
|
||||
newRow['parentfield'] = props.parentFieldname
|
||||
newRow['parenttype'] = props.parentDoctype
|
||||
rows.value.push(newRow)
|
||||
triggerOnRowAdd(newRow)
|
||||
}
|
||||
|
||||
const deleteRows = () => {
|
||||
rows.value = rows.value.filter((row) => !selectedRows.has(row.name))
|
||||
triggerOnRowRemove(selectedRows, rows.value)
|
||||
|
||||
showRowList.value.pop()
|
||||
selectedRows.clear()
|
||||
}
|
||||
|
||||
const reorder = () => {
|
||||
rows.value.forEach((row, index) => {
|
||||
row.idx = index + 1
|
||||
})
|
||||
}
|
||||
|
||||
async function handleButtonClick(field, row) {
|
||||
if (typeof field.click === 'function') {
|
||||
await field.click(row)
|
||||
} else {
|
||||
await triggerButton(field.fieldname, row)
|
||||
}
|
||||
}
|
||||
|
||||
function fieldChange(value, field, row) {
|
||||
value = Array.isArray(value)
|
||||
? value
|
||||
: typeof value === 'object' && value !== null && 'value' in value
|
||||
? value.value
|
||||
: value
|
||||
|
||||
triggerOnChange(field.fieldname, value, row)
|
||||
}
|
||||
|
||||
function getDefaultValue(defaultValue, fieldtype) {
|
||||
if (['Float', 'Currency', 'Percent'].includes(fieldtype)) {
|
||||
return flt(defaultValue)
|
||||
} else if (fieldtype === 'Check') {
|
||||
if (['1', 'true', 'True'].includes(defaultValue)) {
|
||||
return true
|
||||
} else if (['0', 'false', 'False'].includes(defaultValue)) {
|
||||
return false
|
||||
}
|
||||
} else if (fieldtype === 'Int') {
|
||||
return parseInt(defaultValue)
|
||||
} else if (defaultValue === 'Today' && fieldtype === 'Date') {
|
||||
return dayjs().format('YYYY-MM-DD')
|
||||
} else if (
|
||||
['Now', 'now'].includes(defaultValue) &&
|
||||
fieldtype === 'Datetime'
|
||||
) {
|
||||
return dayjs().format('YYYY-MM-DD HH:mm:ss')
|
||||
} else if (['Now', 'now'].includes(defaultValue) && fieldtype === 'Time') {
|
||||
return dayjs().format('HH:mm:ss')
|
||||
} else if (fieldtype === 'Date') {
|
||||
return dayjs(defaultValue).format('YYYY-MM-DD')
|
||||
} else if (fieldtype === 'Datetime') {
|
||||
return dayjs(defaultValue).format('YYYY-MM-DD HH:mm:ss')
|
||||
} else if (fieldtype === 'Time') {
|
||||
return dayjs(defaultValue).format('HH:mm:ss')
|
||||
}
|
||||
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
const getOptions = (options) => {
|
||||
if (Array.isArray(options)) {
|
||||
return options
|
||||
} else if (typeof options === 'string') {
|
||||
return options.split('\n').map((option) => {
|
||||
return { label: __(option), value: option }
|
||||
})
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* For Input fields */
|
||||
:deep(.grid-row input:not([type='checkbox'])),
|
||||
:deep(.grid-row textarea) {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
:deep(.grid-row input:focus),
|
||||
:deep(.grid-row input:hover),
|
||||
:deep(.grid-row textarea:focus),
|
||||
:deep(.grid-row textarea:hover) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.grid-row input:focus-within),
|
||||
:deep(.grid-row textarea:focus-within) {
|
||||
border: 1px solid var(--outline-gray-2);
|
||||
}
|
||||
|
||||
/* For select field */
|
||||
:deep(.grid-row select) {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
/* For Autocomplete, Link */
|
||||
:deep(.grid-row button:not(.button-control):not(.rating-star)),
|
||||
:deep(.grid-row .combobox > div > div) {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background-color: var(--surface-base);
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
:deep(.grid-row:last-child .grid-row-checkbox) {
|
||||
border-bottom-left-radius: 7px;
|
||||
}
|
||||
|
||||
:deep(.grid-row .edit-row button) {
|
||||
border-bottom-right-radius: 7px;
|
||||
}
|
||||
|
||||
:deep(.grid-row button:focus),
|
||||
:deep(.grid-row button:hover) {
|
||||
box-shadow: none;
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
|
||||
:deep(.grid-row button:focus-within:not(.rating-star):not(.button-control)) {
|
||||
border: 1px solid var(--outline-gray-2);
|
||||
}
|
||||
</style>
|
||||
209
frontend/src/components/Controls/GridFieldsEditorModal.vue
Normal file
209
frontend/src/components/Controls/GridFieldsEditorModal.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<Dialog v-model:open="show">
|
||||
<template #title>
|
||||
<h3
|
||||
class="flex items-center gap-2 text-3xl-semibold leading-6 text-ink-gray-9"
|
||||
>
|
||||
<div>{{ __('Edit Grid Fields Layout') }}</div>
|
||||
<Badge
|
||||
v-if="dirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
</h3>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="mt-4">
|
||||
<div class="text-base text-ink-gray-8 mb-2">
|
||||
{{ __('Fields Order') }}
|
||||
</div>
|
||||
<Draggable
|
||||
v-if="oldFields?.length"
|
||||
:list="fields"
|
||||
group="fields"
|
||||
item-key="fieldname"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<template #item="{ element: field }">
|
||||
<div
|
||||
class="px-1 py-0.5 bg-surface-gray-2 border border-outline-elevation-2 rounded text-base text-ink-gray-8 flex items-center justify-between gap-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<DragVerticalIcon class="h-3.5 cursor-grab" />
|
||||
<div>{{ field.label }}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<TextInput
|
||||
v-model="field.columns"
|
||||
variant="outline"
|
||||
type="number"
|
||||
class="w-20"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="lucide-x"
|
||||
@click="removeField(field)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
<Combobox
|
||||
v-if="dropdownFields?.length"
|
||||
:model-value="null"
|
||||
:options="dropdownFields"
|
||||
@update:selected-option="(e) => addField(e)"
|
||||
>
|
||||
<template #trigger="{ open, setOpen }">
|
||||
<Button
|
||||
class="w-full mt-2"
|
||||
:label="__('Add Field')"
|
||||
iconLeft="plus"
|
||||
@click="setOpen(!open)"
|
||||
/>
|
||||
</template>
|
||||
<template #item-label="{ item }">
|
||||
<div class="flex flex-col gap-1 text-ink-gray-9">
|
||||
<div>{{ item.label }}</div>
|
||||
<div class="text-ink-gray-4 text-sm">
|
||||
{{ `${item.fieldname} - ${item.fieldtype}` }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Combobox>
|
||||
<ErrorMessage v-if="error" class="mt-3" :message="error" />
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex items-center gap-2 justify-end">
|
||||
<Button
|
||||
v-if="dirty"
|
||||
class="w-full"
|
||||
:label="__('Reset')"
|
||||
@click="reset"
|
||||
/>
|
||||
<Button
|
||||
class="w-full"
|
||||
:label="__('Save')"
|
||||
variant="solid"
|
||||
:loading="loading"
|
||||
:disabled="!dirty"
|
||||
@click="update"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import DragVerticalIcon from '@/components/Icons/DragVerticalIcon.vue'
|
||||
import { getMeta } from '@/stores/meta'
|
||||
import Draggable from 'vuedraggable'
|
||||
import { Combobox, Dialog, ErrorMessage } from 'frappe-ui'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: { type: String, default: '' },
|
||||
parentDoctype: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const restrictedFieldTypes = [
|
||||
'Section Break',
|
||||
'Column Break',
|
||||
'Tab Break',
|
||||
'Table',
|
||||
'Table MultiSelect',
|
||||
'Image',
|
||||
]
|
||||
|
||||
const { getFields, getGridViewSettings, saveUserSettings } = getMeta(
|
||||
props.doctype,
|
||||
)
|
||||
|
||||
const show = defineModel({ type: Boolean })
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
const dirty = computed(() => {
|
||||
return JSON.stringify(fields.value) !== JSON.stringify(oldFields.value)
|
||||
})
|
||||
|
||||
const oldFields = computed(() => {
|
||||
let _fields =
|
||||
getFields({ restrictNoValueFields: false, restrictedFieldTypes }) || []
|
||||
if (!_fields.length) return []
|
||||
|
||||
let gridViewSettings = getGridViewSettings(props.parentDoctype)
|
||||
|
||||
if (gridViewSettings.length) {
|
||||
return gridViewSettings.map((field) => {
|
||||
let f = _fields.find((f) => f.fieldname === field.fieldname)
|
||||
if (f) {
|
||||
f.columns = field.columns
|
||||
return fieldObj(f)
|
||||
}
|
||||
})
|
||||
}
|
||||
return _fields?.filter((field) => field.in_list_view).map((f) => fieldObj(f))
|
||||
})
|
||||
|
||||
const fields = ref(JSON.parse(JSON.stringify(oldFields.value || [])))
|
||||
|
||||
const dropdownFields = computed(() => {
|
||||
const _fields =
|
||||
getFields({ restrictNoValueFields: false, restrictedFieldTypes }) || []
|
||||
if (!_fields.length) return []
|
||||
|
||||
return _fields
|
||||
.filter(
|
||||
(field) => !fields.value.find((f) => f.fieldname === field.fieldname),
|
||||
)
|
||||
.map((field) => ({ ...field, value: field.fieldname }))
|
||||
})
|
||||
|
||||
function reset() {
|
||||
fields.value = JSON.parse(JSON.stringify(oldFields.value || []))
|
||||
}
|
||||
|
||||
function addField(field) {
|
||||
fields.value.push(fieldObj(field))
|
||||
}
|
||||
|
||||
function removeField(field) {
|
||||
const index = fields.value.findIndex((f) => f.fieldname === field.fieldname)
|
||||
fields.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function update() {
|
||||
loading.value = true
|
||||
|
||||
let updateFields = fields.value.map((field) => {
|
||||
return {
|
||||
fieldname: field.fieldname,
|
||||
columns: field.columns,
|
||||
}
|
||||
})
|
||||
|
||||
if (updateFields.length === 0) {
|
||||
error.value = __('At least one field is required')
|
||||
return
|
||||
}
|
||||
|
||||
saveUserSettings(props.parentDoctype, 'GridView', updateFields, () => {
|
||||
loading.value = false
|
||||
show.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function fieldObj(field) {
|
||||
return {
|
||||
label: field.label,
|
||||
fieldname: field.fieldname,
|
||||
fieldtype: field.fieldtype,
|
||||
options: field.options,
|
||||
in_list_view: field.in_list_view,
|
||||
columns: field.columns || 2,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
129
frontend/src/components/Controls/GridRowFieldsModal.vue
Normal file
129
frontend/src/components/Controls/GridRowFieldsModal.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<Dialog v-model:open="show" :size="'4xl'">
|
||||
<template #title>
|
||||
<h3
|
||||
class="flex items-center gap-2 text-3xl-semibold leading-6 text-ink-gray-9"
|
||||
>
|
||||
<div>{{ __('Edit Grid Row Fields Layout') }}</div>
|
||||
<Badge
|
||||
v-if="dirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
</h3>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex justify-between gap-2">
|
||||
<Button
|
||||
:label="preview ? __('Hide Preview') : __('Show Preview')"
|
||||
@click="preview = !preview"
|
||||
/>
|
||||
<div class="flex flex-row-reverse gap-2">
|
||||
<Button
|
||||
:loading="loading"
|
||||
:label="__('Save')"
|
||||
variant="solid"
|
||||
@click="saveChanges"
|
||||
/>
|
||||
<Button :label="__('Reset')" @click="reload" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tabs?.data">
|
||||
<FieldLayoutEditor
|
||||
v-if="!preview"
|
||||
v-model="tabs.data"
|
||||
:doctype="_doctype"
|
||||
/>
|
||||
<FieldLayout v-else :tabs="tabs.data" :data="{}" :preview="true" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
|
||||
import FieldLayoutEditor from '@/components/FieldLayoutEditor.vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { useTelemetry } from 'frappe-ui/frappe'
|
||||
import { Dialog, Badge, call, createResource } from 'frappe-ui'
|
||||
import { ref, watch, onMounted, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: { type: String, default: 'CRM Lead' },
|
||||
parentDoctype: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['reload'])
|
||||
|
||||
const { capture } = useTelemetry()
|
||||
|
||||
const show = defineModel({ type: Boolean })
|
||||
const _doctype = ref(props.doctype)
|
||||
const loading = ref(false)
|
||||
const dirty = ref(false)
|
||||
const preview = ref(false)
|
||||
|
||||
function getParams() {
|
||||
return {
|
||||
doctype: _doctype.value,
|
||||
type: 'Grid Row',
|
||||
parent_doctype: props.parentDoctype,
|
||||
}
|
||||
}
|
||||
|
||||
const tabs = createResource({
|
||||
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
|
||||
cache: ['GridRowFieldsModal', _doctype.value, props.parentDoctype],
|
||||
params: getParams(),
|
||||
onSuccess(data) {
|
||||
tabs.originalData = JSON.parse(JSON.stringify(data))
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => tabs?.data,
|
||||
() => {
|
||||
dirty.value =
|
||||
JSON.stringify(tabs?.data) !== JSON.stringify(tabs?.originalData)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onMounted(() => useDebounceFn(reload, 100)())
|
||||
|
||||
function reload() {
|
||||
nextTick(() => {
|
||||
tabs.params = getParams()
|
||||
tabs.reload()
|
||||
})
|
||||
}
|
||||
|
||||
function saveChanges() {
|
||||
let _tabs = JSON.parse(JSON.stringify(tabs.data))
|
||||
_tabs.forEach((tab) => {
|
||||
if (!tab.sections) return
|
||||
tab.sections.forEach((section) => {
|
||||
section.columns.forEach((column) => {
|
||||
if (!column.fields) return
|
||||
column.fields = column.fields.map((field) => field.fieldname)
|
||||
})
|
||||
})
|
||||
})
|
||||
loading.value = true
|
||||
call(
|
||||
'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.save_fields_layout',
|
||||
{
|
||||
doctype: _doctype.value,
|
||||
type: 'Grid Row',
|
||||
layout: JSON.stringify(_tabs),
|
||||
},
|
||||
).then(() => {
|
||||
loading.value = false
|
||||
show.value = false
|
||||
capture('data_fields_layout_builder', { doctype: _doctype.value })
|
||||
emit('reload')
|
||||
})
|
||||
}
|
||||
</script>
|
||||
81
frontend/src/components/Controls/GridRowModal.vue
Normal file
81
frontend/src/components/Controls/GridRowModal.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<Dialog v-model:open="show" :size="'4xl'">
|
||||
<template #body>
|
||||
<div class="bg-surface-elevation-2 px-4 pb-6 pt-5 sm:px-6">
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-3xl-semibold leading-6 text-ink-gray-9">
|
||||
{{ __('Editing Row {0}', [index + 1]) }}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
v-if="isManager()"
|
||||
:tooltip="__('Edit Fields Layout')"
|
||||
variant="ghost"
|
||||
class="w-7"
|
||||
:icon="EditIcon"
|
||||
@click="openGridRowFieldsModal"
|
||||
/>
|
||||
<Button
|
||||
icon="lucide-x"
|
||||
variant="ghost"
|
||||
class="w-7"
|
||||
@click="show = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLayout
|
||||
v-if="tabs.data"
|
||||
:tabs="tabs.data"
|
||||
:data="data"
|
||||
:doctype="doctype"
|
||||
:isGridRow="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { createResource } from 'frappe-ui'
|
||||
import { nextTick, provide } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
index: { type: Number, default: 0 },
|
||||
data: { type: Object, default: () => ({}) },
|
||||
doctype: { type: String, default: '' },
|
||||
parentDoctype: { type: String, default: '' },
|
||||
parentFieldname: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const { isManager } = usersStore()
|
||||
|
||||
provide('parentFieldname', props.parentFieldname)
|
||||
|
||||
const show = defineModel({ type: Boolean })
|
||||
const showGridRowFieldsModal = defineModel('showGridRowFieldsModal', {
|
||||
type: Boolean,
|
||||
})
|
||||
|
||||
const tabs = createResource({
|
||||
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
|
||||
cache: ['Grid Row', props.doctype, props.parentDoctype],
|
||||
params: {
|
||||
doctype: props.doctype,
|
||||
type: 'Grid Row',
|
||||
parent_doctype: props.parentDoctype,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
function openGridRowFieldsModal() {
|
||||
showGridRowFieldsModal.value = true
|
||||
nextTick(() => (show.value = false))
|
||||
}
|
||||
</script>
|
||||
19
frontend/src/components/Controls/HtmlControl.vue
Normal file
19
frontend/src/components/Controls/HtmlControl.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="html-control text-sm text-ink-gray-8" v-html="sanitizedHtml" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { sanitizeHTML } from '@/utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
html: { type: String, default: '' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const sanitizedHtml = computed(() => {
|
||||
if (!props.html) return ''
|
||||
return sanitizeHTML(props.html)
|
||||
})
|
||||
</script>
|
||||
37
frontend/src/components/Controls/ImageUploader.vue
Normal file
37
frontend/src/components/Controls/ImageUploader.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<FileUploader
|
||||
:file-types="image_type"
|
||||
@success="(file) => emit('upload', file.file_url)"
|
||||
>
|
||||
<template #default="{ progress, uploading, openFileSelector }">
|
||||
<div class="flex items-end space-x-1">
|
||||
<Button
|
||||
:iconLeft="uploading ? 'cloud-upload' : ImageUpIcon"
|
||||
:label="
|
||||
uploading
|
||||
? __('Uploading {0}%', [progress])
|
||||
: image_url
|
||||
? __('Change')
|
||||
: __('Upload')
|
||||
"
|
||||
@click="openFileSelector"
|
||||
/>
|
||||
<Button
|
||||
v-if="image_url"
|
||||
:label="__('Remove')"
|
||||
@click="emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
</template>
|
||||
<script setup>
|
||||
import ImageUpIcon from '~icons/lucide/image-up'
|
||||
import { FileUploader, Button } from 'frappe-ui'
|
||||
|
||||
defineProps({
|
||||
image_url: { type: String, default: '' },
|
||||
image_type: { type: String, default: 'image/*' },
|
||||
})
|
||||
const emit = defineEmits(['upload', 'remove'])
|
||||
</script>
|
||||
204
frontend/src/components/Controls/Link.vue
Normal file
204
frontend/src/components/Controls/Link.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="space-y-1.5 p-[2px] !-m-[2px]">
|
||||
<label v-if="attrs.label" class="block" :class="labelClasses">
|
||||
{{ __(attrs.label) }}
|
||||
</label>
|
||||
<Autocomplete
|
||||
ref="autocomplete"
|
||||
v-model="value"
|
||||
:options="options.data"
|
||||
:size="attrs.size || 'sm'"
|
||||
:variant="attrs.variant"
|
||||
:placeholder="attrs.placeholder"
|
||||
:disabled="attrs.disabled"
|
||||
:placement="attrs.placement"
|
||||
:filterable="false"
|
||||
>
|
||||
<template #target="{ open, togglePopover }">
|
||||
<slot name="target" v-bind="{ open, togglePopover }" />
|
||||
</template>
|
||||
|
||||
<template #prefix>
|
||||
<slot name="prefix" />
|
||||
</template>
|
||||
|
||||
<template #item-prefix="{ active, selected, option }">
|
||||
<slot name="item-prefix" v-bind="{ active, selected, option }" />
|
||||
</template>
|
||||
|
||||
<template #item-label="{ active, selected, option }">
|
||||
<slot name="item-label" v-bind="{ active, selected, option }">
|
||||
<div v-if="option.description" class="flex flex-col gap-1">
|
||||
<div class="flex-1 font-semibold truncate text-ink-gray-7">
|
||||
{{ option.label }}
|
||||
</div>
|
||||
<div class="flex-1 text-sm truncate text-ink-gray-5">
|
||||
{{ option.description }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex-1 truncate text-ink-gray-7">
|
||||
{{ option.label }}
|
||||
</div>
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<template #footer="{ value: v, close }">
|
||||
<div v-if="attrs.onCreate">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
:label="__('Create New')"
|
||||
iconLeft="plus"
|
||||
@click="() => attrs.onCreate(v, close)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
:label="__('Clear')"
|
||||
iconLeft="x"
|
||||
@click="() => clearValue(close)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Autocomplete>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
|
||||
import { isTranslatable } from '@/utils'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { createResource } from 'frappe-ui'
|
||||
import { useAttrs, computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: { type: String, required: true },
|
||||
filters: { type: [Array, Object, String], default: () => [] },
|
||||
modelValue: { type: String, default: '' },
|
||||
hideMe: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const valuePropPassed = computed(() => 'value' in attrs)
|
||||
|
||||
const value = computed({
|
||||
get: () => {
|
||||
let v = valuePropPassed.value ? attrs.value : props.modelValue
|
||||
|
||||
if (isTranslatable(props.doctype)) return __(v)
|
||||
return v
|
||||
},
|
||||
set: (val) => {
|
||||
return (
|
||||
val?.value &&
|
||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val?.value)
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const autocomplete = ref(null)
|
||||
const text = ref('')
|
||||
|
||||
watchDebounced(
|
||||
() => autocomplete.value?.query,
|
||||
(val) => {
|
||||
val = val || ''
|
||||
if (text.value === val) return
|
||||
text.value = val
|
||||
reload(val)
|
||||
},
|
||||
{ debounce: 300, immediate: true },
|
||||
)
|
||||
|
||||
watchDebounced(
|
||||
() => props.doctype,
|
||||
() => reload(''),
|
||||
{ debounce: 300, immediate: true },
|
||||
)
|
||||
|
||||
watchDebounced(
|
||||
() => props.filters,
|
||||
() => {
|
||||
reload('', true)
|
||||
},
|
||||
{ debounce: 300, immediate: true },
|
||||
)
|
||||
|
||||
const options = createResource({
|
||||
url: 'frappe.desk.search.search_link',
|
||||
cache: [props.doctype, text.value, props.hideMe, props.filters],
|
||||
method: 'POST',
|
||||
params: {
|
||||
txt: text.value,
|
||||
doctype: props.doctype,
|
||||
filters: props.filters,
|
||||
},
|
||||
transform: (data) => {
|
||||
let allData = data.map((option) => {
|
||||
return {
|
||||
label: option.label || option.value,
|
||||
value: option.value,
|
||||
description: stripHtml(option.description),
|
||||
}
|
||||
})
|
||||
if (!props.hideMe && props.doctype == 'User') {
|
||||
allData.unshift({
|
||||
label: '@me',
|
||||
value: '@me',
|
||||
})
|
||||
}
|
||||
return allData
|
||||
},
|
||||
})
|
||||
|
||||
function stripHtml(html) {
|
||||
if (!html) return ''
|
||||
return html
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function reload(val, force = false) {
|
||||
if (!props.doctype) return
|
||||
if (
|
||||
!force &&
|
||||
options.data?.length &&
|
||||
val === options.params?.txt &&
|
||||
props.doctype === options.params?.doctype
|
||||
)
|
||||
return
|
||||
|
||||
options.update({
|
||||
params: {
|
||||
txt: val,
|
||||
doctype: props.doctype,
|
||||
filters: props.filters,
|
||||
},
|
||||
})
|
||||
options.reload()
|
||||
}
|
||||
|
||||
function clearValue(close) {
|
||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', '')
|
||||
close()
|
||||
}
|
||||
|
||||
const labelClasses = computed(() => {
|
||||
return [
|
||||
{
|
||||
sm: 'text-xs',
|
||||
md: 'text-base',
|
||||
}[attrs.size || 'sm'],
|
||||
'text-ink-gray-5',
|
||||
]
|
||||
})
|
||||
|
||||
defineExpose({ reload })
|
||||
</script>
|
||||
56
frontend/src/components/Controls/Password.vue
Normal file
56
frontend/src/components/Controls/Password.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<FormControl
|
||||
:type="show ? 'text' : 'password'"
|
||||
:value="modelValue || value"
|
||||
v-bind="$attrs"
|
||||
@keydown.meta.i.prevent="show = !show"
|
||||
@keydown.ctrl.i.prevent="show = !show"
|
||||
>
|
||||
<template v-if="$slots.prefix" #prefix>
|
||||
<slot name="prefix" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<Tooltip>
|
||||
<template #body>
|
||||
<div
|
||||
class="rounded bg-surface-gray-10 py-1.5 px-2 text-xs text-ink-base shadow-xl"
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
{{ show ? __('Hide Password') : __('Show Password') }}
|
||||
<KeyboardShortcut
|
||||
bg
|
||||
ctrl
|
||||
class="!bg-surface-gray-8 !text-ink-gray-2 px-1"
|
||||
>
|
||||
<span class="font-mono leading-none tracking-widest">+I</span>
|
||||
</KeyboardShortcut>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<FeatherIcon
|
||||
v-show="showEye"
|
||||
:name="show ? 'eye-off' : 'eye'"
|
||||
class="h-3 cursor-pointer mr-1"
|
||||
@click="show = !show"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</FormControl>
|
||||
</template>
|
||||
<script setup>
|
||||
import KeyboardShortcut from '@/components/KeyboardShortcut.vue'
|
||||
import { FormControl, Tooltip } from 'frappe-ui'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
value: { type: [String, Number], default: '' },
|
||||
})
|
||||
const show = ref(false)
|
||||
const showEye = computed(() => {
|
||||
let v = props.modelValue || props.value
|
||||
return !v?.includes('*')
|
||||
})
|
||||
</script>
|
||||
138
frontend/src/components/Controls/RatingInput.vue
Normal file
138
frontend/src/components/Controls/RatingInput.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<Tooltip :text="tooltipText" placement="right" :hoverDelay="0">
|
||||
<div
|
||||
class="rating-stars inline-flex flex-wrap items-center gap-0.5 transition-opacity"
|
||||
:class="disabled ? 'opacity-50' : ''"
|
||||
v-bind="$attrs"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
<button
|
||||
v-for="i in nStars"
|
||||
:key="i"
|
||||
type="button"
|
||||
class="focus:outline-none leading-none rating-star"
|
||||
:class="disabled ? 'cursor-default' : 'cursor-pointer'"
|
||||
@mousemove="!disabled && onMouseMove($event, i)"
|
||||
@click="!disabled && onStarClick($event, i)"
|
||||
>
|
||||
<!--
|
||||
Three visual states during hover:
|
||||
FILLED (yellow-500) — committed stars that are staying
|
||||
PREVIEW (yellow-200) — stars that will be added on click
|
||||
REMOVING(yellow-300) — committed stars that will be removed on click
|
||||
-->
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none">
|
||||
<path
|
||||
d="M11.9987 3.00011C11.8207 3.00011 11.6428 3.09261 11.5509 3.27762L9.15562 8.09836C9.08253 8.24546 8.94185 8.34728 8.77927 8.37075L3.42887 9.14298C3.01771 9.20233 2.85405 9.70811 3.1525 9.99707L7.01978 13.7414C7.13858 13.8564 7.19283 14.0228 7.16469 14.1857L6.25116 19.4762C6.18071 19.8842 6.6083 20.1961 6.97531 20.0045L11.7672 17.5022C11.8397 17.4643 11.9192 17.4454 11.9987 17.4454V3.00011Z"
|
||||
:style="{
|
||||
fill: getHalfColor(i - 0.5),
|
||||
stroke: getHalfColor(i - 0.5),
|
||||
}"
|
||||
/>
|
||||
<path
|
||||
d="M11.9987 3.00011C12.177 3.00011 12.3554 3.09303 12.4471 3.27888L14.8213 8.09112C14.8941 8.23872 15.0349 8.34102 15.1978 8.3647L20.5069 9.13641C20.917 9.19602 21.0807 9.69992 20.7841 9.9892L16.9421 13.7354C16.8243 13.8503 16.7706 14.0157 16.7984 14.1779L17.7053 19.4674C17.7753 19.8759 17.3466 20.1874 16.9798 19.9945L12.2314 17.4973C12.1586 17.459 12.0786 17.4398 11.9987 17.4398V3.00011Z"
|
||||
:style="{ fill: getHalfColor(i), stroke: getHalfColor(i) }"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { Tooltip } from 'frappe-ui'
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: Number, default: 0 },
|
||||
max: { type: [Number, String], default: 5 },
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const nStars = computed(() => Number(props.max) || 5)
|
||||
const hoveredStarValue = ref(null)
|
||||
|
||||
// Saved value in star-units, rounded to nearest 0.5
|
||||
const savedStarValue = computed(() => {
|
||||
const raw = (props.value || 0) * nStars.value
|
||||
return Math.round(raw * 2) / 2
|
||||
})
|
||||
|
||||
// CSS custom properties — values defined in <style> block, dark mode overridden there
|
||||
const FILLED = 'var(--rating-filled)'
|
||||
const PREVIEW = 'var(--rating-preview)'
|
||||
const REMOVING = 'var(--rating-removing)'
|
||||
const EMPTY = 'var(--rating-empty)'
|
||||
|
||||
/**
|
||||
* Each star is two half-values: (i - 0.5) for left, (i) for right.
|
||||
* During hover we show three states so the saved rating stays readable:
|
||||
* halves ≤ min(saved, hovered) → FILLED (committed & staying)
|
||||
* halves > saved, ≤ hovered → PREVIEW (will be added)
|
||||
* halves > hovered, ≤ saved → REMOVING (will be cleared)
|
||||
* halves > max(saved, hovered)→ EMPTY
|
||||
*/
|
||||
function getHalfColor(halfValue) {
|
||||
const saved = savedStarValue.value
|
||||
const hovered = hoveredStarValue.value
|
||||
|
||||
if (hovered === null) {
|
||||
return halfValue <= saved ? FILLED : EMPTY
|
||||
}
|
||||
if (halfValue <= Math.min(saved, hovered)) return FILLED
|
||||
if (halfValue <= hovered) return PREVIEW
|
||||
if (halfValue <= saved) return REMOVING
|
||||
return EMPTY
|
||||
}
|
||||
|
||||
// Tooltip text: in read-only mode shows saved value on hover;
|
||||
// in interactive mode shows hovered preview value while hovering
|
||||
const tooltipText = computed(() => {
|
||||
const display = props.disabled ? savedStarValue.value : hoveredStarValue.value
|
||||
if (!display) return null
|
||||
const label = display % 1 === 0 ? String(display) : display.toFixed(1)
|
||||
return `${label} / ${nStars.value}`
|
||||
})
|
||||
|
||||
function onMouseMove(event, i) {
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
hoveredStarValue.value =
|
||||
event.clientX - rect.left < rect.width / 2 ? i - 0.5 : i
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
hoveredStarValue.value = null
|
||||
}
|
||||
|
||||
function onStarClick(event, i) {
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const isLeftHalf = event.clientX - rect.left < rect.width / 2
|
||||
let newStarValue = isLeftHalf ? i - 0.5 : i
|
||||
|
||||
// Toggle off when clicking the already-set value
|
||||
const currentStarValue = Math.round((props.value || 0) * nStars.value * 2) / 2
|
||||
if (newStarValue === currentStarValue) newStarValue = 0
|
||||
|
||||
emit('change', newStarValue / nStars.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Light mode */
|
||||
.rating-stars {
|
||||
--rating-filled: #eab308; /* yellow-500 */
|
||||
--rating-preview: #fde68a; /* yellow-200 */
|
||||
--rating-removing: #fcd34d; /* yellow-300 */
|
||||
--rating-empty: #d1d5db; /* gray-300 */
|
||||
}
|
||||
|
||||
/* Dark mode — [data-theme="dark"] is how frappe-ui activates dark mode */
|
||||
[data-theme='dark'] .rating-stars {
|
||||
--rating-filled: #eab308; /* yellow-500 — same, readable on dark */
|
||||
--rating-preview: #fde68a; /* yellow-200 — bright preview on dark */
|
||||
--rating-removing: #fcd34d; /* yellow-300 */
|
||||
--rating-empty: #4b5563; /* gray-600 — visible on dark bg */
|
||||
}
|
||||
</style>
|
||||
149
frontend/src/components/Controls/TableMultiselectInput.vue
Normal file
149
frontend/src/components/Controls/TableMultiselectInput.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="group flex flex-wrap gap-1 min-h-20 p-1.5 rounded text-base bg-surface-gray-2 hover:bg-surface-gray-3 focus:border-outline-gray-4 focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full"
|
||||
>
|
||||
<Button
|
||||
v-for="value in parsedValues"
|
||||
ref="valuesRef"
|
||||
:key="value"
|
||||
:label="value"
|
||||
theme="gray"
|
||||
variant="subtle"
|
||||
class="rounded bg-surface-base hover:!bg-surface-gray-1 focus-visible:ring-outline-gray-4"
|
||||
@keydown.delete.capture.stop="removeLastValue"
|
||||
>
|
||||
<template #suffix>
|
||||
<span
|
||||
class="lucide-x h-3.5"
|
||||
aria-hidden="true"
|
||||
@click.stop="removeValue(value)"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
<div class="w-full">
|
||||
<Link
|
||||
v-if="linkField"
|
||||
class="form-control flex-1 truncate cursor-text"
|
||||
:value="query"
|
||||
:filters="filters"
|
||||
:doctype="linkField.options"
|
||||
:onCreate="create"
|
||||
:hideMe="true"
|
||||
@change="(v) => addValue(v)"
|
||||
>
|
||||
<template #target="{ togglePopover }">
|
||||
<button
|
||||
class="w-full h-7 cursor-text"
|
||||
@click.stop="togglePopover"
|
||||
/>
|
||||
</template>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage v-if="error" class="mt-2 pl-2" :message="error" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { createDocument } from '@/composables/document'
|
||||
import { getMeta } from '@/stores/meta'
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: { type: String, required: true },
|
||||
errorMessage: {
|
||||
type: Function,
|
||||
default: (value) => `${value} is an Invalid value`,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const { getFields } = getMeta(props.doctype)
|
||||
|
||||
const values = defineModel({ type: Array, default: () => [] })
|
||||
|
||||
const valuesRef = ref([])
|
||||
const error = ref(null)
|
||||
const query = ref('')
|
||||
|
||||
const linkField = ref('')
|
||||
|
||||
const filters = computed(() => {
|
||||
if (!linkField.value) return []
|
||||
return { name: ['not in', parsedValues.value] }
|
||||
})
|
||||
|
||||
const parsedValues = computed(() => {
|
||||
getLinkField()
|
||||
if (!linkField.value) return []
|
||||
return values.value.map((row) => row[linkField.value.fieldname])
|
||||
})
|
||||
|
||||
const getLinkField = () => {
|
||||
error.value = ''
|
||||
if (!linkField.value) {
|
||||
let fields = getFields()
|
||||
linkField.value = fields?.find((df) =>
|
||||
['Link', 'User'].includes(df.fieldtype),
|
||||
)
|
||||
if (!linkField.value) {
|
||||
error.value = __(
|
||||
'Table MultiSelect requires a Table with at least one Link field',
|
||||
)
|
||||
}
|
||||
}
|
||||
return linkField.value
|
||||
}
|
||||
|
||||
const addValue = (value) => {
|
||||
error.value = null
|
||||
|
||||
if (values.value.some((row) => row[linkField.value.fieldname] === value)) {
|
||||
error.value = __('Value already exists')
|
||||
return
|
||||
}
|
||||
|
||||
if (value) {
|
||||
values.value.push({ [linkField.value.fieldname]: value })
|
||||
emit('change', values.value)
|
||||
if (!error.value) {
|
||||
query.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeValue = (value) => {
|
||||
let _value = values.value.filter(
|
||||
(row) => row[linkField.value.fieldname] !== value,
|
||||
)
|
||||
emit('change', _value)
|
||||
}
|
||||
|
||||
const removeLastValue = () => {
|
||||
if (query.value) return
|
||||
|
||||
let valueRef = valuesRef.value[valuesRef.value.length - 1]?.$el
|
||||
if (document.activeElement === valueRef) {
|
||||
values.value.pop()
|
||||
emit('change', values.value)
|
||||
nextTick(() => {
|
||||
if (values.value.length) {
|
||||
valueRef = valuesRef.value[valuesRef.value.length - 1].$el
|
||||
valueRef?.focus()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
valueRef?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function create(value, close) {
|
||||
const callback = (d) => {
|
||||
if (d) addValue(d.name)
|
||||
}
|
||||
createDocument(linkField.value.options, value, close, callback)
|
||||
}
|
||||
</script>
|
||||
116
frontend/src/components/Controls/TextEditorControl.vue
Normal file
116
frontend/src/components/Controls/TextEditorControl.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<Editor
|
||||
v-model="content"
|
||||
:extensions="extensions"
|
||||
:placeholder="placeholder"
|
||||
:editable="!disabled"
|
||||
:upload-function="(file) => uploadFile(file)"
|
||||
@change="onContentChange"
|
||||
@blur="onBlur"
|
||||
>
|
||||
<EditorFixedMenu
|
||||
v-if="fixedMenu && !disabled"
|
||||
:items="fullToolbar"
|
||||
class="w-full overflow-x-auto rounded-t-lg border border-outline-gray-2 p-1"
|
||||
/>
|
||||
<EditorBubbleMenu v-if="bubbleMenu" :items="bubbleToolbar" />
|
||||
<EditorContent :class="editorClasses" />
|
||||
</Editor>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Editor,
|
||||
EditorContent,
|
||||
EditorFixedMenu,
|
||||
EditorBubbleMenu,
|
||||
} from 'frappe-ui/editor'
|
||||
import {
|
||||
buildEditorExtensions,
|
||||
fullToolbar,
|
||||
bubbleToolbar,
|
||||
uploadFile,
|
||||
} from '@/components/editor/config'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: String, default: '' },
|
||||
placeholder: { type: String, default: '' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
editorClass: { type: String, default: '' },
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'subtle',
|
||||
validator: (v) => ['outline', 'subtle', 'ghost'].includes(v),
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'sm',
|
||||
validator: (v) => ['sm', 'md', 'lg'].includes(v),
|
||||
},
|
||||
fixedMenu: { type: Boolean, default: true },
|
||||
bubbleMenu: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const editorClasses = computed(() => {
|
||||
const defaultClass = 'max-h-[40vh] overflow-y-auto min-w-full'
|
||||
|
||||
const variantMap = {
|
||||
outline: props.disabled
|
||||
? 'border border-t rounded-lg bg-surface-base'
|
||||
: 'border border-t-0 rounded-b-lg bg-surface-base',
|
||||
subtle: props.disabled
|
||||
? 'border border-t rounded-lg bg-surface-gray-2'
|
||||
: 'border border-t-0 rounded-b-lg bg-surface-gray-2',
|
||||
ghost: 'bg-transparent',
|
||||
}
|
||||
const variantClass = variantMap[props.variant]
|
||||
|
||||
const sizeMap = {
|
||||
sm: props.disabled
|
||||
? 'prose-sm min-h-[4rem] p-2'
|
||||
: 'prose-sm min-h-[8rem] p-2',
|
||||
md: props.disabled
|
||||
? 'prose-base min-h-[4rem] p-3'
|
||||
: 'prose-base min-h-[10rem] p-3',
|
||||
lg: props.disabled
|
||||
? 'prose-lg min-h-[4rem] p-4'
|
||||
: 'prose-lg min-h-[12rem] p-4',
|
||||
}
|
||||
const sizeClasess = sizeMap[props.size]
|
||||
|
||||
const disableClass = props.disabled ? 'opacity-60' : ''
|
||||
|
||||
return [
|
||||
variantClass,
|
||||
sizeClasess,
|
||||
defaultClass,
|
||||
disableClass,
|
||||
props.editorClass,
|
||||
]
|
||||
})
|
||||
|
||||
const extensions = buildEditorExtensions()
|
||||
|
||||
const content = ref(props.value ?? '')
|
||||
const isDirty = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
(val) => {
|
||||
content.value = val ?? ''
|
||||
},
|
||||
)
|
||||
|
||||
function onContentChange(val) {
|
||||
if (val !== (props.value ?? '')) isDirty.value = true
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
if (!isDirty.value) return
|
||||
isDirty.value = false
|
||||
emit('change', content.value)
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user