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

This commit is contained in:
upstream-mirror
2026-09-18 01:36:01 +05:00
commit db69de1c11
905 changed files with 102713 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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(/&nbsp;/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>

View 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>

View 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>

View 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>

View 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>