mirror of https://github.com/Budibase/budibase.git
59 changed files with 1066 additions and 627 deletions
@ -1,25 +1,71 @@ |
|||
import * as Sentry from "@sentry/browser" |
|||
import posthog from "posthog-js" |
|||
import api from "builderStore/api" |
|||
|
|||
function activate() { |
|||
Sentry.init({ dsn: process.env.SENTRY_DSN }) |
|||
if (!process.env.POSTHOG_TOKEN) return |
|||
posthog.init(process.env.POSTHOG_TOKEN, { |
|||
api_host: process.env.POSTHOG_URL, |
|||
}) |
|||
let analyticsEnabled |
|||
const posthogConfigured = process.env.POSTHOG_TOKEN && process.env.POSTHOG_URL |
|||
const sentryConfigured = process.env.SENTRY_DSN |
|||
|
|||
async function activate() { |
|||
if (analyticsEnabled === undefined) { |
|||
// only the server knows the true NODE_ENV
|
|||
// this was an issue as NODE_ENV = 'cypress' on the server,
|
|||
// but 'production' on the client
|
|||
const response = await api.get("/api/analytics") |
|||
analyticsEnabled = (await response.json()) === true |
|||
} |
|||
if (!analyticsEnabled) return |
|||
if (sentryConfigured) Sentry.init({ dsn: process.env.SENTRY_DSN }) |
|||
if (posthogConfigured) { |
|||
posthog.init(process.env.POSTHOG_TOKEN, { |
|||
api_host: process.env.POSTHOG_URL, |
|||
}) |
|||
posthog.set_config({ persistence: "cookie" }) |
|||
} |
|||
} |
|||
|
|||
function identify(id) { |
|||
if (!analyticsEnabled || !id) return |
|||
if (posthogConfigured) posthog.identify(id) |
|||
if (sentryConfigured) |
|||
Sentry.configureScope(scope => { |
|||
scope.setUser({ id: id }) |
|||
}) |
|||
} |
|||
|
|||
async function identifyByApiKey(apiKey) { |
|||
if (!analyticsEnabled) return true |
|||
const response = await fetch( |
|||
`https://03gaine137.execute-api.eu-west-1.amazonaws.com/prod/account/id?api_key=${apiKey.trim()}` |
|||
) |
|||
|
|||
if (response.status === 200) { |
|||
const id = await response.json() |
|||
|
|||
await api.put("/api/keys/userId", { value: id }) |
|||
identify(id) |
|||
return true |
|||
} |
|||
|
|||
return false |
|||
} |
|||
|
|||
function captureException(err) { |
|||
if (!analyticsEnabled) return |
|||
Sentry.captureException(err) |
|||
captureEvent("Error", { error: err.message ? err.message : err }) |
|||
} |
|||
|
|||
function captureEvent(event) { |
|||
if (!process.env.POSTHOG_TOKEN) return |
|||
posthog.capture(event) |
|||
function captureEvent(eventName, props = {}) { |
|||
if (!analyticsEnabled || !process.env.POSTHOG_TOKEN) return |
|||
props.sourceApp = "builder" |
|||
posthog.capture(eventName, props) |
|||
} |
|||
|
|||
export default { |
|||
activate, |
|||
identify, |
|||
identifyByApiKey, |
|||
captureException, |
|||
captureEvent, |
|||
} |
|||
|
|||
@ -1,299 +1,32 @@ |
|||
<script> |
|||
import { notifier } from "builderStore/store/notifications" |
|||
import { Heading, Body, Button } from "@budibase/bbui" |
|||
import { FILE_TYPES } from "constants/backend" |
|||
import { Heading, Body, Button, Dropzone } from "@budibase/bbui" |
|||
import api from "builderStore/api" |
|||
|
|||
const BYTES_IN_KB = 1000 |
|||
const BYTES_IN_MB = 1000000 |
|||
|
|||
export let files = [] |
|||
export let fileSizeLimit = BYTES_IN_MB * 20 |
|||
|
|||
let selectedImageIdx = 0 |
|||
let fileDragged = false |
|||
|
|||
$: selectedImage = files[selectedImageIdx] |
|||
|
|||
function determineFileIcon(extension) { |
|||
const ext = extension.toLowerCase() |
|||
|
|||
if (FILE_TYPES.IMAGE.includes(ext)) return "ri-image-2-line" |
|||
if (FILE_TYPES.CODE.includes(ext)) return "ri-terminal-box-line" |
|||
|
|||
return "ri-file-line" |
|||
function handleFileTooLarge() { |
|||
notifier.danger( |
|||
`Files cannot exceed ${fileSizeLimit / |
|||
BYTES_IN_MB}MB. Please try again with smaller files.` |
|||
) |
|||
} |
|||
|
|||
async function processFiles(fileList) { |
|||
const fileArray = Array.from(fileList) |
|||
|
|||
if (fileArray.some(file => file.size >= fileSizeLimit)) { |
|||
notifier.danger( |
|||
`Files cannot exceed ${fileSizeLimit / |
|||
BYTES_IN_MB}MB. Please try again with smaller files.` |
|||
) |
|||
return |
|||
} |
|||
|
|||
const filesToProcess = fileArray.map(({ name, path, size }) => ({ |
|||
const filesToProcess = fileArray.map(({ name, path, size, type }) => ({ |
|||
name, |
|||
path, |
|||
size, |
|||
type, |
|||
})) |
|||
|
|||
const response = await api.post(`/api/attachments/process`, { |
|||
files: filesToProcess, |
|||
}) |
|||
const processedFiles = await response.json() |
|||
files = [...processedFiles, ...files] |
|||
selectedImageIdx = 0 |
|||
} |
|||
|
|||
async function removeFile() { |
|||
files.splice(selectedImageIdx, 1) |
|||
files = files |
|||
selectedImageIdx = 0 |
|||
} |
|||
|
|||
function navigateLeft() { |
|||
selectedImageIdx -= 1 |
|||
} |
|||
|
|||
function navigateRight() { |
|||
selectedImageIdx += 1 |
|||
} |
|||
|
|||
function handleFile(evt) { |
|||
processFiles(evt.target.files) |
|||
} |
|||
|
|||
function handleDragOver(evt) { |
|||
evt.preventDefault() |
|||
fileDragged = true |
|||
} |
|||
|
|||
function handleDragLeave(evt) { |
|||
evt.preventDefault() |
|||
fileDragged = false |
|||
} |
|||
|
|||
function handleDrop(evt) { |
|||
evt.preventDefault() |
|||
processFiles(evt.dataTransfer.files) |
|||
fileDragged = false |
|||
return await response.json() |
|||
} |
|||
</script> |
|||
|
|||
<div |
|||
class="dropzone" |
|||
on:dragover={handleDragOver} |
|||
on:dragleave={handleDragLeave} |
|||
on:dragenter={handleDragOver} |
|||
on:drop={handleDrop} |
|||
class:fileDragged> |
|||
<ul> |
|||
{#if selectedImage} |
|||
<li> |
|||
<header> |
|||
<div> |
|||
<i |
|||
class={`file-icon ${determineFileIcon(selectedImage.extension)}`} /> |
|||
<span class="filename">{selectedImage.name}</span> |
|||
</div> |
|||
<p> |
|||
{#if selectedImage.size <= BYTES_IN_MB} |
|||
{selectedImage.size / BYTES_IN_KB}KB |
|||
{:else}{selectedImage.size / BYTES_IN_MB}MB{/if} |
|||
</p> |
|||
</header> |
|||
<div class="delete-button" on:click={removeFile}> |
|||
<i class="ri-close-line" /> |
|||
</div> |
|||
{#if selectedImageIdx !== 0} |
|||
<div class="nav left" on:click={navigateLeft}> |
|||
<i class="ri-arrow-left-line" /> |
|||
</div> |
|||
{/if} |
|||
<img src={selectedImage.url} /> |
|||
{#if selectedImageIdx !== files.length - 1} |
|||
<div class="nav right" on:click={navigateRight}> |
|||
<i class="ri-arrow-right-line" /> |
|||
</div> |
|||
{/if} |
|||
</li> |
|||
{/if} |
|||
</ul> |
|||
<i class="ri-folder-upload-line" /> |
|||
<input id="file-upload" type="file" multiple on:change={handleFile} /> |
|||
<label for="file-upload">Upload</label> |
|||
</div> |
|||
|
|||
<style> |
|||
.dropzone { |
|||
padding: var(--spacing-l); |
|||
border: 2px dashed var(--grey-7); |
|||
text-align: center; |
|||
display: flex; |
|||
align-items: center; |
|||
flex-direction: column; |
|||
border-radius: 10px; |
|||
transition: all 0.3s; |
|||
} |
|||
|
|||
.fileDragged { |
|||
border: 2px dashed var(--grey-7); |
|||
transform: scale(1.03); |
|||
background: var(--blue-light); |
|||
} |
|||
|
|||
input[type="file"] { |
|||
display: none; |
|||
} |
|||
|
|||
label { |
|||
font-family: var(--font-sans); |
|||
cursor: pointer; |
|||
font-weight: 600; |
|||
box-sizing: border-box; |
|||
overflow: hidden; |
|||
border-radius: var(--border-radius-s); |
|||
color: var(--white); |
|||
padding: var(--spacing-s) var(--spacing-l); |
|||
transition: all 0.2s ease 0s; |
|||
display: inline-flex; |
|||
text-rendering: optimizeLegibility; |
|||
min-width: auto; |
|||
outline: none; |
|||
font-feature-settings: "case" 1, "rlig" 1, "calt" 0; |
|||
-webkit-box-align: center; |
|||
user-select: none; |
|||
flex-shrink: 0; |
|||
align-items: center; |
|||
justify-content: center; |
|||
margin-top: 10px; |
|||
width: 100%; |
|||
border: solid 1.5px var(--ink); |
|||
background-color: var(--ink); |
|||
} |
|||
|
|||
div.nav { |
|||
position: absolute; |
|||
background: black; |
|||
color: var(--white); |
|||
display: flex; |
|||
align-items: center; |
|||
bottom: var(--spacing-s); |
|||
border-radius: 10px; |
|||
transition: 0.2s transform; |
|||
} |
|||
|
|||
.nav:hover { |
|||
cursor: pointer; |
|||
transform: scale(1.1); |
|||
} |
|||
|
|||
.left { |
|||
left: var(--spacing-s); |
|||
} |
|||
|
|||
.right { |
|||
right: var(--spacing-s); |
|||
} |
|||
|
|||
li { |
|||
position: relative; |
|||
height: 300px; |
|||
background: var(--grey-7); |
|||
display: flex; |
|||
justify-content: center; |
|||
border-radius: 10px; |
|||
} |
|||
|
|||
img { |
|||
border-radius: 10px; |
|||
width: 100%; |
|||
box-shadow: 0 var(--spacing-s) 12px rgba(0, 0, 0, 0.15); |
|||
object-fit: contain; |
|||
} |
|||
|
|||
i { |
|||
font-size: 3em; |
|||
} |
|||
|
|||
.file-icon { |
|||
color: var(--white); |
|||
font-size: 2em; |
|||
margin-right: var(--spacing-s); |
|||
} |
|||
|
|||
ul { |
|||
padding: 0; |
|||
display: grid; |
|||
grid-gap: var(--spacing-s); |
|||
list-style-type: none; |
|||
width: 100%; |
|||
} |
|||
|
|||
header { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
position: absolute; |
|||
background: linear-gradient( |
|||
180deg, |
|||
rgba(12, 12, 12, 1), |
|||
rgba(60, 60, 60, 0) |
|||
); |
|||
width: 100%; |
|||
border-top-left-radius: 10px; |
|||
border-top-right-radius: 10px; |
|||
height: 60px; |
|||
} |
|||
|
|||
header > div { |
|||
color: var(--white); |
|||
display: flex; |
|||
align-items: center; |
|||
font-size: 15px; |
|||
margin-left: var(--spacing-m); |
|||
width: 60%; |
|||
overflow: hidden; |
|||
white-space: nowrap; |
|||
text-overflow: ellipsis; |
|||
} |
|||
|
|||
.filename { |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
} |
|||
|
|||
header > p { |
|||
color: var(--grey-5); |
|||
margin-right: var(--spacing-m); |
|||
} |
|||
|
|||
.delete-button { |
|||
position: absolute; |
|||
top: var(--spacing-s); |
|||
right: var(--spacing-s); |
|||
padding: var(--spacing-s); |
|||
border-radius: 10px; |
|||
opacity: 0; |
|||
transition: all 0.3s; |
|||
color: var(--white); |
|||
} |
|||
|
|||
.delete-button i { |
|||
font-size: 2em; |
|||
} |
|||
|
|||
.delete-button:hover { |
|||
opacity: 1; |
|||
cursor: pointer; |
|||
background: linear-gradient( |
|||
to top right, |
|||
rgba(60, 60, 60, 0), |
|||
rgba(255, 0, 0, 0.2) |
|||
); |
|||
} |
|||
</style> |
|||
<Dropzone bind:files {processFiles} {handleFileTooLarge} /> |
|||
|
|||
@ -0,0 +1,196 @@ |
|||
<script> |
|||
import { onMount } from "svelte" |
|||
import fsort from "fast-sort" |
|||
import getOr from "lodash/fp/getOr" |
|||
import { store, backendUiStore } from "builderStore" |
|||
import { Button, Icon } from "@budibase/bbui" |
|||
import ActionButton from "components/common/ActionButton.svelte" |
|||
import LinkedRecord from "./LinkedRecord.svelte" |
|||
import AttachmentList from "./AttachmentList.svelte" |
|||
import TablePagination from "./TablePagination.svelte" |
|||
import { DeleteRecordModal, CreateEditRecordModal } from "./modals" |
|||
import RowPopover from "./popovers/Row.svelte" |
|||
import ColumnPopover from "./popovers/Column.svelte" |
|||
import ViewPopover from "./popovers/View.svelte" |
|||
import ExportPopover from "./popovers/Export.svelte" |
|||
import ColumnHeaderPopover from "./popovers/ColumnHeader.svelte" |
|||
import EditRowPopover from "./popovers/EditRow.svelte" |
|||
import * as api from "./api" |
|||
|
|||
const ITEMS_PER_PAGE = 10 |
|||
// Internal headers we want to hide from the user |
|||
const INTERNAL_HEADERS = ["_id", "_rev", "modelId", "type"] |
|||
|
|||
let modalOpen = false |
|||
let data = [] |
|||
let headers = [] |
|||
let currentPage = 0 |
|||
let search |
|||
|
|||
$: { |
|||
if ( |
|||
$backendUiStore.selectedView && |
|||
$backendUiStore.selectedView.name.startsWith("all_") |
|||
) { |
|||
api.fetchDataForView($backendUiStore.selectedView).then(records => { |
|||
data = records || [] |
|||
}) |
|||
} |
|||
} |
|||
|
|||
$: sort = $backendUiStore.sort |
|||
$: sorted = sort ? fsort(data)[sort.direction](sort.column) : data |
|||
$: paginatedData = sorted |
|||
? sorted.slice( |
|||
currentPage * ITEMS_PER_PAGE, |
|||
currentPage * ITEMS_PER_PAGE + ITEMS_PER_PAGE |
|||
) |
|||
: [] |
|||
|
|||
$: headers = Object.keys($backendUiStore.selectedModel.schema) |
|||
.sort() |
|||
.filter(id => !INTERNAL_HEADERS.includes(id)) |
|||
|
|||
$: schema = $backendUiStore.selectedModel.schema |
|||
$: modelView = { |
|||
schema: $backendUiStore.selectedModel.schema, |
|||
name: $backendUiStore.selectedView.name, |
|||
} |
|||
</script> |
|||
|
|||
<section> |
|||
<div class="table-controls"> |
|||
<h2 class="title">{$backendUiStore.selectedModel.name}</h2> |
|||
<div class="popovers"> |
|||
<ColumnPopover /> |
|||
{#if Object.keys($backendUiStore.selectedModel.schema).length > 0} |
|||
<RowPopover /> |
|||
<ViewPopover /> |
|||
<ExportPopover view={modelView} /> |
|||
{/if} |
|||
</div> |
|||
</div> |
|||
<table class="bb-table"> |
|||
<thead> |
|||
<tr> |
|||
<th class="edit-header"> |
|||
<div>Edit</div> |
|||
</th> |
|||
{#each headers as header} |
|||
<th> |
|||
<ColumnHeaderPopover |
|||
field={$backendUiStore.selectedModel.schema[header]} /> |
|||
</th> |
|||
{/each} |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
{#if paginatedData.length === 0} |
|||
<div class="no-data">No Data.</div> |
|||
{/if} |
|||
{#each paginatedData as row} |
|||
<tr> |
|||
<td> |
|||
<EditRowPopover {row} /> |
|||
</td> |
|||
{#each headers as header} |
|||
<td> |
|||
{#if schema[header].type === 'link'} |
|||
<LinkedRecord field={schema[header]} ids={row[header]} /> |
|||
{:else if schema[header].type === 'attachment'} |
|||
<AttachmentList files={row[header] || []} /> |
|||
{:else}{getOr('', header, row)}{/if} |
|||
</td> |
|||
{/each} |
|||
</tr> |
|||
{/each} |
|||
</tbody> |
|||
</table> |
|||
<TablePagination |
|||
{data} |
|||
bind:currentPage |
|||
pageItemCount={paginatedData.length} |
|||
{ITEMS_PER_PAGE} /> |
|||
</section> |
|||
|
|||
<style> |
|||
section { |
|||
margin-bottom: 20px; |
|||
} |
|||
|
|||
.title { |
|||
font-size: 24px; |
|||
font-weight: 600; |
|||
text-rendering: optimizeLegibility; |
|||
text-transform: capitalize; |
|||
} |
|||
|
|||
table { |
|||
border: 1px solid var(--grey-4); |
|||
background: #fff; |
|||
border-radius: 3px; |
|||
border-collapse: collapse; |
|||
} |
|||
|
|||
thead { |
|||
height: 40px; |
|||
background: var(--grey-3); |
|||
border: 1px solid var(--grey-4); |
|||
} |
|||
|
|||
thead th { |
|||
color: var(--ink); |
|||
text-transform: capitalize; |
|||
font-weight: 500; |
|||
font-size: 14px; |
|||
text-rendering: optimizeLegibility; |
|||
transition: 0.5s all; |
|||
vertical-align: middle; |
|||
} |
|||
|
|||
.edit-header { |
|||
width: 100px; |
|||
cursor: default; |
|||
} |
|||
|
|||
.edit-header:hover { |
|||
color: var(--ink); |
|||
} |
|||
|
|||
th:hover { |
|||
color: var(--blue); |
|||
cursor: pointer; |
|||
} |
|||
|
|||
td { |
|||
max-width: 200px; |
|||
text-overflow: ellipsis; |
|||
border: 1px solid var(--grey-4); |
|||
overflow: hidden; |
|||
white-space: pre; |
|||
box-sizing: border-box; |
|||
} |
|||
|
|||
tbody tr { |
|||
border-bottom: 1px solid var(--grey-4); |
|||
transition: 0.3s background-color; |
|||
color: var(--ink); |
|||
font-size: 12px; |
|||
} |
|||
|
|||
tbody tr:hover { |
|||
background: var(--grey-1); |
|||
} |
|||
|
|||
.table-controls { |
|||
width: 100%; |
|||
} |
|||
|
|||
.popovers { |
|||
display: flex; |
|||
} |
|||
|
|||
.no-data { |
|||
padding: 14px; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,56 @@ |
|||
<script> |
|||
import { onMount } from "svelte" |
|||
import fsort from "fast-sort" |
|||
import getOr from "lodash/fp/getOr" |
|||
import { store, backendUiStore } from "builderStore" |
|||
import api from "builderStore/api" |
|||
import { Button, Icon } from "@budibase/bbui" |
|||
import Table from "./Table.svelte" |
|||
import ActionButton from "components/common/ActionButton.svelte" |
|||
import LinkedRecord from "./LinkedRecord.svelte" |
|||
import TablePagination from "./TablePagination.svelte" |
|||
import { DeleteRecordModal, CreateEditRecordModal } from "./modals" |
|||
import RowPopover from "./popovers/Row.svelte" |
|||
import ColumnPopover from "./popovers/Column.svelte" |
|||
import ViewPopover from "./popovers/View.svelte" |
|||
import ColumnHeaderPopover from "./popovers/ColumnHeader.svelte" |
|||
import EditRowPopover from "./popovers/EditRow.svelte" |
|||
import CalculationPopover from "./popovers/Calculate.svelte" |
|||
import GroupByPopover from "./popovers/GroupBy.svelte" |
|||
import FilterPopover from "./popovers/Filter.svelte" |
|||
import ExportPopover from "./popovers/Export.svelte" |
|||
|
|||
export let view = {} |
|||
|
|||
let data = [] |
|||
|
|||
$: name = view.name |
|||
$: filters = view.filters |
|||
$: field = view.field |
|||
$: groupBy = view.groupBy |
|||
$: !name.startsWith("all_") && filters && fetchViewData(name, field, groupBy) |
|||
|
|||
async function fetchViewData(name, field, groupBy) { |
|||
const params = new URLSearchParams() |
|||
|
|||
if (field) { |
|||
params.set("field", field) |
|||
params.set("stats", true) |
|||
} |
|||
if (groupBy) params.set("group", groupBy) |
|||
|
|||
let QUERY_VIEW_URL = `/api/views/${name}?${params}` |
|||
|
|||
const response = await api.get(QUERY_VIEW_URL) |
|||
data = await response.json() |
|||
} |
|||
</script> |
|||
|
|||
<Table title={decodeURI(name)} schema={view.schema} {data}> |
|||
<FilterPopover {view} /> |
|||
<CalculationPopover {view} /> |
|||
{#if view.calculation} |
|||
<GroupByPopover {view} /> |
|||
{/if} |
|||
<ExportPopover {view} /> |
|||
</Table> |
|||
@ -0,0 +1,71 @@ |
|||
<script> |
|||
import { |
|||
TextButton, |
|||
Button, |
|||
Icon, |
|||
Input, |
|||
Select, |
|||
Popover, |
|||
} from "@budibase/bbui" |
|||
import { backendUiStore } from "builderStore" |
|||
import { notifier } from "builderStore/store/notifications" |
|||
import api from "builderStore/api" |
|||
|
|||
const FORMATS = [ |
|||
{ |
|||
name: "CSV", |
|||
key: "csv", |
|||
}, |
|||
{ |
|||
name: "JSON", |
|||
key: "json", |
|||
}, |
|||
] |
|||
|
|||
export let view |
|||
|
|||
let anchor |
|||
let dropdown |
|||
let exportFormat |
|||
|
|||
async function exportView() { |
|||
const response = await api.post( |
|||
`/api/views/export?format=${exportFormat}`, |
|||
view |
|||
) |
|||
const downloadInfo = await response.json() |
|||
window.location = downloadInfo.url |
|||
} |
|||
</script> |
|||
|
|||
<div bind:this={anchor}> |
|||
<TextButton text small on:click={dropdown.show}> |
|||
<Icon name="download" /> |
|||
Export |
|||
</TextButton> |
|||
</div> |
|||
<Popover bind:this={dropdown} {anchor} align="left"> |
|||
<h5>Export Format</h5> |
|||
<Select secondary thin bind:value={exportFormat}> |
|||
<option value={''}>Select an option</option> |
|||
{#each FORMATS as format} |
|||
<option value={format.key}>{format.name}</option> |
|||
{/each} |
|||
</Select> |
|||
<div class="button-group"> |
|||
<Button secondary on:click={dropdown.hide}>Cancel</Button> |
|||
<Button primary on:click={exportView}>Export</Button> |
|||
</div> |
|||
</Popover> |
|||
|
|||
<style> |
|||
h5 { |
|||
margin-top: 0; |
|||
} |
|||
|
|||
.button-group { |
|||
margin-top: var(--spacing-l); |
|||
display: flex; |
|||
justify-content: flex-end; |
|||
} |
|||
</style> |
|||
@ -1,139 +0,0 @@ |
|||
<script> |
|||
import { Button, Select } from "@budibase/bbui" |
|||
import StateBindingCascader from "./StateBindingCascader.svelte" |
|||
import { find, map, keys, reduce, keyBy } from "lodash/fp" |
|||
import { pipe } from "components/common/core" |
|||
import { |
|||
EVENT_TYPE_MEMBER_NAME, |
|||
allHandlers, |
|||
} from "components/common/eventHandlers" |
|||
import { store } from "builderStore" |
|||
|
|||
export let handler |
|||
export let onCreate |
|||
export let onChanged |
|||
export let onRemoved |
|||
|
|||
export let index |
|||
export let newHandler |
|||
|
|||
let eventOptions |
|||
let handlerType |
|||
let parameters = [] |
|||
|
|||
$: eventOptions = allHandlers() |
|||
|
|||
$: { |
|||
if (handler) { |
|||
handlerType = handler[EVENT_TYPE_MEMBER_NAME] |
|||
parameters = Object.entries(handler.parameters).map(([name, value]) => ({ |
|||
name, |
|||
value, |
|||
})) |
|||
} else { |
|||
// Empty Handler |
|||
handlerType = "" |
|||
parameters = [] |
|||
} |
|||
} |
|||
|
|||
const handlerChanged = (type, params) => { |
|||
const handlerParams = {} |
|||
for (let param of params) { |
|||
handlerParams[param.name] = param.value |
|||
} |
|||
|
|||
const updatedHandler = { |
|||
[EVENT_TYPE_MEMBER_NAME]: type, |
|||
parameters: handlerParams, |
|||
} |
|||
|
|||
onChanged(updatedHandler, index) |
|||
} |
|||
|
|||
const handlerTypeChanged = e => { |
|||
const handlerType = eventOptions.find( |
|||
handler => handler.name === e.target.value |
|||
) |
|||
const defaultParams = handlerType.parameters.map(param => ({ |
|||
name: param, |
|||
value: "", |
|||
})) |
|||
|
|||
handlerChanged(handlerType.name, defaultParams) |
|||
} |
|||
|
|||
const onParameterChanged = index => e => { |
|||
const value = e.target ? e.target.value : e |
|||
const newParams = [...parameters] |
|||
newParams[index].value = value |
|||
handlerChanged(handlerType, newParams) |
|||
} |
|||
</script> |
|||
|
|||
<div class="type-selector-container {newHandler && 'new-handler'}"> |
|||
<div class="handler-controls"> |
|||
<div class="handler-option"> |
|||
<span>Action</span> |
|||
<Select value={handlerType} on:change={handlerTypeChanged}> |
|||
<option /> |
|||
{#each eventOptions as option} |
|||
<option value={option.name}>{option.name}</option> |
|||
{/each} |
|||
</Select> |
|||
</div> |
|||
{#if parameters} |
|||
<br /> |
|||
{#each parameters as parameter, idx} |
|||
<StateBindingCascader on:change={onParameterChanged(idx)} {parameter} /> |
|||
{/each} |
|||
{/if} |
|||
{#if parameters.length > 0} |
|||
<div class="button-container"> |
|||
{#if newHandler} |
|||
<Button primary thin on:click={onCreate}>Add Action</Button> |
|||
{:else} |
|||
<Button outline thin on:click={onRemoved}>Remove Action</Button> |
|||
{/if} |
|||
</div> |
|||
{/if} |
|||
</div> |
|||
</div> |
|||
|
|||
<style> |
|||
.type-selector-container { |
|||
display: grid; |
|||
grid-gap: 20px; |
|||
width: 100%; |
|||
background: rgba(223, 223, 223, 0.5); |
|||
border: 1px solid #dfdfdf; |
|||
margin-bottom: 18px; |
|||
} |
|||
|
|||
.handler-option { |
|||
display: flex; |
|||
flex-direction: column; |
|||
} |
|||
|
|||
.new-handler { |
|||
background: #fff; |
|||
} |
|||
|
|||
.handler-controls { |
|||
display: grid; |
|||
grid-template-columns: 1fr; |
|||
grid-gap: 20px; |
|||
padding: 22px; |
|||
} |
|||
|
|||
.button-container { |
|||
display: grid; |
|||
justify-items: end; |
|||
} |
|||
|
|||
span { |
|||
font-size: 18px; |
|||
margin-bottom: 10px; |
|||
font-weight: 500; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,3 @@ |
|||
exports.isEnabled = async function(ctx) { |
|||
ctx.body = JSON.stringify(process.env.ENABLE_ANALYTICS === "true") |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
exports.csv = function(headers, rows) { |
|||
let csv = headers.map(key => `"${key}"`).join(",") |
|||
|
|||
for (let row of rows) { |
|||
csv = `${csv}\n${headers |
|||
.map(header => `"${row[header]}"`.trim()) |
|||
.join(",")}` |
|||
} |
|||
return csv |
|||
} |
|||
|
|||
exports.json = function(headers, rows) { |
|||
return JSON.stringify(rows, undefined, 2) |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
const Router = require("@koa/router") |
|||
const authorized = require("../../middleware/authorized") |
|||
const { BUILDER } = require("../../utilities/accessLevels") |
|||
const controller = require("../controllers/analytics") |
|||
|
|||
const router = Router() |
|||
|
|||
router.get("/api/analytics", authorized(BUILDER), controller.isEnabled) |
|||
|
|||
module.exports = router |
|||
@ -0,0 +1,152 @@ |
|||
const newid = require("./newid") |
|||
|
|||
const DocumentTypes = { |
|||
MODEL: "model", |
|||
RECORD: "record", |
|||
USER: "user", |
|||
AUTOMATION: "automation", |
|||
LINK: "link", |
|||
APP: "app", |
|||
ACCESS_LEVEL: "accesslevel", |
|||
} |
|||
|
|||
exports.DocumentTypes = DocumentTypes |
|||
|
|||
const UNICODE_MAX = "\ufff0" |
|||
|
|||
/** |
|||
* If creating DB allDocs/query params with only a single top level ID this can be used, this |
|||
* is usually the case as most of our docs are top level e.g. models, automations, users and so on. |
|||
* More complex cases such as link docs and records which have multiple levels of IDs that their |
|||
* ID consists of need their own functions to build the allDocs parameters. |
|||
* @param {string} docType The type of document which input params are being built for, e.g. user, |
|||
* link, app, model and so on. |
|||
* @param {string|null} docId The ID of the document minus its type - this is only needed if looking |
|||
* for a singular document. |
|||
* @param {object} otherProps Add any other properties onto the request, e.g. include_docs. |
|||
* @returns {object} Parameters which can then be used with an allDocs request. |
|||
*/ |
|||
function getDocParams(docType, docId = null, otherProps = {}) { |
|||
if (docId == null) { |
|||
docId = "" |
|||
} |
|||
return { |
|||
...otherProps, |
|||
startkey: `${docType}:${docId}`, |
|||
endkey: `${docType}:${docId}${UNICODE_MAX}`, |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Gets parameters for retrieving models, this is a utility function for the getDocParams function. |
|||
*/ |
|||
exports.getModelParams = (modelId = null, otherProps = {}) => { |
|||
return getDocParams(DocumentTypes.MODEL, modelId, otherProps) |
|||
} |
|||
|
|||
/** |
|||
* Generates a new model ID. |
|||
* @returns {string} The new model ID which the model doc can be stored under. |
|||
*/ |
|||
exports.generateModelID = () => { |
|||
return `${DocumentTypes.MODEL}:${newid()}` |
|||
} |
|||
|
|||
/** |
|||
* Gets the DB allDocs/query params for retrieving a record. |
|||
* @param {string} modelId The model in which the records have been stored. |
|||
* @param {string|null} recordId The ID of the record which is being specifically queried for. This can be |
|||
* left null to get all the records in the model. |
|||
* @param {object} otherProps Any other properties to add to the request. |
|||
* @returns {object} Parameters which can then be used with an allDocs request. |
|||
*/ |
|||
exports.getRecordParams = (modelId, recordId = null, otherProps = {}) => { |
|||
if (modelId == null) { |
|||
throw "Cannot build params for records without a model ID" |
|||
} |
|||
const endOfKey = recordId == null ? `${modelId}:` : `${modelId}:${recordId}` |
|||
return getDocParams(DocumentTypes.RECORD, endOfKey, otherProps) |
|||
} |
|||
|
|||
/** |
|||
* Gets a new record ID for the specified model. |
|||
* @param {string} modelId The model which the record is being created for. |
|||
* @returns {string} The new ID which a record doc can be stored under. |
|||
*/ |
|||
exports.generateRecordID = modelId => { |
|||
return `${DocumentTypes.RECORD}:${modelId}:${newid()}` |
|||
} |
|||
|
|||
/** |
|||
* Gets parameters for retrieving users, this is a utility function for the getDocParams function. |
|||
*/ |
|||
exports.getUserParams = (username = null, otherProps = {}) => { |
|||
return getDocParams(DocumentTypes.USER, username, otherProps) |
|||
} |
|||
|
|||
/** |
|||
* Generates a new user ID based on the passed in username. |
|||
* @param {string} username The username which the ID is going to be built up of. |
|||
* @returns {string} The new user ID which the user doc can be stored under. |
|||
*/ |
|||
exports.generateUserID = username => { |
|||
return `${DocumentTypes.USER}:${username}` |
|||
} |
|||
|
|||
/** |
|||
* Gets parameters for retrieving automations, this is a utility function for the getDocParams function. |
|||
*/ |
|||
exports.getAutomationParams = (automationId = null, otherProps = {}) => { |
|||
return getDocParams(DocumentTypes.AUTOMATION, automationId, otherProps) |
|||
} |
|||
|
|||
/** |
|||
* Generates a new automation ID. |
|||
* @returns {string} The new automation ID which the automation doc can be stored under. |
|||
*/ |
|||
exports.generateAutomationID = () => { |
|||
return `${DocumentTypes.AUTOMATION}:${newid()}` |
|||
} |
|||
|
|||
/** |
|||
* Generates a new link doc ID. This is currently not usable with the alldocs call, |
|||
* instead a view is built to make walking to tree easier. |
|||
* @param {string} modelId1 The ID of the linker model. |
|||
* @param {string} modelId2 The ID of the linked model. |
|||
* @param {string} recordId1 The ID of the linker record. |
|||
* @param {string} recordId2 The ID of the linked record. |
|||
* @returns {string} The new link doc ID which the automation doc can be stored under. |
|||
*/ |
|||
exports.generateLinkID = (modelId1, modelId2, recordId1, recordId2) => { |
|||
return `${DocumentTypes.AUTOMATION}:${modelId1}:${modelId2}:${recordId1}:${recordId2}` |
|||
} |
|||
|
|||
/** |
|||
* Generates a new app ID. |
|||
* @returns {string} The new app ID which the app doc can be stored under. |
|||
*/ |
|||
exports.generateAppID = () => { |
|||
return `${DocumentTypes.APP}:${newid()}` |
|||
} |
|||
|
|||
/** |
|||
* Gets parameters for retrieving apps, this is a utility function for the getDocParams function. |
|||
*/ |
|||
exports.getAppParams = (appId = null, otherProps = {}) => { |
|||
return getDocParams(DocumentTypes.APP, appId, otherProps) |
|||
} |
|||
|
|||
/** |
|||
* Generates a new access level ID. |
|||
* @returns {string} The new access level ID which the access level doc can be stored under. |
|||
*/ |
|||
exports.generateAccessLevelID = () => { |
|||
return `${DocumentTypes.ACCESS_LEVEL}:${newid()}` |
|||
} |
|||
|
|||
/** |
|||
* Gets parameters for retrieving an access level, this is a utility function for the getDocParams function. |
|||
*/ |
|||
exports.getAccessLevelParams = (accessLevelId = null, otherProps = {}) => { |
|||
return getDocParams(DocumentTypes.ACCESS_LEVEL, accessLevelId, otherProps) |
|||
} |
|||
@ -0,0 +1,64 @@ |
|||
<script> |
|||
import { FILE_TYPES } from "./fileTypes" |
|||
|
|||
export let files |
|||
export let height = "70" |
|||
export let width = "70" |
|||
</script> |
|||
|
|||
<div class="file-list"> |
|||
{#each files as file} |
|||
<a href={file.url} target="_blank"> |
|||
<div class="file"> |
|||
{#if FILE_TYPES.IMAGE.includes(file.extension.toLowerCase())} |
|||
<img {width} {height} src={file.url} /> |
|||
{:else} |
|||
<i class="far fa-file" /> |
|||
{/if} |
|||
</div> |
|||
<span>{file.name}</span> |
|||
</a> |
|||
{/each} |
|||
</div> |
|||
|
|||
<style> |
|||
.file-list { |
|||
display: grid; |
|||
grid-auto-flow: column; |
|||
grid-gap: var(--spacing-m); |
|||
grid-template-columns: repeat(10, 1fr); |
|||
} |
|||
|
|||
img { |
|||
object-fit: contain; |
|||
} |
|||
|
|||
i { |
|||
margin-bottom: var(--spacing-m); |
|||
} |
|||
|
|||
a { |
|||
color: var(--ink); |
|||
text-decoration: none; |
|||
} |
|||
|
|||
.file { |
|||
position: relative; |
|||
height: 75px; |
|||
width: 75px; |
|||
border: 2px dashed var(--grey-7); |
|||
padding: var(--spacing-xs); |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: center; |
|||
justify-content: center; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
} |
|||
|
|||
span { |
|||
width: 75px; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,35 @@ |
|||
<script> |
|||
import { Heading, Body, Button, Dropzone } from "@budibase/bbui" |
|||
import { FILE_TYPES } from "./fileTypes" |
|||
|
|||
const BYTES_IN_KB = 1000 |
|||
|
|||
export let files = [] |
|||
|
|||
function handleFileTooLarge(fileSizeLimit) { |
|||
alert( |
|||
`Files cannot exceed ${fileSizeLimit / |
|||
BYTES_IN_MB}MB. Please try again with smaller files.` |
|||
) |
|||
} |
|||
|
|||
async function processFiles(fileList) { |
|||
let data = new FormData() |
|||
for (var i = 0; i < fileList.length; i++) { |
|||
data.append("file", fileList[i]) |
|||
} |
|||
|
|||
const response = await fetch("/api/attachments/upload", { |
|||
method: "POST", |
|||
body: data, |
|||
headers: { |
|||
Accept: "application/json", |
|||
}, |
|||
}) |
|||
|
|||
const processedFiles = await response.json() |
|||
return processedFiles |
|||
} |
|||
</script> |
|||
|
|||
<Dropzone bind:files {processFiles} {handleFileTooLarge} /> |
|||
@ -0,0 +1,5 @@ |
|||
export const FILE_TYPES = { |
|||
IMAGE: ["png", "tiff", "gif", "raw", "jpg", "jpeg"], |
|||
CODE: ["js", "rs", "py", "java", "rb", "hs", "yml"], |
|||
DOCUMENT: ["odf", "docx", "doc", "pdf", "csv"], |
|||
} |
|||
Loading…
Reference in new issue