This commit is contained in:
2024-09-01 18:54:23 +05:00
parent 76d18365a5
commit 061f09eca1
1597 changed files with 109451 additions and 1 deletions

View File

@@ -0,0 +1,334 @@
import { Errors } from '@/mixins'
import { computed, nextTick, reactive } from 'vue'
import each from 'lodash/each'
import find from 'lodash/find'
import filter from 'lodash/filter'
import isNil from 'lodash/isNil'
import isObject from 'lodash/isObject'
import map from 'lodash/map'
import tap from 'lodash/tap'
import trim from 'lodash/trim'
import { useLocalization } from '@/composables/useLocalization'
const { __ } = useLocalization()
export function useActions(props, emitter, store) {
const state = reactive({
working: false,
errors: new Errors(),
actionModalVisible: false,
responseModalVisible: false,
selectedActionKey: '',
endpoint: props.endpoint || `/nova-api/${props.resourceName}/action`,
actionResponseData: null,
})
const selectedResources = computed(() => props.selectedResources)
const selectedAction = computed(() => {
if (state.selectedActionKey) {
return find(allActions.value, a => a.uriKey === state.selectedActionKey)
}
})
const allActions = computed(() =>
props.actions.concat(props.pivotActions?.actions || [])
)
const encodedFilters = computed(
() => store.getters[`${props.resourceName}/currentEncodedFilters`]
)
const searchParameter = computed(() =>
props.viaRelationship
? props.viaRelationship + '_search'
: props.resourceName + '_search'
)
const currentSearch = computed(
() => store.getters.queryStringParams[searchParameter.value] || ''
)
const trashedParameter = computed(() =>
props.viaRelationship
? props.viaRelationship + '_trashed'
: props.resourceName + '_trashed'
)
const currentTrashed = computed(
() => store.getters.queryStringParams[trashedParameter.value] || ''
)
const availableActions = computed(() => {
return filter(
props.actions,
action => selectedResources.value.length > 0 && !action.standalone
)
})
const availablePivotActions = computed(() => {
if (!props.pivotActions) {
return []
}
return filter(props.pivotActions.actions, action => {
if (selectedResources.value.length === 0) {
return action.standalone
}
return true
})
})
const hasPivotActions = computed(() => availablePivotActions.value.length > 0)
const selectedActionIsPivotAction = computed(() => {
return (
hasPivotActions.value &&
Boolean(find(props.pivotActions.actions, a => a === selectedAction.value))
)
})
const actionRequestQueryString = computed(() => {
return {
action: state.selectedActionKey,
pivotAction: selectedActionIsPivotAction.value,
search: currentSearch.value,
filters: encodedFilters.value,
trashed: currentTrashed.value,
viaResource: props.viaResource,
viaResourceId: props.viaResourceId,
viaRelationship: props.viaRelationship,
}
})
const actionFormData = computed(() => {
return tap(new FormData(), formData => {
if (selectedResources.value === 'all') {
formData.append('resources', 'all')
} else {
let pivotIds = filter(
map(selectedResources.value, resource =>
isObject(resource) ? resource.id.pivotValue : null
)
)
each(selectedResources.value, resource =>
formData.append(
'resources[]',
isObject(resource) ? resource.id.value : resource
)
)
if (
selectedResources.value !== 'all' &&
selectedActionIsPivotAction.value === true &&
pivotIds.length > 0
) {
each(pivotIds, pivotId => formData.append('pivots[]', pivotId))
}
}
each(selectedAction.value.fields, field => {
field.fill(formData)
})
})
})
function determineActionStrategy() {
if (selectedAction.value.withoutConfirmation) {
executeAction()
} else {
openConfirmationModal()
}
}
function openConfirmationModal() {
state.actionModalVisible = true
}
function closeConfirmationModal() {
state.actionModalVisible = false
}
function openResponseModal() {
state.responseModalVisible = true
}
function closeResponseModal() {
state.responseModalVisible = false
}
function emitResponseCallback(callback) {
emitter('actionExecuted')
Nova.$emit('action-executed')
if (typeof callback === 'function') {
callback()
}
}
function showActionResponseMessage(data) {
if (data.danger) {
return Nova.error(data.danger)
}
Nova.success(data.message || __('The action was executed successfully.'))
}
function executeAction(then) {
state.working = true
Nova.$progress.start()
let responseType = selectedAction.value.responseType ?? 'json'
Nova.request({
method: 'post',
url: state.endpoint,
params: actionRequestQueryString.value,
data: actionFormData.value,
responseType,
})
.then(async response => {
closeConfirmationModal()
handleActionResponse(response.data, response.headers, then)
})
.catch(error => {
if (error.response && error.response.status === 422) {
if (responseType === 'blob') {
error.response.data.text().then(data => {
state.errors = new Errors(JSON.parse(data).errors)
})
} else {
state.errors = new Errors(error.response.data.errors)
}
Nova.error(__('There was a problem executing the action.'))
}
})
.finally(() => {
state.working = false
Nova.$progress.done()
})
}
function handleActionResponse(data, headers, then) {
let contentDisposition = headers['content-disposition']
if (
data instanceof Blob &&
isNil(contentDisposition) &&
data.type === 'application/json'
) {
data.text().then(jsonStringData => {
handleActionResponse(JSON.parse(jsonStringData), headers)
})
return
}
if (data instanceof Blob) {
return emitResponseCallback(async () => {
let fileName = 'unknown'
if (contentDisposition) {
let fileNameMatch = contentDisposition
.split(';')[1]
.match(/filename=(.+)/)
if (fileNameMatch.length === 2) fileName = trim(fileNameMatch[1], '"')
}
await nextTick(() => {
let url = window.URL.createObjectURL(new Blob([data]))
let link = document.createElement('a')
link.href = url
link.setAttribute('download', fileName)
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
})
})
}
if (data.modal) {
state.actionResponseData = data
showActionResponseMessage(data)
return openResponseModal()
}
if (data.download) {
return emitResponseCallback(async () => {
showActionResponseMessage(data)
await nextTick(() => {
let link = document.createElement('a')
link.href = data.download
link.download = data.name
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
})
})
}
if (data.deleted) {
return emitResponseCallback(() => showActionResponseMessage(data))
}
if (data.redirect) {
window.location = data.redirect
}
if (data.visit) {
showActionResponseMessage(data)
return Nova.visit({
url: Nova.url(data.visit.path, data.visit.options),
remote: false,
})
}
if (data.openInNewTab) {
return emitResponseCallback(() =>
window.open(data.openInNewTab, '_blank')
)
}
emitResponseCallback(() => showActionResponseMessage(data))
}
function handleActionClick(uriKey) {
state.selectedActionKey = uriKey
determineActionStrategy()
}
function setSelectedActionKey(key) {
state.selectedActionKey = key
}
return {
errors: computed(() => state.errors),
working: computed(() => state.working),
actionModalVisible: computed(() => state.actionModalVisible),
responseModalVisible: computed(() => state.responseModalVisible),
selectedActionKey: computed(() => state.selectedActionKey),
determineActionStrategy,
setSelectedActionKey,
openConfirmationModal,
closeConfirmationModal,
openResponseModal,
closeResponseModal,
handleActionClick,
selectedAction,
allActions,
availableActions,
availablePivotActions,
executeAction,
actionResponseData: computed(() => state.actionResponseData),
}
}

View File

@@ -0,0 +1,9 @@
import { useEventListener } from '@vueuse/core'
export function useCloseOnEsc(callback) {
return {
closeOnEsc: useEventListener(document, 'keydown', event => {
if (event.key === 'Escape') callback()
}),
}
}

View File

@@ -0,0 +1,22 @@
import { ref } from 'vue'
export function useDragAndDrop(emit) {
const startedDrag = ref(false)
const files = ref([])
const handleOnDragEnter = () => (startedDrag.value = true)
const handleOnDragLeave = () => (startedDrag.value = false)
const handleOnDrop = e => {
files.value = e.dataTransfer.files
emit('fileChanged', e.dataTransfer.files)
}
return {
startedDrag,
handleOnDragEnter,
handleOnDragLeave,
handleOnDrop,
}
}

View File

@@ -0,0 +1,28 @@
import { computed } from 'vue'
export function useFilePreviews(file) {
const imageTypes = [
'image/png',
'image/jpeg',
'image/gif',
'image/svg+xml',
'image/webp',
]
const type = computed(() =>
imageTypes.includes(file.value.type) ? 'image' : 'other'
)
const previewUrl = computed(() =>
URL.createObjectURL(file.value.originalFile)
)
const isImage = computed(() => type.value === 'image')
return {
imageTypes,
isImage,
type,
previewUrl,
}
}

View File

@@ -0,0 +1,6 @@
let id = 0
export function useId() {
++id
return id
}

View File

@@ -0,0 +1,7 @@
import __ from '../util/localization'
export function useLocalization() {
return {
__: (key, replace) => __(key, replace),
}
}

View File

@@ -0,0 +1,392 @@
import { ref, computed, watch, nextTick } from 'vue'
import CodeMirror from 'codemirror'
import each from 'lodash/each'
import isNil from 'lodash/isNil'
import debounce from 'lodash/debounce'
import { useLocalization } from '@/composables/useLocalization'
const { __ } = useLocalization()
const defineMarkdownCommands = (
editor,
{ props, emit, isFocused, filesUploadingCount, filesUploadedCount, files }
) => {
const doc = editor.getDoc()
return {
setValue(value) {
doc.setValue(value)
this.refresh()
},
focus() {
isFocused.value = true
},
refresh() {
nextTick(() => editor.refresh())
},
insert(insertion) {
let cursor = doc.getCursor()
doc.replaceRange(insertion, {
line: cursor.line,
ch: cursor.ch,
})
},
insertAround(start, end) {
if (doc.somethingSelected()) {
const selection = doc.getSelection()
doc.replaceSelection(start + selection + end)
} else {
let cursor = doc.getCursor()
doc.replaceRange(start + end, {
line: cursor.line,
ch: cursor.ch,
})
doc.setCursor({
line: cursor.line,
ch: cursor.ch + start.length,
})
}
},
insertBefore(insertion, cursorOffset) {
if (doc.somethingSelected()) {
const selects = doc.listSelections()
selects.forEach(selection => {
const pos = [selection.head.line, selection.anchor.line].sort()
for (let i = pos[0]; i <= pos[1]; i++) {
doc.replaceRange(insertion, { line: i, ch: 0 })
}
doc.setCursor({ line: pos[0], ch: cursorOffset || 0 })
})
} else {
let cursor = doc.getCursor()
doc.replaceRange(insertion, {
line: cursor.line,
ch: 0,
})
doc.setCursor({
line: cursor.line,
ch: cursorOffset || 0,
})
}
},
uploadAttachment(file) {
if (!isNil(props.uploader)) {
filesUploadingCount.value = filesUploadingCount.value + 1
const placeholder = `![Uploading ${file.name}…]()`
this.insert(placeholder)
props.uploader(file, {
onCompleted: (path, url) => {
let value = doc.getValue()
value = value.replace(placeholder, `![${path}](${url})`)
doc.setValue(value)
emit('change', value)
filesUploadedCount.value = filesUploadedCount.value + 1
},
onFailure: error => {
filesUploadingCount.value = filesUploadingCount.value - 1
},
})
}
},
}
}
const defineMarkdownActions = (commands, { isEditable, isFullScreen }) => {
return {
bold() {
if (!isEditable) return
commands.insertAround('**', '**')
},
italicize() {
if (!isEditable) return
commands.insertAround('*', '*')
},
image() {
if (!isEditable) return
commands.insertBefore('![](url)', 2)
},
link() {
if (!isEditable) return
commands.insertAround('[', '](url)')
},
toggleFullScreen() {
isFullScreen.value = !isFullScreen.value
commands.refresh()
},
fullScreen() {
isFullScreen.value = true
commands.refresh()
},
exitFullScreen() {
isFullScreen.value = false
commands.refresh()
},
}
}
const defineMarkdownKeyMaps = (editor, actions) => {
const keyMaps = {
'Cmd-B': 'bold',
'Cmd-I': 'italicize',
'Cmd-Alt-I': 'image',
'Cmd-K': 'link',
F11: 'fullScreen',
Esc: 'exitFullScreen',
}
each(keyMaps, (action, map) => {
const realMap = map.replace(
'Cmd-',
CodeMirror.keyMap['default'] == CodeMirror.keyMap.macDefault
? 'Cmd-'
: 'Ctrl-'
)
editor.options.extraKeys[realMap] = actions[keyMaps[map]].bind(this)
})
}
const defineMarkdownEvents = (
editor,
commands,
{ props, emit, isFocused, files, filesUploadingCount, filesUploadedCount }
) => {
const doc = editor.getDoc()
const handlePasteFromClipboard = e => {
if (e.clipboardData && e.clipboardData.items) {
const items = e.clipboardData.items
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
commands.uploadAttachment(items[i].getAsFile())
e.preventDefault()
}
}
}
}
const markdownFileRegex = /!\[[^\]]*\]\(([^\)]+)\)/gm
const getFileUrls = function (content) {
return [...content.matchAll(markdownFileRegex)]
.map(match => match[1])
.filter(url => {
try {
new URL(url)
return true
} catch {
return false
}
})
}
editor.on('focus', () => (isFocused.value = true))
editor.on('blur', () => (isFocused.value = false))
doc.on('change', (cm, changeObj) => {
if (changeObj.origin === 'setValue') {
return
}
emit('change', cm.getValue())
})
doc.on(
'change',
debounce((cm, changeObj) => {
const newFiles = getFileUrls(cm.getValue())
files.value
.filter(file => !newFiles.includes(file))
.filter((url, index, array) => array.indexOf(url) === index)
.forEach(file => emit('file-removed', file))
newFiles
.filter(url => !files.value.includes(url))
.filter((url, index, array) => array.indexOf(url) === index)
.forEach(file => emit('file-added', file))
files.value = newFiles
}, 1000)
)
editor.on('paste', (cm, event) => {
handlePasteFromClipboard(event)
})
watch(isFocused, (currentValue, oldValue) => {
if (currentValue === true && oldValue === false) {
editor.focus()
}
})
}
const bootstrap = (
theTextarea,
{
emit,
props,
isEditable,
isFocused,
isFullScreen,
filesUploadingCount,
filesUploadedCount,
files,
unmountMarkdownEditor,
}
) => {
const editor = CodeMirror.fromTextArea(theTextarea.value, {
tabSize: 4,
indentWithTabs: true,
lineWrapping: true,
mode: 'markdown',
viewportMargin: Infinity,
extraKeys: {
Enter: 'newlineAndIndentContinueMarkdownList',
},
readOnly: props.readonly,
})
const doc = editor.getDoc()
const commands = defineMarkdownCommands(editor, {
props,
emit,
isFocused,
filesUploadingCount,
filesUploadedCount,
files,
})
const actions = defineMarkdownActions(commands, { isEditable, isFullScreen })
defineMarkdownKeyMaps(editor, actions)
defineMarkdownEvents(editor, commands, {
props,
emit,
isFocused,
files,
filesUploadingCount,
filesUploadedCount,
})
commands.refresh()
return {
editor,
unmount: () => {
editor.toTextArea()
unmountMarkdownEditor()
},
actions: {
...commands,
...actions,
handle(context, action) {
if (!props.readonly) {
isFocused.value = true
actions[action].call(context)
}
},
},
}
}
export function useMarkdownEditing(emit, props) {
const isFullScreen = ref(false)
const isFocused = ref(false)
const previewContent = ref('')
const visualMode = ref('write')
const statusContent = ref(
__('Attach files by dragging & dropping, selecting or pasting them.')
)
const files = ref([])
const filesUploadingCount = ref(0)
const filesUploadedCount = ref(0)
const isEditable = computed(
() => props.readonly && visualMode.value == 'write'
)
const unmountMarkdownEditor = () => {
isFullScreen.value = false
isFocused.value = false
visualMode.value = 'write'
previewContent.value = ''
filesUploadingCount.value = 0
filesUploadedCount.value = 0
files.value = []
}
if (!isNil(props.uploader)) {
watch(
[filesUploadedCount, filesUploadingCount],
([currentFilesUploaded, currentFilesCount]) => {
if (currentFilesCount > currentFilesUploaded) {
statusContent.value = __('Uploading files... (:current/:total)', {
current: currentFilesUploaded,
total: currentFilesCount,
})
} else {
statusContent.value = __(
'Attach files by dragging & dropping, selecting or pasting them.'
)
}
}
)
}
return {
createMarkdownEditor: (context, theTextarea) => {
return bootstrap.call(context, theTextarea, {
emit,
props,
isEditable,
isFocused,
isFullScreen,
filesUploadingCount,
filesUploadedCount,
files,
unmountMarkdownEditor,
})
},
isFullScreen,
isFocused,
isEditable,
visualMode,
previewContent,
statusContent,
files,
}
}