mirror of https://github.com/Budibase/budibase.git
60 changed files with 1069 additions and 630 deletions
@ -1,25 +1,71 @@ |
|||||
import * as Sentry from "@sentry/browser" |
import * as Sentry from "@sentry/browser" |
||||
import posthog from "posthog-js" |
import posthog from "posthog-js" |
||||
|
import api from "builderStore/api" |
||||
|
|
||||
function activate() { |
let analyticsEnabled |
||||
Sentry.init({ dsn: process.env.SENTRY_DSN }) |
const posthogConfigured = process.env.POSTHOG_TOKEN && process.env.POSTHOG_URL |
||||
if (!process.env.POSTHOG_TOKEN) return |
const sentryConfigured = process.env.SENTRY_DSN |
||||
posthog.init(process.env.POSTHOG_TOKEN, { |
|
||||
api_host: process.env.POSTHOG_URL, |
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) { |
function captureException(err) { |
||||
|
if (!analyticsEnabled) return |
||||
Sentry.captureException(err) |
Sentry.captureException(err) |
||||
|
captureEvent("Error", { error: err.message ? err.message : err }) |
||||
} |
} |
||||
|
|
||||
function captureEvent(event) { |
function captureEvent(eventName, props = {}) { |
||||
if (!process.env.POSTHOG_TOKEN) return |
if (!analyticsEnabled || !process.env.POSTHOG_TOKEN) return |
||||
posthog.capture(event) |
props.sourceApp = "builder" |
||||
|
posthog.capture(eventName, props) |
||||
} |
} |
||||
|
|
||||
export default { |
export default { |
||||
activate, |
activate, |
||||
|
identify, |
||||
|
identifyByApiKey, |
||||
captureException, |
captureException, |
||||
captureEvent, |
captureEvent, |
||||
} |
} |
||||
|
|||||
@ -1,299 +1,32 @@ |
|||||
<script> |
<script> |
||||
import { notifier } from "builderStore/store/notifications" |
import { notifier } from "builderStore/store/notifications" |
||||
import { Heading, Body, Button } from "@budibase/bbui" |
import { Heading, Body, Button, Dropzone } from "@budibase/bbui" |
||||
import { FILE_TYPES } from "constants/backend" |
|
||||
import api from "builderStore/api" |
import api from "builderStore/api" |
||||
|
|
||||
const BYTES_IN_KB = 1000 |
|
||||
const BYTES_IN_MB = 1000000 |
|
||||
|
|
||||
export let files = [] |
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" |
function handleFileTooLarge() { |
||||
if (FILE_TYPES.CODE.includes(ext)) return "ri-terminal-box-line" |
notifier.danger( |
||||
|
`Files cannot exceed ${fileSizeLimit / |
||||
return "ri-file-line" |
BYTES_IN_MB}MB. Please try again with smaller files.` |
||||
|
) |
||||
} |
} |
||||
|
|
||||
async function processFiles(fileList) { |
async function processFiles(fileList) { |
||||
const fileArray = Array.from(fileList) |
const fileArray = Array.from(fileList) |
||||
|
|
||||
if (fileArray.some(file => file.size >= fileSizeLimit)) { |
const filesToProcess = fileArray.map(({ name, path, size, type }) => ({ |
||||
notifier.danger( |
|
||||
`Files cannot exceed ${fileSizeLimit / |
|
||||
BYTES_IN_MB}MB. Please try again with smaller files.` |
|
||||
) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
const filesToProcess = fileArray.map(({ name, path, size }) => ({ |
|
||||
name, |
name, |
||||
path, |
path, |
||||
size, |
size, |
||||
|
type, |
||||
})) |
})) |
||||
|
|
||||
const response = await api.post(`/api/attachments/process`, { |
const response = await api.post(`/api/attachments/process`, { |
||||
files: filesToProcess, |
files: filesToProcess, |
||||
}) |
}) |
||||
const processedFiles = await response.json() |
return 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 |
|
||||
} |
} |
||||
</script> |
</script> |
||||
|
|
||||
<div |
<Dropzone bind:files {processFiles} {handleFileTooLarge} /> |
||||
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> |
|
||||
|
|||||
@ -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