Merge base branch.
This commit is contained in:
commit
fbf60ece4f
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "2.11.43",
|
"version": "2.11.44",
|
||||||
"npmClient": "yarn",
|
"npmClient": "yarn",
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
|
|
|
@ -1,37 +1,50 @@
|
||||||
import env from "../../environment"
|
import env from "../../environment"
|
||||||
import * as objectStore from "../objectStore"
|
import * as objectStore from "../objectStore"
|
||||||
import * as cloudfront from "../cloudfront"
|
import * as cloudfront from "../cloudfront"
|
||||||
|
import qs from "querystring"
|
||||||
|
import { DEFAULT_TENANT_ID, getTenantId } from "../../context"
|
||||||
|
|
||||||
|
export function clientLibraryPath(appId: string) {
|
||||||
|
return `${objectStore.sanitizeKey(appId)}/budibase-client.js`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In production the client library is stored in the object store, however in development
|
* Previously we used to serve the client library directly from Cloudfront, however
|
||||||
* we use the symlinked version produced by lerna, located in node modules. We link to this
|
* due to issues with the domain we were unable to continue doing this - keeping
|
||||||
* via a specific endpoint (under /api/assets/client).
|
* incase we are able to switch back to CDN path again in future.
|
||||||
* @param appId In production we need the appId to look up the correct bucket, as the
|
|
||||||
* version of the client lib may differ between apps.
|
|
||||||
* @param version The version to retrieve.
|
|
||||||
* @return The URL to be inserted into appPackage response or server rendered
|
|
||||||
* app index file.
|
|
||||||
*/
|
*/
|
||||||
export const clientLibraryUrl = (appId: string, version: string) => {
|
export function clientLibraryCDNUrl(appId: string, version: string) {
|
||||||
if (env.isProd()) {
|
let file = clientLibraryPath(appId)
|
||||||
let file = `${objectStore.sanitizeKey(appId)}/budibase-client.js`
|
if (env.CLOUDFRONT_CDN) {
|
||||||
if (env.CLOUDFRONT_CDN) {
|
// append app version to bust the cache
|
||||||
// append app version to bust the cache
|
if (version) {
|
||||||
if (version) {
|
file += `?v=${version}`
|
||||||
file += `?v=${version}`
|
|
||||||
}
|
|
||||||
// don't need to use presigned for client with cloudfront
|
|
||||||
// file is public
|
|
||||||
return cloudfront.getUrl(file)
|
|
||||||
} else {
|
|
||||||
return objectStore.getPresignedUrl(env.APPS_BUCKET_NAME, file)
|
|
||||||
}
|
}
|
||||||
|
// don't need to use presigned for client with cloudfront
|
||||||
|
// file is public
|
||||||
|
return cloudfront.getUrl(file)
|
||||||
} else {
|
} else {
|
||||||
return `/api/assets/client`
|
return objectStore.getPresignedUrl(env.APPS_BUCKET_NAME, file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getAppFileUrl = (s3Key: string) => {
|
export function clientLibraryUrl(appId: string, version: string) {
|
||||||
|
let tenantId, qsParams: { appId: string; version: string; tenantId?: string }
|
||||||
|
try {
|
||||||
|
tenantId = getTenantId()
|
||||||
|
} finally {
|
||||||
|
qsParams = {
|
||||||
|
appId,
|
||||||
|
version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tenantId && tenantId !== DEFAULT_TENANT_ID) {
|
||||||
|
qsParams.tenantId = tenantId
|
||||||
|
}
|
||||||
|
return `/api/assets/client?${qs.encode(qsParams)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppFileUrl(s3Key: string) {
|
||||||
if (env.CLOUDFRONT_CDN) {
|
if (env.CLOUDFRONT_CDN) {
|
||||||
return cloudfront.getPresignedUrl(s3Key)
|
return cloudfront.getPresignedUrl(s3Key)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { Plugin } from "@budibase/types"
|
||||||
|
|
||||||
// URLS
|
// URLS
|
||||||
|
|
||||||
export const enrichPluginURLs = (plugins: Plugin[]) => {
|
export function enrichPluginURLs(plugins: Plugin[]) {
|
||||||
if (!plugins || !plugins.length) {
|
if (!plugins || !plugins.length) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
@ -17,12 +17,12 @@ export const enrichPluginURLs = (plugins: Plugin[]) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPluginJSUrl = (plugin: Plugin) => {
|
function getPluginJSUrl(plugin: Plugin) {
|
||||||
const s3Key = getPluginJSKey(plugin)
|
const s3Key = getPluginJSKey(plugin)
|
||||||
return getPluginUrl(s3Key)
|
return getPluginUrl(s3Key)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPluginIconUrl = (plugin: Plugin): string | undefined => {
|
function getPluginIconUrl(plugin: Plugin): string | undefined {
|
||||||
const s3Key = getPluginIconKey(plugin)
|
const s3Key = getPluginIconKey(plugin)
|
||||||
if (!s3Key) {
|
if (!s3Key) {
|
||||||
return
|
return
|
||||||
|
@ -30,7 +30,7 @@ const getPluginIconUrl = (plugin: Plugin): string | undefined => {
|
||||||
return getPluginUrl(s3Key)
|
return getPluginUrl(s3Key)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPluginUrl = (s3Key: string) => {
|
function getPluginUrl(s3Key: string) {
|
||||||
if (env.CLOUDFRONT_CDN) {
|
if (env.CLOUDFRONT_CDN) {
|
||||||
return cloudfront.getPresignedUrl(s3Key)
|
return cloudfront.getPresignedUrl(s3Key)
|
||||||
} else {
|
} else {
|
||||||
|
@ -40,11 +40,11 @@ const getPluginUrl = (s3Key: string) => {
|
||||||
|
|
||||||
// S3 KEYS
|
// S3 KEYS
|
||||||
|
|
||||||
export const getPluginJSKey = (plugin: Plugin) => {
|
export function getPluginJSKey(plugin: Plugin) {
|
||||||
return getPluginS3Key(plugin, "plugin.min.js")
|
return getPluginS3Key(plugin, "plugin.min.js")
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getPluginIconKey = (plugin: Plugin) => {
|
export function getPluginIconKey(plugin: Plugin) {
|
||||||
// stored iconUrl is deprecated - hardcode to icon.svg in this case
|
// stored iconUrl is deprecated - hardcode to icon.svg in this case
|
||||||
const iconFileName = plugin.iconUrl ? "icon.svg" : plugin.iconFileName
|
const iconFileName = plugin.iconUrl ? "icon.svg" : plugin.iconFileName
|
||||||
if (!iconFileName) {
|
if (!iconFileName) {
|
||||||
|
@ -53,12 +53,12 @@ export const getPluginIconKey = (plugin: Plugin) => {
|
||||||
return getPluginS3Key(plugin, iconFileName)
|
return getPluginS3Key(plugin, iconFileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPluginS3Key = (plugin: Plugin, fileName: string) => {
|
function getPluginS3Key(plugin: Plugin, fileName: string) {
|
||||||
const s3Key = getPluginS3Dir(plugin.name)
|
const s3Key = getPluginS3Dir(plugin.name)
|
||||||
return `${s3Key}/${fileName}`
|
return `${s3Key}/${fileName}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getPluginS3Dir = (pluginName: string) => {
|
export function getPluginS3Dir(pluginName: string) {
|
||||||
let s3Key = `${pluginName}`
|
let s3Key = `${pluginName}`
|
||||||
if (env.MULTI_TENANCY) {
|
if (env.MULTI_TENANCY) {
|
||||||
const tenantId = context.getTenantId()
|
const tenantId = context.getTenantId()
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import * as app from "../app"
|
import * as app from "../app"
|
||||||
import { getAppFileUrl } from "../app"
|
|
||||||
import { testEnv } from "../../../../tests/extra"
|
import { testEnv } from "../../../../tests/extra"
|
||||||
|
|
||||||
describe("app", () => {
|
describe("app", () => {
|
||||||
|
@ -7,6 +6,15 @@ describe("app", () => {
|
||||||
testEnv.nodeJest()
|
testEnv.nodeJest()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function baseCheck(url: string, tenantId?: string) {
|
||||||
|
expect(url).toContain("/api/assets/client")
|
||||||
|
if (tenantId) {
|
||||||
|
expect(url).toContain(`tenantId=${tenantId}`)
|
||||||
|
}
|
||||||
|
expect(url).toContain("appId=app_123")
|
||||||
|
expect(url).toContain("version=2.0.0")
|
||||||
|
}
|
||||||
|
|
||||||
describe("clientLibraryUrl", () => {
|
describe("clientLibraryUrl", () => {
|
||||||
function getClientUrl() {
|
function getClientUrl() {
|
||||||
return app.clientLibraryUrl("app_123/budibase-client.js", "2.0.0")
|
return app.clientLibraryUrl("app_123/budibase-client.js", "2.0.0")
|
||||||
|
@ -20,31 +28,19 @@ describe("app", () => {
|
||||||
it("gets url in dev", () => {
|
it("gets url in dev", () => {
|
||||||
testEnv.nodeDev()
|
testEnv.nodeDev()
|
||||||
const url = getClientUrl()
|
const url = getClientUrl()
|
||||||
expect(url).toBe("/api/assets/client")
|
baseCheck(url)
|
||||||
})
|
|
||||||
|
|
||||||
it("gets url with embedded minio", () => {
|
|
||||||
testEnv.withMinio()
|
|
||||||
const url = getClientUrl()
|
|
||||||
expect(url).toBe(
|
|
||||||
"/files/signed/prod-budi-app-assets/app_123/budibase-client.js/budibase-client.js"
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("gets url with custom S3", () => {
|
it("gets url with custom S3", () => {
|
||||||
testEnv.withS3()
|
testEnv.withS3()
|
||||||
const url = getClientUrl()
|
const url = getClientUrl()
|
||||||
expect(url).toBe(
|
baseCheck(url)
|
||||||
"http://s3.example.com/prod-budi-app-assets/app_123/budibase-client.js/budibase-client.js"
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("gets url with cloudfront + s3", () => {
|
it("gets url with cloudfront + s3", () => {
|
||||||
testEnv.withCloudfront()
|
testEnv.withCloudfront()
|
||||||
const url = getClientUrl()
|
const url = getClientUrl()
|
||||||
expect(url).toBe(
|
baseCheck(url)
|
||||||
"http://cf.example.com/app_123/budibase-client.js/budibase-client.js?v=2.0.0"
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -57,7 +53,7 @@ describe("app", () => {
|
||||||
testEnv.nodeDev()
|
testEnv.nodeDev()
|
||||||
await testEnv.withTenant(tenantId => {
|
await testEnv.withTenant(tenantId => {
|
||||||
const url = getClientUrl()
|
const url = getClientUrl()
|
||||||
expect(url).toBe("/api/assets/client")
|
baseCheck(url, tenantId)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -65,9 +61,7 @@ describe("app", () => {
|
||||||
await testEnv.withTenant(tenantId => {
|
await testEnv.withTenant(tenantId => {
|
||||||
testEnv.withMinio()
|
testEnv.withMinio()
|
||||||
const url = getClientUrl()
|
const url = getClientUrl()
|
||||||
expect(url).toBe(
|
baseCheck(url, tenantId)
|
||||||
"/files/signed/prod-budi-app-assets/app_123/budibase-client.js/budibase-client.js"
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -75,9 +69,7 @@ describe("app", () => {
|
||||||
await testEnv.withTenant(tenantId => {
|
await testEnv.withTenant(tenantId => {
|
||||||
testEnv.withS3()
|
testEnv.withS3()
|
||||||
const url = getClientUrl()
|
const url = getClientUrl()
|
||||||
expect(url).toBe(
|
baseCheck(url, tenantId)
|
||||||
"http://s3.example.com/prod-budi-app-assets/app_123/budibase-client.js/budibase-client.js"
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -85,9 +77,7 @@ describe("app", () => {
|
||||||
await testEnv.withTenant(tenantId => {
|
await testEnv.withTenant(tenantId => {
|
||||||
testEnv.withCloudfront()
|
testEnv.withCloudfront()
|
||||||
const url = getClientUrl()
|
const url = getClientUrl()
|
||||||
expect(url).toBe(
|
baseCheck(url, tenantId)
|
||||||
"http://cf.example.com/app_123/budibase-client.js/budibase-client.js?v=2.0.0"
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
const sanitize = require("sanitize-s3-objectkey")
|
const sanitize = require("sanitize-s3-objectkey")
|
||||||
import AWS from "aws-sdk"
|
import AWS from "aws-sdk"
|
||||||
import stream from "stream"
|
import stream, { Readable } from "stream"
|
||||||
import fetch from "node-fetch"
|
import fetch from "node-fetch"
|
||||||
import tar from "tar-fs"
|
import tar from "tar-fs"
|
||||||
import zlib from "zlib"
|
import zlib from "zlib"
|
||||||
|
@ -66,10 +66,10 @@ export function sanitizeBucket(input: string) {
|
||||||
* @return an S3 object store object, check S3 Nodejs SDK for usage.
|
* @return an S3 object store object, check S3 Nodejs SDK for usage.
|
||||||
* @constructor
|
* @constructor
|
||||||
*/
|
*/
|
||||||
export const ObjectStore = (
|
export function ObjectStore(
|
||||||
bucket: string,
|
bucket: string,
|
||||||
opts: { presigning: boolean } = { presigning: false }
|
opts: { presigning: boolean } = { presigning: false }
|
||||||
) => {
|
) {
|
||||||
const config: any = {
|
const config: any = {
|
||||||
s3ForcePathStyle: true,
|
s3ForcePathStyle: true,
|
||||||
signatureVersion: "v4",
|
signatureVersion: "v4",
|
||||||
|
@ -104,7 +104,7 @@ export const ObjectStore = (
|
||||||
* Given an object store and a bucket name this will make sure the bucket exists,
|
* Given an object store and a bucket name this will make sure the bucket exists,
|
||||||
* if it does not exist then it will create it.
|
* if it does not exist then it will create it.
|
||||||
*/
|
*/
|
||||||
export const makeSureBucketExists = async (client: any, bucketName: string) => {
|
export async function makeSureBucketExists(client: any, bucketName: string) {
|
||||||
bucketName = sanitizeBucket(bucketName)
|
bucketName = sanitizeBucket(bucketName)
|
||||||
try {
|
try {
|
||||||
await client
|
await client
|
||||||
|
@ -139,13 +139,13 @@ export const makeSureBucketExists = async (client: any, bucketName: string) => {
|
||||||
* Uploads the contents of a file given the required parameters, useful when
|
* Uploads the contents of a file given the required parameters, useful when
|
||||||
* temp files in use (for example file uploaded as an attachment).
|
* temp files in use (for example file uploaded as an attachment).
|
||||||
*/
|
*/
|
||||||
export const upload = async ({
|
export async function upload({
|
||||||
bucket: bucketName,
|
bucket: bucketName,
|
||||||
filename,
|
filename,
|
||||||
path,
|
path,
|
||||||
type,
|
type,
|
||||||
metadata,
|
metadata,
|
||||||
}: UploadParams) => {
|
}: UploadParams) {
|
||||||
const extension = filename.split(".").pop()
|
const extension = filename.split(".").pop()
|
||||||
const fileBytes = fs.readFileSync(path)
|
const fileBytes = fs.readFileSync(path)
|
||||||
|
|
||||||
|
@ -180,12 +180,12 @@ export const upload = async ({
|
||||||
* Similar to the upload function but can be used to send a file stream
|
* Similar to the upload function but can be used to send a file stream
|
||||||
* through to the object store.
|
* through to the object store.
|
||||||
*/
|
*/
|
||||||
export const streamUpload = async (
|
export async function streamUpload(
|
||||||
bucketName: string,
|
bucketName: string,
|
||||||
filename: string,
|
filename: string,
|
||||||
stream: any,
|
stream: any,
|
||||||
extra = {}
|
extra = {}
|
||||||
) => {
|
) {
|
||||||
const objectStore = ObjectStore(bucketName)
|
const objectStore = ObjectStore(bucketName)
|
||||||
await makeSureBucketExists(objectStore, bucketName)
|
await makeSureBucketExists(objectStore, bucketName)
|
||||||
|
|
||||||
|
@ -215,7 +215,7 @@ export const streamUpload = async (
|
||||||
* retrieves the contents of a file from the object store, if it is a known content type it
|
* retrieves the contents of a file from the object store, if it is a known content type it
|
||||||
* will be converted, otherwise it will be returned as a buffer stream.
|
* will be converted, otherwise it will be returned as a buffer stream.
|
||||||
*/
|
*/
|
||||||
export const retrieve = async (bucketName: string, filepath: string) => {
|
export async function retrieve(bucketName: string, filepath: string) {
|
||||||
const objectStore = ObjectStore(bucketName)
|
const objectStore = ObjectStore(bucketName)
|
||||||
const params = {
|
const params = {
|
||||||
Bucket: sanitizeBucket(bucketName),
|
Bucket: sanitizeBucket(bucketName),
|
||||||
|
@ -230,7 +230,7 @@ export const retrieve = async (bucketName: string, filepath: string) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const listAllObjects = async (bucketName: string, path: string) => {
|
export async function listAllObjects(bucketName: string, path: string) {
|
||||||
const objectStore = ObjectStore(bucketName)
|
const objectStore = ObjectStore(bucketName)
|
||||||
const list = (params: ListParams = {}) => {
|
const list = (params: ListParams = {}) => {
|
||||||
return objectStore
|
return objectStore
|
||||||
|
@ -261,11 +261,11 @@ export const listAllObjects = async (bucketName: string, path: string) => {
|
||||||
/**
|
/**
|
||||||
* Generate a presigned url with a default TTL of 1 hour
|
* Generate a presigned url with a default TTL of 1 hour
|
||||||
*/
|
*/
|
||||||
export const getPresignedUrl = (
|
export function getPresignedUrl(
|
||||||
bucketName: string,
|
bucketName: string,
|
||||||
key: string,
|
key: string,
|
||||||
durationSeconds: number = 3600
|
durationSeconds: number = 3600
|
||||||
) => {
|
) {
|
||||||
const objectStore = ObjectStore(bucketName, { presigning: true })
|
const objectStore = ObjectStore(bucketName, { presigning: true })
|
||||||
const params = {
|
const params = {
|
||||||
Bucket: sanitizeBucket(bucketName),
|
Bucket: sanitizeBucket(bucketName),
|
||||||
|
@ -291,7 +291,7 @@ export const getPresignedUrl = (
|
||||||
/**
|
/**
|
||||||
* Same as retrieval function but puts to a temporary file.
|
* Same as retrieval function but puts to a temporary file.
|
||||||
*/
|
*/
|
||||||
export const retrieveToTmp = async (bucketName: string, filepath: string) => {
|
export async function retrieveToTmp(bucketName: string, filepath: string) {
|
||||||
bucketName = sanitizeBucket(bucketName)
|
bucketName = sanitizeBucket(bucketName)
|
||||||
filepath = sanitizeKey(filepath)
|
filepath = sanitizeKey(filepath)
|
||||||
const data = await retrieve(bucketName, filepath)
|
const data = await retrieve(bucketName, filepath)
|
||||||
|
@ -300,7 +300,7 @@ export const retrieveToTmp = async (bucketName: string, filepath: string) => {
|
||||||
return outputPath
|
return outputPath
|
||||||
}
|
}
|
||||||
|
|
||||||
export const retrieveDirectory = async (bucketName: string, path: string) => {
|
export async function retrieveDirectory(bucketName: string, path: string) {
|
||||||
let writePath = join(budibaseTempDir(), v4())
|
let writePath = join(budibaseTempDir(), v4())
|
||||||
fs.mkdirSync(writePath)
|
fs.mkdirSync(writePath)
|
||||||
const objects = await listAllObjects(bucketName, path)
|
const objects = await listAllObjects(bucketName, path)
|
||||||
|
@ -324,7 +324,7 @@ export const retrieveDirectory = async (bucketName: string, path: string) => {
|
||||||
/**
|
/**
|
||||||
* Delete a single file.
|
* Delete a single file.
|
||||||
*/
|
*/
|
||||||
export const deleteFile = async (bucketName: string, filepath: string) => {
|
export async function deleteFile(bucketName: string, filepath: string) {
|
||||||
const objectStore = ObjectStore(bucketName)
|
const objectStore = ObjectStore(bucketName)
|
||||||
await makeSureBucketExists(objectStore, bucketName)
|
await makeSureBucketExists(objectStore, bucketName)
|
||||||
const params = {
|
const params = {
|
||||||
|
@ -334,7 +334,7 @@ export const deleteFile = async (bucketName: string, filepath: string) => {
|
||||||
return objectStore.deleteObject(params).promise()
|
return objectStore.deleteObject(params).promise()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const deleteFiles = async (bucketName: string, filepaths: string[]) => {
|
export async function deleteFiles(bucketName: string, filepaths: string[]) {
|
||||||
const objectStore = ObjectStore(bucketName)
|
const objectStore = ObjectStore(bucketName)
|
||||||
await makeSureBucketExists(objectStore, bucketName)
|
await makeSureBucketExists(objectStore, bucketName)
|
||||||
const params = {
|
const params = {
|
||||||
|
@ -349,10 +349,10 @@ export const deleteFiles = async (bucketName: string, filepaths: string[]) => {
|
||||||
/**
|
/**
|
||||||
* Delete a path, including everything within.
|
* Delete a path, including everything within.
|
||||||
*/
|
*/
|
||||||
export const deleteFolder = async (
|
export async function deleteFolder(
|
||||||
bucketName: string,
|
bucketName: string,
|
||||||
folder: string
|
folder: string
|
||||||
): Promise<any> => {
|
): Promise<any> {
|
||||||
bucketName = sanitizeBucket(bucketName)
|
bucketName = sanitizeBucket(bucketName)
|
||||||
folder = sanitizeKey(folder)
|
folder = sanitizeKey(folder)
|
||||||
const client = ObjectStore(bucketName)
|
const client = ObjectStore(bucketName)
|
||||||
|
@ -383,11 +383,11 @@ export const deleteFolder = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const uploadDirectory = async (
|
export async function uploadDirectory(
|
||||||
bucketName: string,
|
bucketName: string,
|
||||||
localPath: string,
|
localPath: string,
|
||||||
bucketPath: string
|
bucketPath: string
|
||||||
) => {
|
) {
|
||||||
bucketName = sanitizeBucket(bucketName)
|
bucketName = sanitizeBucket(bucketName)
|
||||||
let uploads = []
|
let uploads = []
|
||||||
const files = fs.readdirSync(localPath, { withFileTypes: true })
|
const files = fs.readdirSync(localPath, { withFileTypes: true })
|
||||||
|
@ -404,11 +404,11 @@ export const uploadDirectory = async (
|
||||||
return files
|
return files
|
||||||
}
|
}
|
||||||
|
|
||||||
export const downloadTarballDirect = async (
|
export async function downloadTarballDirect(
|
||||||
url: string,
|
url: string,
|
||||||
path: string,
|
path: string,
|
||||||
headers = {}
|
headers = {}
|
||||||
) => {
|
) {
|
||||||
path = sanitizeKey(path)
|
path = sanitizeKey(path)
|
||||||
const response = await fetch(url, { headers })
|
const response = await fetch(url, { headers })
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
@ -418,11 +418,11 @@ export const downloadTarballDirect = async (
|
||||||
await streamPipeline(response.body, zlib.createUnzip(), tar.extract(path))
|
await streamPipeline(response.body, zlib.createUnzip(), tar.extract(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const downloadTarball = async (
|
export async function downloadTarball(
|
||||||
url: string,
|
url: string,
|
||||||
bucketName: string,
|
bucketName: string,
|
||||||
path: string
|
path: string
|
||||||
) => {
|
) {
|
||||||
bucketName = sanitizeBucket(bucketName)
|
bucketName = sanitizeBucket(bucketName)
|
||||||
path = sanitizeKey(path)
|
path = sanitizeKey(path)
|
||||||
const response = await fetch(url)
|
const response = await fetch(url)
|
||||||
|
@ -438,3 +438,17 @@ export const downloadTarball = async (
|
||||||
// return the temporary path incase there is a use for it
|
// return the temporary path incase there is a use for it
|
||||||
return tmpPath
|
return tmpPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getReadStream(
|
||||||
|
bucketName: string,
|
||||||
|
path: string
|
||||||
|
): Promise<Readable> {
|
||||||
|
bucketName = sanitizeBucket(bucketName)
|
||||||
|
path = sanitizeKey(path)
|
||||||
|
const client = ObjectStore(bucketName)
|
||||||
|
const params = {
|
||||||
|
Bucket: bucketName,
|
||||||
|
Key: path,
|
||||||
|
}
|
||||||
|
return client.getObject(params).createReadStream()
|
||||||
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
encodeJSBinding,
|
encodeJSBinding,
|
||||||
findHBSBlocks,
|
findHBSBlocks,
|
||||||
} from "@budibase/string-templates"
|
} from "@budibase/string-templates"
|
||||||
|
import { capitalise } from "helpers"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively searches for a specific component ID
|
* Recursively searches for a specific component ID
|
||||||
|
@ -235,3 +236,13 @@ export const makeComponentUnique = component => {
|
||||||
// Recurse on all children
|
// Recurse on all children
|
||||||
return JSON.parse(definition)
|
return JSON.parse(definition)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getComponentText = component => {
|
||||||
|
if (component?._instanceName) {
|
||||||
|
return component._instanceName
|
||||||
|
}
|
||||||
|
const type =
|
||||||
|
component._component.replace("@budibase/standard-components/", "") ||
|
||||||
|
"component"
|
||||||
|
return capitalise(type)
|
||||||
|
}
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
export let closeButtonIcon = "Close"
|
export let closeButtonIcon = "Close"
|
||||||
|
|
||||||
$: customHeaderContent = $$slots["panel-header-content"]
|
$: customHeaderContent = $$slots["panel-header-content"]
|
||||||
|
$: customTitleContent = $$slots["panel-title-content"]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
@ -33,7 +34,11 @@
|
||||||
<Icon name={icon} />
|
<Icon name={icon} />
|
||||||
{/if}
|
{/if}
|
||||||
<div class="title">
|
<div class="title">
|
||||||
<Body size="S">{title}</Body>
|
{#if customTitleContent}
|
||||||
|
<slot name="panel-title-content" />
|
||||||
|
{:else}
|
||||||
|
<Body size="S">{title || ""}</Body>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if showAddButton}
|
{#if showAddButton}
|
||||||
<div class="add-button" on:click={onClickAddButton}>
|
<div class="add-button" on:click={onClickAddButton}>
|
||||||
|
@ -134,4 +139,7 @@
|
||||||
.custom-content-wrap {
|
.custom-content-wrap {
|
||||||
border-bottom: var(--border-light);
|
border-bottom: var(--border-light);
|
||||||
}
|
}
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
<script>
|
<script>
|
||||||
import Panel from "components/design/Panel.svelte"
|
import Panel from "components/design/Panel.svelte"
|
||||||
import { store, selectedComponent, selectedScreen } from "builderStore"
|
import { store, selectedComponent, selectedScreen } from "builderStore"
|
||||||
|
import { getComponentText } from "builderStore/componentUtils"
|
||||||
import ComponentSettingsSection from "./ComponentSettingsSection.svelte"
|
import ComponentSettingsSection from "./ComponentSettingsSection.svelte"
|
||||||
import DesignSection from "./DesignSection.svelte"
|
import DesignSection from "./DesignSection.svelte"
|
||||||
import CustomStylesSection from "./CustomStylesSection.svelte"
|
import CustomStylesSection from "./CustomStylesSection.svelte"
|
||||||
import ConditionalUISection from "./ConditionalUISection.svelte"
|
import ConditionalUISection from "./ConditionalUISection.svelte"
|
||||||
|
import { notifications } from "@budibase/bbui"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getBindableProperties,
|
getBindableProperties,
|
||||||
|
@ -13,6 +15,14 @@
|
||||||
import { ActionButton } from "@budibase/bbui"
|
import { ActionButton } from "@budibase/bbui"
|
||||||
import { capitalise } from "helpers"
|
import { capitalise } from "helpers"
|
||||||
|
|
||||||
|
const onUpdateName = async value => {
|
||||||
|
try {
|
||||||
|
await store.actions.components.updateSetting("_instanceName", value)
|
||||||
|
} catch (error) {
|
||||||
|
notifications.error("Error updating component name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$: componentInstance = $selectedComponent
|
$: componentInstance = $selectedComponent
|
||||||
$: componentDefinition = store.actions.components.getDefinition(
|
$: componentDefinition = store.actions.components.getDefinition(
|
||||||
$selectedComponent?._component
|
$selectedComponent?._component
|
||||||
|
@ -39,6 +49,22 @@
|
||||||
{#if $selectedComponent}
|
{#if $selectedComponent}
|
||||||
{#key $selectedComponent._id}
|
{#key $selectedComponent._id}
|
||||||
<Panel {title} icon={componentDefinition?.icon} borderLeft wide>
|
<Panel {title} icon={componentDefinition?.icon} borderLeft wide>
|
||||||
|
<span class="panel-title-content" slot="panel-title-content">
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
value={title}
|
||||||
|
{title}
|
||||||
|
placeholder={getComponentText(componentInstance)}
|
||||||
|
on:keypress={e => {
|
||||||
|
if (e.key.toLowerCase() === "enter") {
|
||||||
|
e.target.blur()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
on:change={e => {
|
||||||
|
onUpdateName(e.target.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
<span slot="panel-header-content">
|
<span slot="panel-header-content">
|
||||||
<div class="settings-tabs">
|
<div class="settings-tabs">
|
||||||
{#each tabs as tab}
|
{#each tabs as tab}
|
||||||
|
@ -90,4 +116,24 @@
|
||||||
padding: 0 var(--spacing-l);
|
padding: 0 var(--spacing-l);
|
||||||
padding-bottom: var(--spacing-l);
|
padding-bottom: var(--spacing-l);
|
||||||
}
|
}
|
||||||
|
.input {
|
||||||
|
color: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.panel-title-content {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
.input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
input::placeholder {
|
||||||
|
color: var(--spectrum-global-color-gray-600);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script>
|
<script>
|
||||||
import { helpers } from "@budibase/shared-core"
|
import { helpers } from "@budibase/shared-core"
|
||||||
import { Input, DetailSummary, notifications } from "@budibase/bbui"
|
import { DetailSummary, notifications } from "@budibase/bbui"
|
||||||
import { store } from "builderStore"
|
import { store } from "builderStore"
|
||||||
import PropertyControl from "components/design/settings/controls/PropertyControl.svelte"
|
import PropertyControl from "components/design/settings/controls/PropertyControl.svelte"
|
||||||
import ResetFieldsButton from "components/design/settings/controls/ResetFieldsButton.svelte"
|
import ResetFieldsButton from "components/design/settings/controls/ResetFieldsButton.svelte"
|
||||||
|
@ -16,7 +16,6 @@
|
||||||
export let isScreen = false
|
export let isScreen = false
|
||||||
export let onUpdateSetting
|
export let onUpdateSetting
|
||||||
export let showSectionTitle = true
|
export let showSectionTitle = true
|
||||||
export let showInstanceName = true
|
|
||||||
|
|
||||||
$: sections = getSections(componentInstance, componentDefinition, isScreen)
|
$: sections = getSections(componentInstance, componentDefinition, isScreen)
|
||||||
|
|
||||||
|
@ -127,7 +126,7 @@
|
||||||
{#if section.visible}
|
{#if section.visible}
|
||||||
<DetailSummary
|
<DetailSummary
|
||||||
name={showSectionTitle ? section.name : ""}
|
name={showSectionTitle ? section.name : ""}
|
||||||
collapsible={false}
|
show={section.collapsed !== true}
|
||||||
>
|
>
|
||||||
{#if section.info}
|
{#if section.info}
|
||||||
<div class="section-info">
|
<div class="section-info">
|
||||||
|
@ -140,15 +139,6 @@
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="settings">
|
<div class="settings">
|
||||||
{#if idx === 0 && !componentInstance._component.endsWith("/layout") && !isScreen && showInstanceName}
|
|
||||||
<PropertyControl
|
|
||||||
control={Input}
|
|
||||||
label="Name"
|
|
||||||
key="_instanceName"
|
|
||||||
value={componentInstance._instanceName}
|
|
||||||
onChange={val => updateSetting({ key: "_instanceName" }, val)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{#each section.settings as setting (setting.key)}
|
{#each section.settings as setting (setting.key)}
|
||||||
{#if setting.visible}
|
{#if setting.visible}
|
||||||
<PropertyControl
|
<PropertyControl
|
||||||
|
|
|
@ -2,14 +2,16 @@
|
||||||
import { store, userSelectedResourceMap } from "builderStore"
|
import { store, userSelectedResourceMap } from "builderStore"
|
||||||
import ComponentDropdownMenu from "./ComponentDropdownMenu.svelte"
|
import ComponentDropdownMenu from "./ComponentDropdownMenu.svelte"
|
||||||
import NavItem from "components/common/NavItem.svelte"
|
import NavItem from "components/common/NavItem.svelte"
|
||||||
import { capitalise } from "helpers"
|
|
||||||
import { notifications } from "@budibase/bbui"
|
import { notifications } from "@budibase/bbui"
|
||||||
import {
|
import {
|
||||||
selectedComponentPath,
|
selectedComponentPath,
|
||||||
selectedComponent,
|
selectedComponent,
|
||||||
selectedScreen,
|
selectedScreen,
|
||||||
} from "builderStore"
|
} from "builderStore"
|
||||||
import { findComponentPath } from "builderStore/componentUtils"
|
import {
|
||||||
|
findComponentPath,
|
||||||
|
getComponentText,
|
||||||
|
} from "builderStore/componentUtils"
|
||||||
import { get } from "svelte/store"
|
import { get } from "svelte/store"
|
||||||
import { dndStore } from "./dndStore"
|
import { dndStore } from "./dndStore"
|
||||||
|
|
||||||
|
@ -35,16 +37,6 @@
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const getComponentText = component => {
|
|
||||||
if (component._instanceName) {
|
|
||||||
return component._instanceName
|
|
||||||
}
|
|
||||||
const type =
|
|
||||||
component._component.replace("@budibase/standard-components/", "") ||
|
|
||||||
"component"
|
|
||||||
return capitalise(type)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getComponentIcon = component => {
|
const getComponentIcon = component => {
|
||||||
const def = store.actions.components.getDefinition(component?._component)
|
const def = store.actions.components.getDefinition(component?._component)
|
||||||
return def?.icon
|
return def?.icon
|
||||||
|
|
|
@ -5305,6 +5305,12 @@
|
||||||
"key": "title",
|
"key": "title",
|
||||||
"nested": true
|
"nested": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"label": "Description",
|
||||||
|
"key": "description",
|
||||||
|
"nested": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"section": true,
|
"section": true,
|
||||||
"dependsOn": {
|
"dependsOn": {
|
||||||
|
|
|
@ -12,6 +12,7 @@
|
||||||
export let fields
|
export let fields
|
||||||
export let labelPosition
|
export let labelPosition
|
||||||
export let title
|
export let title
|
||||||
|
export let description
|
||||||
export let showDeleteButton
|
export let showDeleteButton
|
||||||
export let showSaveButton
|
export let showSaveButton
|
||||||
export let saveButtonLabel
|
export let saveButtonLabel
|
||||||
|
@ -98,6 +99,7 @@
|
||||||
fields: fieldsOrDefault,
|
fields: fieldsOrDefault,
|
||||||
labelPosition,
|
labelPosition,
|
||||||
title,
|
title,
|
||||||
|
description,
|
||||||
saveButtonLabel: saveLabel,
|
saveButtonLabel: saveLabel,
|
||||||
deleteButtonLabel: deleteLabel,
|
deleteButtonLabel: deleteLabel,
|
||||||
schema,
|
schema,
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
export let fields
|
export let fields
|
||||||
export let labelPosition
|
export let labelPosition
|
||||||
export let title
|
export let title
|
||||||
|
export let description
|
||||||
export let saveButtonLabel
|
export let saveButtonLabel
|
||||||
export let deleteButtonLabel
|
export let deleteButtonLabel
|
||||||
export let schema
|
export let schema
|
||||||
|
@ -160,55 +161,71 @@
|
||||||
<BlockComponent
|
<BlockComponent
|
||||||
type="container"
|
type="container"
|
||||||
props={{
|
props={{
|
||||||
direction: "row",
|
direction: "column",
|
||||||
hAlign: "stretch",
|
gap: "S",
|
||||||
vAlign: "center",
|
|
||||||
gap: "M",
|
|
||||||
wrap: true,
|
|
||||||
}}
|
}}
|
||||||
order={0}
|
order={0}
|
||||||
>
|
>
|
||||||
<BlockComponent
|
<BlockComponent
|
||||||
type="heading"
|
type="container"
|
||||||
props={{ text: title || "" }}
|
props={{
|
||||||
|
direction: "row",
|
||||||
|
hAlign: "stretch",
|
||||||
|
vAlign: "center",
|
||||||
|
gap: "M",
|
||||||
|
wrap: true,
|
||||||
|
}}
|
||||||
order={0}
|
order={0}
|
||||||
/>
|
>
|
||||||
{#if renderButtons}
|
|
||||||
<BlockComponent
|
<BlockComponent
|
||||||
type="container"
|
type="heading"
|
||||||
props={{
|
props={{ text: title || "" }}
|
||||||
direction: "row",
|
order={0}
|
||||||
hAlign: "stretch",
|
/>
|
||||||
vAlign: "center",
|
{#if renderButtons}
|
||||||
gap: "M",
|
<BlockComponent
|
||||||
wrap: true,
|
type="container"
|
||||||
}}
|
props={{
|
||||||
|
direction: "row",
|
||||||
|
hAlign: "stretch",
|
||||||
|
vAlign: "center",
|
||||||
|
gap: "M",
|
||||||
|
wrap: true,
|
||||||
|
}}
|
||||||
|
order={1}
|
||||||
|
>
|
||||||
|
{#if renderDeleteButton}
|
||||||
|
<BlockComponent
|
||||||
|
type="button"
|
||||||
|
props={{
|
||||||
|
text: deleteButtonLabel,
|
||||||
|
onClick: onDelete,
|
||||||
|
quiet: true,
|
||||||
|
type: "secondary",
|
||||||
|
}}
|
||||||
|
order={0}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if renderSaveButton}
|
||||||
|
<BlockComponent
|
||||||
|
type="button"
|
||||||
|
props={{
|
||||||
|
text: saveButtonLabel,
|
||||||
|
onClick: onSave,
|
||||||
|
type: "cta",
|
||||||
|
}}
|
||||||
|
order={1}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</BlockComponent>
|
||||||
|
{/if}
|
||||||
|
</BlockComponent>
|
||||||
|
{#if description}
|
||||||
|
<BlockComponent
|
||||||
|
type="text"
|
||||||
|
props={{ text: description }}
|
||||||
order={1}
|
order={1}
|
||||||
>
|
/>
|
||||||
{#if renderDeleteButton}
|
|
||||||
<BlockComponent
|
|
||||||
type="button"
|
|
||||||
props={{
|
|
||||||
text: deleteButtonLabel,
|
|
||||||
onClick: onDelete,
|
|
||||||
quiet: true,
|
|
||||||
type: "secondary",
|
|
||||||
}}
|
|
||||||
order={0}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{#if renderSaveButton}
|
|
||||||
<BlockComponent
|
|
||||||
type="button"
|
|
||||||
props={{
|
|
||||||
text: saveButtonLabel,
|
|
||||||
onClick: onSave,
|
|
||||||
type: "cta",
|
|
||||||
}}
|
|
||||||
order={1}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</BlockComponent>
|
|
||||||
{/if}
|
{/if}
|
||||||
</BlockComponent>
|
</BlockComponent>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -12,6 +12,8 @@
|
||||||
import GridCell from "./GridCell.svelte"
|
import GridCell from "./GridCell.svelte"
|
||||||
import { getColumnIcon } from "../lib/utils"
|
import { getColumnIcon } from "../lib/utils"
|
||||||
import MigrationModal from "../controls/MigrationModal.svelte"
|
import MigrationModal from "../controls/MigrationModal.svelte"
|
||||||
|
import { debounce } from "../../../utils/utils"
|
||||||
|
import { FieldType, FormulaTypes } from "@budibase/types"
|
||||||
|
|
||||||
export let column
|
export let column
|
||||||
export let idx
|
export let idx
|
||||||
|
@ -32,24 +34,70 @@
|
||||||
definition,
|
definition,
|
||||||
datasource,
|
datasource,
|
||||||
schema,
|
schema,
|
||||||
|
focusedCellId,
|
||||||
|
filter,
|
||||||
|
inlineFilters,
|
||||||
} = getContext("grid")
|
} = getContext("grid")
|
||||||
|
|
||||||
|
const searchableTypes = [
|
||||||
|
FieldType.STRING,
|
||||||
|
FieldType.OPTIONS,
|
||||||
|
FieldType.NUMBER,
|
||||||
|
FieldType.BIGINT,
|
||||||
|
FieldType.ARRAY,
|
||||||
|
FieldType.LONGFORM,
|
||||||
|
]
|
||||||
|
|
||||||
let anchor
|
let anchor
|
||||||
let open = false
|
let open = false
|
||||||
let editIsOpen = false
|
let editIsOpen = false
|
||||||
let timeout
|
let timeout
|
||||||
let popover
|
let popover
|
||||||
let migrationModal
|
let migrationModal
|
||||||
|
let searchValue
|
||||||
|
let input
|
||||||
|
|
||||||
$: sortedBy = column.name === $sort.column
|
$: sortedBy = column.name === $sort.column
|
||||||
$: canMoveLeft = orderable && idx > 0
|
$: canMoveLeft = orderable && idx > 0
|
||||||
$: canMoveRight = orderable && idx < $renderedColumns.length - 1
|
$: canMoveRight = orderable && idx < $renderedColumns.length - 1
|
||||||
$: ascendingLabel = ["number", "bigint"].includes(column.schema?.type)
|
$: sortingLabels = getSortingLabels(column.schema?.type)
|
||||||
? "low-high"
|
$: searchable = isColumnSearchable(column)
|
||||||
: "A-Z"
|
$: resetSearchValue(column.name)
|
||||||
$: descendingLabel = ["number", "bigint"].includes(column.schema?.type)
|
$: searching = searchValue != null
|
||||||
? "high-low"
|
$: debouncedUpdateFilter(searchValue)
|
||||||
: "Z-A"
|
|
||||||
|
const getSortingLabels = type => {
|
||||||
|
switch (type) {
|
||||||
|
case FieldType.NUMBER:
|
||||||
|
case FieldType.BIGINT:
|
||||||
|
return {
|
||||||
|
ascending: "low-high",
|
||||||
|
descending: "high-low",
|
||||||
|
}
|
||||||
|
case FieldType.DATETIME:
|
||||||
|
return {
|
||||||
|
ascending: "old-new",
|
||||||
|
descending: "new-old",
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
ascending: "A-Z",
|
||||||
|
descending: "Z-A",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetSearchValue = name => {
|
||||||
|
searchValue = $inlineFilters?.find(x => x.id === `inline-${name}`)?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const isColumnSearchable = col => {
|
||||||
|
const { type, formulaType } = col.schema
|
||||||
|
return (
|
||||||
|
searchableTypes.includes(type) ||
|
||||||
|
(type === FieldType.FORMULA && formulaType === FormulaTypes.STATIC)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const editColumn = async () => {
|
const editColumn = async () => {
|
||||||
editIsOpen = true
|
editIsOpen = true
|
||||||
|
@ -155,6 +203,38 @@
|
||||||
open = false
|
open = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startSearching = async () => {
|
||||||
|
$focusedCellId = null
|
||||||
|
searchValue = ""
|
||||||
|
await tick()
|
||||||
|
input?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onInputKeyDown = e => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
updateFilter()
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
input?.blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopSearching = () => {
|
||||||
|
searchValue = null
|
||||||
|
updateFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBlurInput = () => {
|
||||||
|
if (searchValue === "") {
|
||||||
|
searchValue = null
|
||||||
|
}
|
||||||
|
updateFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFilter = () => {
|
||||||
|
filter.actions.addInlineFilter(column, searchValue)
|
||||||
|
}
|
||||||
|
const debouncedUpdateFilter = debounce(updateFilter, 250)
|
||||||
|
|
||||||
onMount(() => subscribe("close-edit-column", cancelEdit))
|
onMount(() => subscribe("close-edit-column", cancelEdit))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -165,6 +245,8 @@
|
||||||
<div
|
<div
|
||||||
class="header-cell"
|
class="header-cell"
|
||||||
class:open
|
class:open
|
||||||
|
class:searchable
|
||||||
|
class:searching
|
||||||
style="flex: 0 0 {column.width}px;"
|
style="flex: 0 0 {column.width}px;"
|
||||||
bind:this={anchor}
|
bind:this={anchor}
|
||||||
class:disabled={$isReordering || $isResizing}
|
class:disabled={$isReordering || $isResizing}
|
||||||
|
@ -179,30 +261,49 @@
|
||||||
defaultHeight
|
defaultHeight
|
||||||
center
|
center
|
||||||
>
|
>
|
||||||
<Icon
|
{#if searching}
|
||||||
size="S"
|
<input
|
||||||
name={getColumnIcon(column)}
|
bind:this={input}
|
||||||
color={`var(--spectrum-global-color-gray-600)`}
|
type="text"
|
||||||
/>
|
bind:value={searchValue}
|
||||||
|
on:blur={onBlurInput}
|
||||||
|
on:click={() => focusedCellId.set(null)}
|
||||||
|
on:keydown={onInputKeyDown}
|
||||||
|
data-grid-ignore
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="column-icon">
|
||||||
|
<Icon size="S" name={getColumnIcon(column)} />
|
||||||
|
</div>
|
||||||
|
<div class="search-icon" on:click={startSearching}>
|
||||||
|
<Icon hoverable size="S" name="Search" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="name">
|
<div class="name">
|
||||||
{column.label}
|
{column.label}
|
||||||
</div>
|
</div>
|
||||||
{#if sortedBy}
|
|
||||||
<div class="sort-indicator">
|
{#if searching}
|
||||||
<Icon
|
<div class="clear-icon" on:click={stopSearching}>
|
||||||
size="S"
|
<Icon hoverable size="S" name="Close" />
|
||||||
name={$sort.order === "descending" ? "SortOrderDown" : "SortOrderUp"}
|
</div>
|
||||||
color="var(--spectrum-global-color-gray-600)"
|
{:else}
|
||||||
/>
|
{#if sortedBy}
|
||||||
|
<div class="sort-indicator">
|
||||||
|
<Icon
|
||||||
|
hoverable
|
||||||
|
size="S"
|
||||||
|
name={$sort.order === "descending"
|
||||||
|
? "SortOrderDown"
|
||||||
|
: "SortOrderUp"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="more-icon" on:click={() => (open = true)}>
|
||||||
|
<Icon hoverable size="S" name="MoreVertical" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="more" on:click={() => (open = true)}>
|
|
||||||
<Icon
|
|
||||||
size="S"
|
|
||||||
name="MoreVertical"
|
|
||||||
color="var(--spectrum-global-color-gray-600)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</GridCell>
|
</GridCell>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -253,7 +354,7 @@
|
||||||
disabled={!canBeSortColumn(column.schema.type) ||
|
disabled={!canBeSortColumn(column.schema.type) ||
|
||||||
(column.name === $sort.column && $sort.order === "ascending")}
|
(column.name === $sort.column && $sort.order === "ascending")}
|
||||||
>
|
>
|
||||||
Sort {ascendingLabel}
|
Sort {sortingLabels.ascending}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon="SortOrderDown"
|
icon="SortOrderDown"
|
||||||
|
@ -261,7 +362,7 @@
|
||||||
disabled={!canBeSortColumn(column.schema.type) ||
|
disabled={!canBeSortColumn(column.schema.type) ||
|
||||||
(column.name === $sort.column && $sort.order === "descending")}
|
(column.name === $sort.column && $sort.order === "descending")}
|
||||||
>
|
>
|
||||||
Sort {descendingLabel}
|
Sort {sortingLabels.descending}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem disabled={!canMoveLeft} icon="ChevronLeft" on:click={moveLeft}>
|
<MenuItem disabled={!canMoveLeft} icon="ChevronLeft" on:click={moveLeft}>
|
||||||
Move left
|
Move left
|
||||||
|
@ -306,6 +407,29 @@
|
||||||
background: var(--grid-background-alt);
|
background: var(--grid-background-alt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Icon colors */
|
||||||
|
.header-cell :global(.spectrum-Icon) {
|
||||||
|
color: var(--spectrum-global-color-gray-600);
|
||||||
|
}
|
||||||
|
.header-cell :global(.spectrum-Icon.hoverable:hover) {
|
||||||
|
color: var(--spectrum-global-color-gray-800) !important;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search icon */
|
||||||
|
.search-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.header-cell.searchable:not(.open):hover .search-icon,
|
||||||
|
.header-cell.searchable.searching .search-icon {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.header-cell.searchable:not(.open):hover .column-icon,
|
||||||
|
.header-cell.searchable.searching .column-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main center content */
|
||||||
.name {
|
.name {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
width: 0;
|
width: 0;
|
||||||
|
@ -313,23 +437,45 @@
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.header-cell.searching .name {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
display: none;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
outline: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--spectrum-global-color-gray-800);
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 30px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
input:focus {
|
||||||
|
border: 1px solid var(--accent-color);
|
||||||
|
}
|
||||||
|
input:not(:focus) {
|
||||||
|
background: var(--spectrum-global-color-gray-200);
|
||||||
|
}
|
||||||
|
.header-cell.searching input {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.more {
|
/* Right icons */
|
||||||
|
.more-icon {
|
||||||
display: none;
|
display: none;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
margin: 0 -4px;
|
margin: 0 -4px;
|
||||||
}
|
}
|
||||||
.header-cell.open .more,
|
.header-cell.open .more-icon,
|
||||||
.header-cell:hover .more {
|
.header-cell:hover .more-icon {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.more:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.more:hover :global(.spectrum-Icon) {
|
|
||||||
color: var(--spectrum-global-color-gray-800) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-cell.open .sort-indicator,
|
.header-cell.open .sort-indicator,
|
||||||
.header-cell:hover .sort-indicator {
|
.header-cell:hover .sort-indicator {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
|
@ -27,8 +27,10 @@
|
||||||
rowVerticalInversionIndex,
|
rowVerticalInversionIndex,
|
||||||
columnHorizontalInversionIndex,
|
columnHorizontalInversionIndex,
|
||||||
selectedRows,
|
selectedRows,
|
||||||
loading,
|
loaded,
|
||||||
|
refreshing,
|
||||||
config,
|
config,
|
||||||
|
filter,
|
||||||
} = getContext("grid")
|
} = getContext("grid")
|
||||||
|
|
||||||
let visible = false
|
let visible = false
|
||||||
|
@ -153,7 +155,7 @@
|
||||||
<!-- New row FAB -->
|
<!-- New row FAB -->
|
||||||
<TempTooltip
|
<TempTooltip
|
||||||
text="Click here to create your first row"
|
text="Click here to create your first row"
|
||||||
condition={hasNoRows && !$loading}
|
condition={hasNoRows && $loaded && !$filter?.length && !$refreshing}
|
||||||
type={TooltipType.Info}
|
type={TooltipType.Info}
|
||||||
>
|
>
|
||||||
{#if !visible && !selectedRowCount && $config.canAddRows}
|
{#if !visible && !selectedRowCount && $config.canAddRows}
|
||||||
|
|
|
@ -21,6 +21,7 @@
|
||||||
const ignoredOriginSelectors = [
|
const ignoredOriginSelectors = [
|
||||||
".spectrum-Modal",
|
".spectrum-Modal",
|
||||||
"#builder-side-panel-container",
|
"#builder-side-panel-container",
|
||||||
|
"[data-grid-ignore]",
|
||||||
]
|
]
|
||||||
|
|
||||||
// Global key listener which intercepts all key events
|
// Global key listener which intercepts all key events
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { derived, get, writable } from "svelte/store"
|
import { derived, get } from "svelte/store"
|
||||||
import { getDatasourceDefinition } from "../../../fetch"
|
import { getDatasourceDefinition, getDatasourceSchema } from "../../../fetch"
|
||||||
|
import { memo } from "../../../utils"
|
||||||
|
|
||||||
export const createStores = () => {
|
export const createStores = () => {
|
||||||
const definition = writable(null)
|
const definition = memo(null)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
definition,
|
definition,
|
||||||
|
@ -10,10 +11,15 @@ export const createStores = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const deriveStores = context => {
|
export const deriveStores = context => {
|
||||||
const { definition, schemaOverrides, columnWhitelist, datasource } = context
|
const { API, definition, schemaOverrides, columnWhitelist, datasource } =
|
||||||
|
context
|
||||||
|
|
||||||
const schema = derived(definition, $definition => {
|
const schema = derived(definition, $definition => {
|
||||||
let schema = $definition?.schema
|
let schema = getDatasourceSchema({
|
||||||
|
API,
|
||||||
|
datasource: get(datasource),
|
||||||
|
definition: $definition,
|
||||||
|
})
|
||||||
if (!schema) {
|
if (!schema) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
@ -66,6 +66,8 @@ export const initialise = context => {
|
||||||
datasource,
|
datasource,
|
||||||
sort,
|
sort,
|
||||||
filter,
|
filter,
|
||||||
|
inlineFilters,
|
||||||
|
allFilters,
|
||||||
nonPlus,
|
nonPlus,
|
||||||
initialFilter,
|
initialFilter,
|
||||||
initialSortColumn,
|
initialSortColumn,
|
||||||
|
@ -87,6 +89,7 @@ export const initialise = context => {
|
||||||
|
|
||||||
// Wipe state
|
// Wipe state
|
||||||
filter.set(get(initialFilter))
|
filter.set(get(initialFilter))
|
||||||
|
inlineFilters.set([])
|
||||||
sort.set({
|
sort.set({
|
||||||
column: get(initialSortColumn),
|
column: get(initialSortColumn),
|
||||||
order: get(initialSortOrder) || "ascending",
|
order: get(initialSortOrder) || "ascending",
|
||||||
|
@ -94,14 +97,14 @@ export const initialise = context => {
|
||||||
|
|
||||||
// Update fetch when filter changes
|
// Update fetch when filter changes
|
||||||
unsubscribers.push(
|
unsubscribers.push(
|
||||||
filter.subscribe($filter => {
|
allFilters.subscribe($allFilters => {
|
||||||
// Ensure we're updating the correct fetch
|
// Ensure we're updating the correct fetch
|
||||||
const $fetch = get(fetch)
|
const $fetch = get(fetch)
|
||||||
if (!isSameDatasource($fetch?.options?.datasource, $datasource)) {
|
if (!isSameDatasource($fetch?.options?.datasource, $datasource)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
$fetch.update({
|
$fetch.update({
|
||||||
filter: $filter,
|
filter: $allFilters,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
|
@ -71,6 +71,8 @@ export const initialise = context => {
|
||||||
datasource,
|
datasource,
|
||||||
fetch,
|
fetch,
|
||||||
filter,
|
filter,
|
||||||
|
inlineFilters,
|
||||||
|
allFilters,
|
||||||
sort,
|
sort,
|
||||||
table,
|
table,
|
||||||
initialFilter,
|
initialFilter,
|
||||||
|
@ -93,6 +95,7 @@ export const initialise = context => {
|
||||||
|
|
||||||
// Wipe state
|
// Wipe state
|
||||||
filter.set(get(initialFilter))
|
filter.set(get(initialFilter))
|
||||||
|
inlineFilters.set([])
|
||||||
sort.set({
|
sort.set({
|
||||||
column: get(initialSortColumn),
|
column: get(initialSortColumn),
|
||||||
order: get(initialSortOrder) || "ascending",
|
order: get(initialSortOrder) || "ascending",
|
||||||
|
@ -100,14 +103,14 @@ export const initialise = context => {
|
||||||
|
|
||||||
// Update fetch when filter changes
|
// Update fetch when filter changes
|
||||||
unsubscribers.push(
|
unsubscribers.push(
|
||||||
filter.subscribe($filter => {
|
allFilters.subscribe($allFilters => {
|
||||||
// Ensure we're updating the correct fetch
|
// Ensure we're updating the correct fetch
|
||||||
const $fetch = get(fetch)
|
const $fetch = get(fetch)
|
||||||
if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) {
|
if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
$fetch.update({
|
$fetch.update({
|
||||||
filter: $filter,
|
filter: $allFilters,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
|
@ -73,6 +73,8 @@ export const initialise = context => {
|
||||||
sort,
|
sort,
|
||||||
rows,
|
rows,
|
||||||
filter,
|
filter,
|
||||||
|
inlineFilters,
|
||||||
|
allFilters,
|
||||||
subscribe,
|
subscribe,
|
||||||
viewV2,
|
viewV2,
|
||||||
initialFilter,
|
initialFilter,
|
||||||
|
@ -97,6 +99,7 @@ export const initialise = context => {
|
||||||
|
|
||||||
// Reset state for new view
|
// Reset state for new view
|
||||||
filter.set(get(initialFilter))
|
filter.set(get(initialFilter))
|
||||||
|
inlineFilters.set([])
|
||||||
sort.set({
|
sort.set({
|
||||||
column: get(initialSortColumn),
|
column: get(initialSortColumn),
|
||||||
order: get(initialSortOrder) || "ascending",
|
order: get(initialSortOrder) || "ascending",
|
||||||
|
@ -143,21 +146,19 @@ export const initialise = context => {
|
||||||
order: $sort.order || "ascending",
|
order: $sort.order || "ascending",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await rows.actions.refreshData()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Otherwise just update the fetch
|
|
||||||
else {
|
// Also update the fetch to ensure the new sort is respected.
|
||||||
// Ensure we're updating the correct fetch
|
// Ensure we're updating the correct fetch.
|
||||||
const $fetch = get(fetch)
|
const $fetch = get(fetch)
|
||||||
if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) {
|
if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) {
|
||||||
return
|
return
|
||||||
}
|
|
||||||
$fetch.update({
|
|
||||||
sortOrder: $sort.order || "ascending",
|
|
||||||
sortColumn: $sort.column,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
$fetch.update({
|
||||||
|
sortOrder: $sort.order,
|
||||||
|
sortColumn: $sort.column,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -176,20 +177,25 @@ export const initialise = context => {
|
||||||
...$view,
|
...$view,
|
||||||
query: $filter,
|
query: $filter,
|
||||||
})
|
})
|
||||||
await rows.actions.refreshData()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Otherwise just update the fetch
|
})
|
||||||
else {
|
)
|
||||||
// Ensure we're updating the correct fetch
|
|
||||||
const $fetch = get(fetch)
|
// Keep fetch up to date with filters.
|
||||||
if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) {
|
// If we're able to save filters against the view then we only need to apply
|
||||||
return
|
// inline filters to the fetch, as saved filters are applied server side.
|
||||||
}
|
// If we can't save filters, then all filters must be applied to the fetch.
|
||||||
$fetch.update({
|
unsubscribers.push(
|
||||||
filter: $filter,
|
allFilters.subscribe($allFilters => {
|
||||||
})
|
// Ensure we're updating the correct fetch
|
||||||
|
const $fetch = get(fetch)
|
||||||
|
if ($fetch?.options?.datasource?.tableId !== $datasource.tableId) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
$fetch.update({
|
||||||
|
filter: $allFilters,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,79 @@
|
||||||
import { writable, get } from "svelte/store"
|
import { writable, get, derived } from "svelte/store"
|
||||||
|
import { FieldType } from "@budibase/types"
|
||||||
|
|
||||||
export const createStores = context => {
|
export const createStores = context => {
|
||||||
const { props } = context
|
const { props } = context
|
||||||
|
|
||||||
// Initialise to default props
|
// Initialise to default props
|
||||||
const filter = writable(get(props).initialFilter)
|
const filter = writable(get(props).initialFilter)
|
||||||
|
const inlineFilters = writable([])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filter,
|
filter,
|
||||||
|
inlineFilters,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deriveStores = context => {
|
||||||
|
const { filter, inlineFilters } = context
|
||||||
|
|
||||||
|
const allFilters = derived(
|
||||||
|
[filter, inlineFilters],
|
||||||
|
([$filter, $inlineFilters]) => {
|
||||||
|
return [...($filter || []), ...$inlineFilters]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
allFilters,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createActions = context => {
|
||||||
|
const { filter, inlineFilters } = context
|
||||||
|
|
||||||
|
const addInlineFilter = (column, value) => {
|
||||||
|
const filterId = `inline-${column.name}`
|
||||||
|
const type = column.schema.type
|
||||||
|
let inlineFilter = {
|
||||||
|
field: column.name,
|
||||||
|
id: filterId,
|
||||||
|
operator: "string",
|
||||||
|
valueType: "value",
|
||||||
|
type,
|
||||||
|
value,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add overrides specific so the certain column type
|
||||||
|
if (type === FieldType.NUMBER) {
|
||||||
|
inlineFilter.value = parseFloat(value)
|
||||||
|
inlineFilter.operator = "equal"
|
||||||
|
} else if (type === FieldType.BIGINT) {
|
||||||
|
inlineFilter.operator = "equal"
|
||||||
|
} else if (type === FieldType.ARRAY) {
|
||||||
|
inlineFilter.operator = "contains"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add this filter
|
||||||
|
inlineFilters.update($inlineFilters => {
|
||||||
|
// Remove any existing inline filter for this column
|
||||||
|
$inlineFilters = $inlineFilters?.filter(x => x.id !== filterId)
|
||||||
|
|
||||||
|
// Add new one if a value exists
|
||||||
|
if (value) {
|
||||||
|
$inlineFilters.push(inlineFilter)
|
||||||
|
}
|
||||||
|
return $inlineFilters
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
filter: {
|
||||||
|
...filter,
|
||||||
|
actions: {
|
||||||
|
addInlineFilter,
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ export const createStores = () => {
|
||||||
const rows = writable([])
|
const rows = writable([])
|
||||||
const loading = writable(false)
|
const loading = writable(false)
|
||||||
const loaded = writable(false)
|
const loaded = writable(false)
|
||||||
|
const refreshing = writable(false)
|
||||||
const rowChangeCache = writable({})
|
const rowChangeCache = writable({})
|
||||||
const inProgressChanges = writable({})
|
const inProgressChanges = writable({})
|
||||||
const hasNextPage = writable(false)
|
const hasNextPage = writable(false)
|
||||||
|
@ -53,6 +54,7 @@ export const createStores = () => {
|
||||||
fetch,
|
fetch,
|
||||||
rowLookupMap,
|
rowLookupMap,
|
||||||
loaded,
|
loaded,
|
||||||
|
refreshing,
|
||||||
loading,
|
loading,
|
||||||
rowChangeCache,
|
rowChangeCache,
|
||||||
inProgressChanges,
|
inProgressChanges,
|
||||||
|
@ -66,7 +68,7 @@ export const createActions = context => {
|
||||||
rows,
|
rows,
|
||||||
rowLookupMap,
|
rowLookupMap,
|
||||||
definition,
|
definition,
|
||||||
filter,
|
allFilters,
|
||||||
loading,
|
loading,
|
||||||
sort,
|
sort,
|
||||||
datasource,
|
datasource,
|
||||||
|
@ -82,6 +84,7 @@ export const createActions = context => {
|
||||||
notifications,
|
notifications,
|
||||||
fetch,
|
fetch,
|
||||||
isDatasourcePlus,
|
isDatasourcePlus,
|
||||||
|
refreshing,
|
||||||
} = context
|
} = context
|
||||||
const instanceLoaded = writable(false)
|
const instanceLoaded = writable(false)
|
||||||
|
|
||||||
|
@ -108,7 +111,7 @@ export const createActions = context => {
|
||||||
// Tick to allow other reactive logic to update stores when datasource changes
|
// Tick to allow other reactive logic to update stores when datasource changes
|
||||||
// before proceeding. This allows us to wipe filters etc if needed.
|
// before proceeding. This allows us to wipe filters etc if needed.
|
||||||
await tick()
|
await tick()
|
||||||
const $filter = get(filter)
|
const $allFilters = get(allFilters)
|
||||||
const $sort = get(sort)
|
const $sort = get(sort)
|
||||||
|
|
||||||
// Determine how many rows to fetch per page
|
// Determine how many rows to fetch per page
|
||||||
|
@ -120,7 +123,7 @@ export const createActions = context => {
|
||||||
API,
|
API,
|
||||||
datasource: $datasource,
|
datasource: $datasource,
|
||||||
options: {
|
options: {
|
||||||
filter: $filter,
|
filter: $allFilters,
|
||||||
sortColumn: $sort.column,
|
sortColumn: $sort.column,
|
||||||
sortOrder: $sort.order,
|
sortOrder: $sort.order,
|
||||||
limit,
|
limit,
|
||||||
|
@ -176,6 +179,9 @@ export const createActions = context => {
|
||||||
// Notify that we're loaded
|
// Notify that we're loaded
|
||||||
loading.set(false)
|
loading.set(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update refreshing state
|
||||||
|
refreshing.set($fetch.loading)
|
||||||
})
|
})
|
||||||
|
|
||||||
fetch.set(newFetch)
|
fetch.set(newFetch)
|
||||||
|
|
|
@ -35,9 +35,28 @@ export default class ViewV2Fetch extends DataFetch {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getData() {
|
async getData() {
|
||||||
const { datasource, limit, sortColumn, sortOrder, sortType, paginate } =
|
const {
|
||||||
this.options
|
datasource,
|
||||||
const { cursor, query } = get(this.store)
|
limit,
|
||||||
|
sortColumn,
|
||||||
|
sortOrder,
|
||||||
|
sortType,
|
||||||
|
paginate,
|
||||||
|
filter,
|
||||||
|
} = this.options
|
||||||
|
const { cursor, query, definition } = get(this.store)
|
||||||
|
|
||||||
|
// If sort/filter params are not defined, update options to store the
|
||||||
|
// params built in to this view. This ensures that we can accurately
|
||||||
|
// compare old and new params and skip a redundant API call.
|
||||||
|
if (!sortColumn && definition.sort?.field) {
|
||||||
|
this.options.sortColumn = definition.sort.field
|
||||||
|
this.options.sortOrder = definition.sort.order
|
||||||
|
}
|
||||||
|
if (!filter?.length && definition.query?.length) {
|
||||||
|
this.options.filter = definition.query
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await this.API.viewV2.fetch({
|
const res = await this.API.viewV2.fetch({
|
||||||
viewId: datasource.id,
|
viewId: datasource.id,
|
||||||
|
|
|
@ -32,12 +32,24 @@ export const fetchData = ({ API, datasource, options }) => {
|
||||||
return new Fetch({ API, datasource, ...options })
|
return new Fetch({ API, datasource, ...options })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetches the definition of any type of datasource
|
// Creates an empty fetch instance with no datasource configured, so no data
|
||||||
export const getDatasourceDefinition = async ({ API, datasource }) => {
|
// will initially be loaded
|
||||||
|
const createEmptyFetchInstance = ({ API, datasource }) => {
|
||||||
const handler = DataFetchMap[datasource?.type]
|
const handler = DataFetchMap[datasource?.type]
|
||||||
if (!handler) {
|
if (!handler) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const instance = new handler({ API })
|
return new handler({ API })
|
||||||
return await instance.getDefinition(datasource)
|
}
|
||||||
|
|
||||||
|
// Fetches the definition of any type of datasource
|
||||||
|
export const getDatasourceDefinition = async ({ API, datasource }) => {
|
||||||
|
const instance = createEmptyFetchInstance({ API, datasource })
|
||||||
|
return await instance?.getDefinition(datasource)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetches the schema of any type of datasource
|
||||||
|
export const getDatasourceSchema = ({ API, datasource, definition }) => {
|
||||||
|
const instance = createEmptyFetchInstance({ API, datasource })
|
||||||
|
return instance?.getSchema(datasource, definition)
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,7 @@ import AWS from "aws-sdk"
|
||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
import sdk from "../../../sdk"
|
import sdk from "../../../sdk"
|
||||||
import * as pro from "@budibase/pro"
|
import * as pro from "@budibase/pro"
|
||||||
import { App } from "@budibase/types"
|
import { App, Ctx } from "@budibase/types"
|
||||||
|
|
||||||
const send = require("koa-send")
|
const send = require("koa-send")
|
||||||
|
|
||||||
|
@ -39,7 +39,7 @@ async function prepareUpload({ s3Key, bucket, metadata, file }: any) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const toggleBetaUiFeature = async function (ctx: any) {
|
export const toggleBetaUiFeature = async function (ctx: Ctx) {
|
||||||
const cookieName = `beta:${ctx.params.feature}`
|
const cookieName = `beta:${ctx.params.feature}`
|
||||||
|
|
||||||
if (ctx.cookies.get(cookieName)) {
|
if (ctx.cookies.get(cookieName)) {
|
||||||
|
@ -67,16 +67,14 @@ export const toggleBetaUiFeature = async function (ctx: any) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const serveBuilder = async function (ctx: any) {
|
export const serveBuilder = async function (ctx: Ctx) {
|
||||||
const builderPath = join(TOP_LEVEL_PATH, "builder")
|
const builderPath = join(TOP_LEVEL_PATH, "builder")
|
||||||
await send(ctx, ctx.file, { root: builderPath })
|
await send(ctx, ctx.file, { root: builderPath })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const uploadFile = async function (ctx: any) {
|
export const uploadFile = async function (ctx: Ctx) {
|
||||||
let files =
|
const file = ctx.request?.files?.file
|
||||||
ctx.request.files.file.length > 1
|
let files = file && Array.isArray(file) ? Array.from(file) : [file]
|
||||||
? Array.from(ctx.request.files.file)
|
|
||||||
: [ctx.request.files.file]
|
|
||||||
|
|
||||||
const uploads = files.map(async (file: any) => {
|
const uploads = files.map(async (file: any) => {
|
||||||
const fileExtension = [...file.name.split(".")].pop()
|
const fileExtension = [...file.name.split(".")].pop()
|
||||||
|
@ -93,14 +91,14 @@ export const uploadFile = async function (ctx: any) {
|
||||||
ctx.body = await Promise.all(uploads)
|
ctx.body = await Promise.all(uploads)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const deleteObjects = async function (ctx: any) {
|
export const deleteObjects = async function (ctx: Ctx) {
|
||||||
ctx.body = await objectStore.deleteFiles(
|
ctx.body = await objectStore.deleteFiles(
|
||||||
ObjectStoreBuckets.APPS,
|
ObjectStoreBuckets.APPS,
|
||||||
ctx.request.body.keys
|
ctx.request.body.keys
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const serveApp = async function (ctx: any) {
|
export const serveApp = async function (ctx: Ctx) {
|
||||||
const bbHeaderEmbed =
|
const bbHeaderEmbed =
|
||||||
ctx.request.get("x-budibase-embed")?.toLowerCase() === "true"
|
ctx.request.get("x-budibase-embed")?.toLowerCase() === "true"
|
||||||
|
|
||||||
|
@ -181,7 +179,7 @@ export const serveApp = async function (ctx: any) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const serveBuilderPreview = async function (ctx: any) {
|
export const serveBuilderPreview = async function (ctx: Ctx) {
|
||||||
const db = context.getAppDB({ skip_setup: true })
|
const db = context.getAppDB({ skip_setup: true })
|
||||||
const appInfo = await db.get<App>(DocumentType.APP_METADATA)
|
const appInfo = await db.get<App>(DocumentType.APP_METADATA)
|
||||||
|
|
||||||
|
@ -197,18 +195,30 @@ export const serveBuilderPreview = async function (ctx: any) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const serveClientLibrary = async function (ctx: any) {
|
export const serveClientLibrary = async function (ctx: Ctx) {
|
||||||
|
const appId = context.getAppId() || (ctx.request.query.appId as string)
|
||||||
let rootPath = join(NODE_MODULES_PATH, "@budibase", "client", "dist")
|
let rootPath = join(NODE_MODULES_PATH, "@budibase", "client", "dist")
|
||||||
// incase running from TS directly
|
if (!appId) {
|
||||||
if (env.isDev() && !fs.existsSync(rootPath)) {
|
ctx.throw(400, "No app ID provided - cannot fetch client library.")
|
||||||
rootPath = join(require.resolve("@budibase/client"), "..")
|
}
|
||||||
|
if (env.isProd()) {
|
||||||
|
ctx.body = await objectStore.getReadStream(
|
||||||
|
ObjectStoreBuckets.APPS,
|
||||||
|
objectStore.clientLibraryPath(appId!)
|
||||||
|
)
|
||||||
|
ctx.set("Content-Type", "application/javascript")
|
||||||
|
} else if (env.isDev()) {
|
||||||
|
// incase running from TS directly
|
||||||
|
const tsPath = join(require.resolve("@budibase/client"), "..")
|
||||||
|
return send(ctx, "budibase-client.js", {
|
||||||
|
root: !fs.existsSync(rootPath) ? tsPath : rootPath,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
ctx.throw(500, "Unable to retrieve client library.")
|
||||||
}
|
}
|
||||||
return send(ctx, "budibase-client.js", {
|
|
||||||
root: rootPath,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getSignedUploadURL = async function (ctx: any) {
|
export const getSignedUploadURL = async function (ctx: Ctx) {
|
||||||
// Ensure datasource is valid
|
// Ensure datasource is valid
|
||||||
let datasource
|
let datasource
|
||||||
try {
|
try {
|
||||||
|
@ -247,7 +257,7 @@ export const getSignedUploadURL = async function (ctx: any) {
|
||||||
const params = { Bucket: bucket, Key: key }
|
const params = { Bucket: bucket, Key: key }
|
||||||
signedUrl = s3.getSignedUrl("putObject", params)
|
signedUrl = s3.getSignedUrl("putObject", params)
|
||||||
publicUrl = `https://${bucket}.s3.${awsRegion}.amazonaws.com/${key}`
|
publicUrl = `https://${bucket}.s3.${awsRegion}.amazonaws.com/${key}`
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
ctx.throw(400, error)
|
ctx.throw(400, error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,15 +27,9 @@ router.param("file", async (file: any, ctx: any, next: any) => {
|
||||||
return next()
|
return next()
|
||||||
})
|
})
|
||||||
|
|
||||||
// only used in development for retrieving the client library,
|
|
||||||
// in production the client lib is always stored in the object store.
|
|
||||||
if (env.isDev()) {
|
|
||||||
router.get("/api/assets/client", controller.serveClientLibrary)
|
|
||||||
}
|
|
||||||
|
|
||||||
router
|
router
|
||||||
// TODO: for now this builder endpoint is not authorized/secured, will need to be
|
|
||||||
.get("/builder/:file*", controller.serveBuilder)
|
.get("/builder/:file*", controller.serveBuilder)
|
||||||
|
.get("/api/assets/client", controller.serveClientLibrary)
|
||||||
.post("/api/attachments/process", authorized(BUILDER), controller.uploadFile)
|
.post("/api/attachments/process", authorized(BUILDER), controller.uploadFile)
|
||||||
.post(
|
.post(
|
||||||
"/api/attachments/delete",
|
"/api/attachments/delete",
|
||||||
|
|
|
@ -46,7 +46,7 @@ export enum MigrationName {
|
||||||
GLOBAL_INFO_SYNC_USERS = "global_info_sync_users",
|
GLOBAL_INFO_SYNC_USERS = "global_info_sync_users",
|
||||||
TABLE_SETTINGS_LINKS_TO_ACTIONS = "table_settings_links_to_actions",
|
TABLE_SETTINGS_LINKS_TO_ACTIONS = "table_settings_links_to_actions",
|
||||||
// increment this number to re-activate this migration
|
// increment this number to re-activate this migration
|
||||||
SYNC_QUOTAS = "sync_quotas_1",
|
SYNC_QUOTAS = "sync_quotas_2",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MigrationDefinition {
|
export interface MigrationDefinition {
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
"test:notify": "node scripts/testResultsWebhook",
|
"test:notify": "node scripts/testResultsWebhook",
|
||||||
"test:cloud:prod": "yarn run test --testPathIgnorePatterns=\\.integration\\.",
|
"test:cloud:prod": "yarn run test --testPathIgnorePatterns=\\.integration\\.",
|
||||||
"test:cloud:qa": "yarn run test",
|
"test:cloud:qa": "yarn run test",
|
||||||
"test:self:ci": "yarn run test --testPathIgnorePatterns=\\.integration\\. \\.cloud\\.",
|
"test:self:ci": "yarn run test --testPathIgnorePatterns=\\.integration\\. \\.cloud\\. \\.license\\.",
|
||||||
"serve:test:self:ci": "start-server-and-test dev:built http://localhost:4001/health test:self:ci",
|
"serve:test:self:ci": "start-server-and-test dev:built http://localhost:4001/health test:self:ci",
|
||||||
"serve": "start-server-and-test dev:built http://localhost:4001/health",
|
"serve": "start-server-and-test dev:built http://localhost:4001/health",
|
||||||
"dev:built": "cd ../ && yarn dev:built"
|
"dev:built": "cd ../ && yarn dev:built"
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import AccountInternalAPIClient from "./AccountInternalAPIClient"
|
import AccountInternalAPIClient from "./AccountInternalAPIClient"
|
||||||
import { AccountAPI, LicenseAPI, AuthAPI } from "./apis"
|
import { AccountAPI, LicenseAPI, AuthAPI, StripeAPI } from "./apis"
|
||||||
import { State } from "../../types"
|
import { State } from "../../types"
|
||||||
|
|
||||||
export default class AccountInternalAPI {
|
export default class AccountInternalAPI {
|
||||||
|
@ -8,11 +8,13 @@ export default class AccountInternalAPI {
|
||||||
auth: AuthAPI
|
auth: AuthAPI
|
||||||
accounts: AccountAPI
|
accounts: AccountAPI
|
||||||
licenses: LicenseAPI
|
licenses: LicenseAPI
|
||||||
|
stripe: StripeAPI
|
||||||
|
|
||||||
constructor(state: State) {
|
constructor(state: State) {
|
||||||
this.client = new AccountInternalAPIClient(state)
|
this.client = new AccountInternalAPIClient(state)
|
||||||
this.auth = new AuthAPI(this.client)
|
this.auth = new AuthAPI(this.client)
|
||||||
this.accounts = new AccountAPI(this.client)
|
this.accounts = new AccountAPI(this.client)
|
||||||
this.licenses = new LicenseAPI(this.client)
|
this.licenses = new LicenseAPI(this.client)
|
||||||
|
this.stripe = new StripeAPI(this.client)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,21 +2,19 @@ import AccountInternalAPIClient from "../AccountInternalAPIClient"
|
||||||
import {
|
import {
|
||||||
Account,
|
Account,
|
||||||
CreateOfflineLicenseRequest,
|
CreateOfflineLicenseRequest,
|
||||||
|
GetLicenseKeyResponse,
|
||||||
GetOfflineLicenseResponse,
|
GetOfflineLicenseResponse,
|
||||||
UpdateLicenseRequest,
|
UpdateLicenseRequest,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { Response } from "node-fetch"
|
import { Response } from "node-fetch"
|
||||||
import BaseAPI from "./BaseAPI"
|
import BaseAPI from "./BaseAPI"
|
||||||
import { APIRequestOpts } from "../../../types"
|
import { APIRequestOpts } from "../../../types"
|
||||||
|
|
||||||
export default class LicenseAPI extends BaseAPI {
|
export default class LicenseAPI extends BaseAPI {
|
||||||
client: AccountInternalAPIClient
|
client: AccountInternalAPIClient
|
||||||
|
|
||||||
constructor(client: AccountInternalAPIClient) {
|
constructor(client: AccountInternalAPIClient) {
|
||||||
super()
|
super()
|
||||||
this.client = client
|
this.client = client
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateLicense(
|
async updateLicense(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
body: UpdateLicenseRequest,
|
body: UpdateLicenseRequest,
|
||||||
|
@ -29,9 +27,7 @@ export default class LicenseAPI extends BaseAPI {
|
||||||
})
|
})
|
||||||
}, opts)
|
}, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Better approach for setting tenant id header
|
// TODO: Better approach for setting tenant id header
|
||||||
|
|
||||||
async createOfflineLicense(
|
async createOfflineLicense(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
|
@ -51,7 +47,6 @@ export default class LicenseAPI extends BaseAPI {
|
||||||
expect(response.status).toBe(opts.status ? opts.status : 201)
|
expect(response.status).toBe(opts.status ? opts.status : 201)
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOfflineLicense(
|
async getOfflineLicense(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
|
@ -69,4 +64,74 @@ export default class LicenseAPI extends BaseAPI {
|
||||||
expect(response.status).toBe(opts.status ? opts.status : 200)
|
expect(response.status).toBe(opts.status ? opts.status : 200)
|
||||||
return [response, json]
|
return [response, json]
|
||||||
}
|
}
|
||||||
|
async getLicenseKey(
|
||||||
|
opts: { status?: number } = {}
|
||||||
|
): Promise<[Response, GetLicenseKeyResponse]> {
|
||||||
|
const [response, json] = await this.client.get(`/api/license/key`)
|
||||||
|
expect(response.status).toBe(opts.status || 200)
|
||||||
|
return [response, json]
|
||||||
|
}
|
||||||
|
async activateLicense(
|
||||||
|
apiKey: string,
|
||||||
|
tenantId: string,
|
||||||
|
licenseKey: string,
|
||||||
|
opts: APIRequestOpts = { status: 200 }
|
||||||
|
) {
|
||||||
|
return this.doRequest(() => {
|
||||||
|
return this.client.post(`/api/license/activate`, {
|
||||||
|
body: {
|
||||||
|
apiKey: apiKey,
|
||||||
|
tenantId: tenantId,
|
||||||
|
licenseKey: licenseKey,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, opts)
|
||||||
|
}
|
||||||
|
async regenerateLicenseKey(opts: APIRequestOpts = { status: 200 }) {
|
||||||
|
return this.doRequest(() => {
|
||||||
|
return this.client.post(`/api/license/key/regenerate`, {})
|
||||||
|
}, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPlans(opts: APIRequestOpts = { status: 200 }) {
|
||||||
|
return this.doRequest(() => {
|
||||||
|
return this.client.get(`/api/plans`)
|
||||||
|
}, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePlan(opts: APIRequestOpts = { status: 200 }) {
|
||||||
|
return this.doRequest(() => {
|
||||||
|
return this.client.put(`/api/license/plan`)
|
||||||
|
}, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshAccountLicense(
|
||||||
|
accountId: string,
|
||||||
|
opts: { status?: number } = {}
|
||||||
|
): Promise<Response> {
|
||||||
|
const [response, json] = await this.client.post(
|
||||||
|
`/api/accounts/${accountId}/license/refresh`,
|
||||||
|
{
|
||||||
|
internal: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
expect(response.status).toBe(opts.status ? opts.status : 201)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLicenseUsage(opts: APIRequestOpts = { status: 200 }) {
|
||||||
|
return this.doRequest(() => {
|
||||||
|
return this.client.get(`/api/license/usage`)
|
||||||
|
}, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
async licenseUsageTriggered(
|
||||||
|
opts: { status?: number } = {}
|
||||||
|
): Promise<Response> {
|
||||||
|
const [response, json] = await this.client.post(
|
||||||
|
`/api/license/usage/triggered`
|
||||||
|
)
|
||||||
|
expect(response.status).toBe(opts.status ? opts.status : 201)
|
||||||
|
return response
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,64 @@
|
||||||
|
import AccountInternalAPIClient from "../AccountInternalAPIClient"
|
||||||
|
import BaseAPI from "./BaseAPI"
|
||||||
|
import { APIRequestOpts } from "../../../types"
|
||||||
|
|
||||||
|
export default class StripeAPI extends BaseAPI {
|
||||||
|
client: AccountInternalAPIClient
|
||||||
|
|
||||||
|
constructor(client: AccountInternalAPIClient) {
|
||||||
|
super()
|
||||||
|
this.client = client
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCheckoutSession(
|
||||||
|
priceId: string,
|
||||||
|
opts: APIRequestOpts = { status: 200 }
|
||||||
|
) {
|
||||||
|
return this.doRequest(() => {
|
||||||
|
return this.client.post(`/api/stripe/checkout-session`, {
|
||||||
|
body: { priceId },
|
||||||
|
})
|
||||||
|
}, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkoutSuccess(opts: APIRequestOpts = { status: 200 }) {
|
||||||
|
return this.doRequest(() => {
|
||||||
|
return this.client.post(`/api/stripe/checkout-success`)
|
||||||
|
}, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPortalSession(
|
||||||
|
stripeCustomerId: string,
|
||||||
|
opts: APIRequestOpts = { status: 200 }
|
||||||
|
) {
|
||||||
|
return this.doRequest(() => {
|
||||||
|
return this.client.post(`/api/stripe/portal-session`, {
|
||||||
|
body: { stripeCustomerId },
|
||||||
|
})
|
||||||
|
}, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
async linkStripeCustomer(opts: APIRequestOpts = { status: 200 }) {
|
||||||
|
return this.doRequest(() => {
|
||||||
|
return this.client.post(`/api/stripe/link`)
|
||||||
|
}, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInvoices(opts: APIRequestOpts = { status: 200 }) {
|
||||||
|
return this.doRequest(() => {
|
||||||
|
return this.client.get(`/api/stripe/invoices`)
|
||||||
|
}, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUpcomingInvoice(opts: APIRequestOpts = { status: 200 }) {
|
||||||
|
return this.doRequest(() => {
|
||||||
|
return this.client.get(`/api/stripe/upcoming-invoice`)
|
||||||
|
}, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStripeCustomers(opts: APIRequestOpts = { status: 200 }) {
|
||||||
|
return this.doRequest(() => {
|
||||||
|
return this.client.get(`/api/stripe/customers`)
|
||||||
|
}, opts)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,4 @@
|
||||||
export { default as AuthAPI } from "./AuthAPI"
|
export { default as AuthAPI } from "./AuthAPI"
|
||||||
export { default as AccountAPI } from "./AccountAPI"
|
export { default as AccountAPI } from "./AccountAPI"
|
||||||
export { default as LicenseAPI } from "./LicenseAPI"
|
export { default as LicenseAPI } from "./LicenseAPI"
|
||||||
|
export { default as StripeAPI } from "./StripeAPI"
|
||||||
|
|
|
@ -0,0 +1,68 @@
|
||||||
|
import TestConfiguration from "../../config/TestConfiguration"
|
||||||
|
import * as fixures from "../../fixtures"
|
||||||
|
import { Feature, Hosting } from "@budibase/types"
|
||||||
|
|
||||||
|
describe("license activation", () => {
|
||||||
|
const config = new TestConfiguration()
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await config.beforeAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await config.afterAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("creates, activates and deletes online license - self host", async () => {
|
||||||
|
// Remove existing license key
|
||||||
|
await config.internalApi.license.deleteLicenseKey()
|
||||||
|
|
||||||
|
// Verify license key not found
|
||||||
|
await config.internalApi.license.getLicenseKey({ status: 404 })
|
||||||
|
|
||||||
|
// Create self host account
|
||||||
|
const createAccountRequest = fixures.accounts.generateAccount({
|
||||||
|
hosting: Hosting.SELF,
|
||||||
|
})
|
||||||
|
const [createAccountRes, account] =
|
||||||
|
await config.accountsApi.accounts.create(createAccountRequest, {
|
||||||
|
autoVerify: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
let licenseKey: string = " "
|
||||||
|
await config.doInNewState(async () => {
|
||||||
|
await config.loginAsAccount(createAccountRequest)
|
||||||
|
// Retrieve license key
|
||||||
|
const [res, body] = await config.accountsApi.licenses.getLicenseKey()
|
||||||
|
licenseKey = body.licenseKey
|
||||||
|
})
|
||||||
|
|
||||||
|
const accountId = account.accountId!
|
||||||
|
|
||||||
|
// Update license to have paid feature
|
||||||
|
const [res, acc] = await config.accountsApi.licenses.updateLicense(
|
||||||
|
accountId,
|
||||||
|
{
|
||||||
|
overrides: {
|
||||||
|
features: [Feature.APP_BACKUPS],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Activate license key
|
||||||
|
await config.internalApi.license.activateLicenseKey({ licenseKey })
|
||||||
|
|
||||||
|
// Verify license updated with new feature
|
||||||
|
await config.doInNewState(async () => {
|
||||||
|
await config.loginAsAccount(createAccountRequest)
|
||||||
|
const [selfRes, body] = await config.api.accounts.self()
|
||||||
|
expect(body.license.features[0]).toBe("appBackups")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove license key
|
||||||
|
await config.internalApi.license.deleteLicenseKey()
|
||||||
|
|
||||||
|
// Verify license key not found
|
||||||
|
await config.internalApi.license.getLicenseKey({ status: 404 })
|
||||||
|
})
|
||||||
|
})
|
|
@ -1,17 +1,19 @@
|
||||||
import { Response } from "node-fetch"
|
import { Response } from "node-fetch"
|
||||||
import {
|
import {
|
||||||
|
ActivateLicenseKeyRequest,
|
||||||
ActivateOfflineLicenseTokenRequest,
|
ActivateOfflineLicenseTokenRequest,
|
||||||
|
GetLicenseKeyResponse,
|
||||||
GetOfflineIdentifierResponse,
|
GetOfflineIdentifierResponse,
|
||||||
GetOfflineLicenseTokenResponse,
|
GetOfflineLicenseTokenResponse,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import BudibaseInternalAPIClient from "../BudibaseInternalAPIClient"
|
import BudibaseInternalAPIClient from "../BudibaseInternalAPIClient"
|
||||||
import BaseAPI from "./BaseAPI"
|
import BaseAPI from "./BaseAPI"
|
||||||
|
import { APIRequestOpts } from "../../../types"
|
||||||
|
|
||||||
export default class LicenseAPI extends BaseAPI {
|
export default class LicenseAPI extends BaseAPI {
|
||||||
constructor(client: BudibaseInternalAPIClient) {
|
constructor(client: BudibaseInternalAPIClient) {
|
||||||
super(client)
|
super(client)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOfflineLicenseToken(
|
async getOfflineLicenseToken(
|
||||||
opts: { status?: number } = {}
|
opts: { status?: number } = {}
|
||||||
): Promise<[Response, GetOfflineLicenseTokenResponse]> {
|
): Promise<[Response, GetOfflineLicenseTokenResponse]> {
|
||||||
|
@ -21,19 +23,16 @@ export default class LicenseAPI extends BaseAPI {
|
||||||
)
|
)
|
||||||
return [response, body]
|
return [response, body]
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteOfflineLicenseToken(): Promise<[Response]> {
|
async deleteOfflineLicenseToken(): Promise<[Response]> {
|
||||||
const [response] = await this.del(`/global/license/offline`, 204)
|
const [response] = await this.del(`/global/license/offline`, 204)
|
||||||
return [response]
|
return [response]
|
||||||
}
|
}
|
||||||
|
|
||||||
async activateOfflineLicenseToken(
|
async activateOfflineLicenseToken(
|
||||||
body: ActivateOfflineLicenseTokenRequest
|
body: ActivateOfflineLicenseTokenRequest
|
||||||
): Promise<[Response]> {
|
): Promise<[Response]> {
|
||||||
const [response] = await this.post(`/global/license/offline`, body)
|
const [response] = await this.post(`/global/license/offline`, body)
|
||||||
return [response]
|
return [response]
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOfflineIdentifier(): Promise<
|
async getOfflineIdentifier(): Promise<
|
||||||
[Response, GetOfflineIdentifierResponse]
|
[Response, GetOfflineIdentifierResponse]
|
||||||
> {
|
> {
|
||||||
|
@ -42,4 +41,23 @@ export default class LicenseAPI extends BaseAPI {
|
||||||
)
|
)
|
||||||
return [response, body]
|
return [response, body]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getLicenseKey(
|
||||||
|
opts: { status?: number } = {}
|
||||||
|
): Promise<[Response, GetLicenseKeyResponse]> {
|
||||||
|
const [response, body] = await this.get(`/global/license/key`, opts.status)
|
||||||
|
return [response, body]
|
||||||
|
}
|
||||||
|
|
||||||
|
async activateLicenseKey(
|
||||||
|
body: ActivateLicenseKeyRequest
|
||||||
|
): Promise<[Response]> {
|
||||||
|
const [response] = await this.post(`/global/license/key`, body)
|
||||||
|
return [response]
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLicenseKey(): Promise<[Response]> {
|
||||||
|
const [response] = await this.del(`/global/license/key`, 204)
|
||||||
|
return [response]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue