merge with master
This commit is contained in:
commit
bebfe4c6e1
|
@ -75,6 +75,28 @@
|
||||||
"design"
|
"design"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"login": "Rory-Powell",
|
||||||
|
"name": "Rory Powell",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/8755148?v=4",
|
||||||
|
"profile": "https://github.com/Rory-Powell",
|
||||||
|
"contributions": [
|
||||||
|
"code",
|
||||||
|
"doc",
|
||||||
|
"test"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "PClmnt",
|
||||||
|
"name": "Peter Clement",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/5665926?v=4",
|
||||||
|
"profile": "https://github.com/PClmnt",
|
||||||
|
"contributions": [
|
||||||
|
"code",
|
||||||
|
"doc",
|
||||||
|
"test"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"login": "Conor-Mack",
|
"login": "Conor-Mack",
|
||||||
"name": "Conor_Mack",
|
"name": "Conor_Mack",
|
||||||
|
|
|
@ -7,3 +7,5 @@ Contributors
|
||||||
* Martin McKeaveney - [@shogunpurple](https://github.com/shogunpurple)
|
* Martin McKeaveney - [@shogunpurple](https://github.com/shogunpurple)
|
||||||
* Andrew Kingston - [@aptkingston](https://github.com/aptkingston)
|
* Andrew Kingston - [@aptkingston](https://github.com/aptkingston)
|
||||||
* Michael Drury - [@mike12345567](https://github.com/mike12345567)
|
* Michael Drury - [@mike12345567](https://github.com/mike12345567)
|
||||||
|
* Peter Clement - [@PClmnt](https://github.com/PClmnt)
|
||||||
|
* Rory Powell - [@Rory-Powell](https://github.com/Rory-Powell)
|
|
@ -126,7 +126,16 @@ To run the budibase server and builder in dev mode (i.e. with live reloading):
|
||||||
|
|
||||||
This will enable watch mode for both the builder app, server, client library and any component libraries.
|
This will enable watch mode for both the builder app, server, client library and any component libraries.
|
||||||
|
|
||||||
### 5. Cleanup
|
### 5. Debugging using VS Code
|
||||||
|
|
||||||
|
To debug the budibase server and worker a VS Code launch configuration has been provided.
|
||||||
|
|
||||||
|
Visit the debug window and select `Budibase Server` or `Budibase Worker` to debug the respective component.
|
||||||
|
Alternatively to start both components simultaneously select `Start Budibase`.
|
||||||
|
|
||||||
|
In addition to the above, the remaining budibase components may be ran in dev mode using: `yarn dev:noserver`.
|
||||||
|
|
||||||
|
### 6. Cleanup
|
||||||
|
|
||||||
If you wish to delete all the apps created in development and reset the environment then run the following:
|
If you wish to delete all the apps created in development and reset the environment then run the following:
|
||||||
|
|
||||||
|
|
|
@ -21,6 +21,27 @@
|
||||||
"args": [],
|
"args": [],
|
||||||
"cwd":"C:/code/my-apps",
|
"cwd":"C:/code/my-apps",
|
||||||
"console": "externalTerminal"
|
"console": "externalTerminal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Budibase Server",
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"runtimeArgs": ["--nolazy", "-r", "ts-node/register/transpile-only"],
|
||||||
|
"args": ["${workspaceFolder}/packages/server/src/index.ts"],
|
||||||
|
"cwd": "${workspaceFolder}/packages/server"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Budibase Worker",
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"program": "${workspaceFolder}/packages/worker/src/index.js",
|
||||||
|
"cwd": "${workspaceFolder}/packages/worker"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compounds": [
|
||||||
|
{
|
||||||
|
"name": "Start Budibase",
|
||||||
|
"configurations": ["Budibase Server", "Budibase Worker"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
|
@ -211,9 +211,11 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||||
<td align="center"><a href="https://budibase.com/"><img src="https://avatars3.githubusercontent.com/u/3524181?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Michael Shanks</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Tests">⚠️</a></td>
|
<td align="center"><a href="https://budibase.com/"><img src="https://avatars3.githubusercontent.com/u/3524181?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Michael Shanks</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Tests">⚠️</a></td>
|
||||||
<td align="center"><a href="https://github.com/kevmodrome"><img src="https://avatars3.githubusercontent.com/u/534488?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Kevin Åberg Kultalahti</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Tests">⚠️</a></td>
|
<td align="center"><a href="https://github.com/kevmodrome"><img src="https://avatars3.githubusercontent.com/u/534488?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Kevin Åberg Kultalahti</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Tests">⚠️</a></td>
|
||||||
<td align="center"><a href="https://www.budibase.com/"><img src="https://avatars2.githubusercontent.com/u/49767913?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Joe</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=joebudi" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=joebudi" title="Code">💻</a> <a href="#content-joebudi" title="Content">🖋</a> <a href="#design-joebudi" title="Design">🎨</a></td>
|
<td align="center"><a href="https://www.budibase.com/"><img src="https://avatars2.githubusercontent.com/u/49767913?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Joe</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=joebudi" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=joebudi" title="Code">💻</a> <a href="#content-joebudi" title="Content">🖋</a> <a href="#design-joebudi" title="Design">🎨</a></td>
|
||||||
<td align="center"><a href="https://github.com/Conor-Mack"><img src="https://avatars1.githubusercontent.com/u/36074859?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Conor_Mack</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=Conor-Mack" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=Conor-Mack" title="Tests">⚠️</a></td>
|
<td align="center"><a href="https://github.com/Rory-Powell"><img src="https://avatars.githubusercontent.com/u/8755148?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Rory Powell</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=Rory-Powell" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=Rory-Powell" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=Rory-Powell" title="Tests">⚠️</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
<td align="center"><a href="https://github.com/PClmnt"><img src="https://avatars.githubusercontent.com/u/5665926?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Peter Clement</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=PClmnt" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=PClmnt" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=PClmnt" title="Tests">⚠️</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/Conor-Mack"><img src="https://avatars1.githubusercontent.com/u/36074859?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Conor_Mack</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=Conor-Mack" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=Conor-Mack" title="Tests">⚠️</a></td>
|
||||||
<td align="center"><a href="https://github.com/pngwn"><img src="https://avatars1.githubusercontent.com/u/12937446?v=4?s=100" width="100px;" alt=""/><br /><sub><b>pngwn</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=pngwn" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=pngwn" title="Tests">⚠️</a></td>
|
<td align="center"><a href="https://github.com/pngwn"><img src="https://avatars1.githubusercontent.com/u/12937446?v=4?s=100" width="100px;" alt=""/><br /><sub><b>pngwn</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=pngwn" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=pngwn" title="Tests">⚠️</a></td>
|
||||||
<td align="center"><a href="https://github.com/HugoLd"><img src="https://avatars0.githubusercontent.com/u/26521848?v=4?s=100" width="100px;" alt=""/><br /><sub><b>HugoLd</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=HugoLd" title="Code">💻</a></td>
|
<td align="center"><a href="https://github.com/HugoLd"><img src="https://avatars0.githubusercontent.com/u/26521848?v=4?s=100" width="100px;" alt=""/><br /><sub><b>HugoLd</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=HugoLd" title="Code">💻</a></td>
|
||||||
<td align="center"><a href="https://github.com/victoriasloan"><img src="https://avatars.githubusercontent.com/u/9913651?v=4?s=100" width="100px;" alt=""/><br /><sub><b>victoriasloan</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=victoriasloan" title="Code">💻</a></td>
|
<td align="center"><a href="https://github.com/victoriasloan"><img src="https://avatars.githubusercontent.com/u/9913651?v=4?s=100" width="100px;" alt=""/><br /><sub><b>victoriasloan</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=victoriasloan" title="Code">💻</a></td>
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
module.exports = {
|
||||||
|
user: require("./src/cache/user"),
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
module.exports = require("./src/security/sessions")
|
|
@ -0,0 +1,21 @@
|
||||||
|
const { getDB } = require("../db")
|
||||||
|
const { StaticDatabases } = require("../db/utils")
|
||||||
|
const redis = require("../redis/authRedis")
|
||||||
|
|
||||||
|
const EXPIRY_SECONDS = 3600
|
||||||
|
|
||||||
|
exports.getUser = async userId => {
|
||||||
|
const client = await redis.getUserClient()
|
||||||
|
// try cache
|
||||||
|
let user = await client.get(userId)
|
||||||
|
if (!user) {
|
||||||
|
user = await getDB(StaticDatabases.GLOBAL.name).get(userId)
|
||||||
|
client.store(userId, user, EXPIRY_SECONDS)
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.invalidateUser = async userId => {
|
||||||
|
const client = await redis.getUserClient()
|
||||||
|
await client.delete(userId)
|
||||||
|
}
|
|
@ -4,6 +4,7 @@ const JwtStrategy = require("passport-jwt").Strategy
|
||||||
const { StaticDatabases } = require("./db/utils")
|
const { StaticDatabases } = require("./db/utils")
|
||||||
const { jwt, local, authenticated, google, auditLog } = require("./middleware")
|
const { jwt, local, authenticated, google, auditLog } = require("./middleware")
|
||||||
const { setDB, getDB } = require("./db")
|
const { setDB, getDB } = require("./db")
|
||||||
|
const userCache = require("./cache/user")
|
||||||
|
|
||||||
// Strategies
|
// Strategies
|
||||||
passport.use(new LocalStrategy(local.options, local.authenticate))
|
passport.use(new LocalStrategy(local.options, local.authenticate))
|
||||||
|
@ -47,6 +48,9 @@ module.exports = {
|
||||||
jwt: require("jsonwebtoken"),
|
jwt: require("jsonwebtoken"),
|
||||||
auditLog,
|
auditLog,
|
||||||
},
|
},
|
||||||
|
cache: {
|
||||||
|
user: userCache,
|
||||||
|
},
|
||||||
StaticDatabases,
|
StaticDatabases,
|
||||||
constants: require("./constants"),
|
constants: require("./constants"),
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
const { Cookies } = require("../constants")
|
const { Cookies } = require("../constants")
|
||||||
const database = require("../db")
|
|
||||||
const { getCookie, clearCookie } = require("../utils")
|
const { getCookie, clearCookie } = require("../utils")
|
||||||
const { StaticDatabases } = require("../db/utils")
|
const { getUser } = require("../cache/user")
|
||||||
|
const { getSession, updateSessionTTL } = require("../security/sessions")
|
||||||
const env = require("../environment")
|
const env = require("../environment")
|
||||||
|
|
||||||
const PARAM_REGEX = /\/:(.*?)\//g
|
const PARAM_REGEX = /\/:(.*?)\//g
|
||||||
|
@ -48,14 +48,27 @@ module.exports = (noAuthPatterns = [], opts) => {
|
||||||
user = null,
|
user = null,
|
||||||
internal = false
|
internal = false
|
||||||
if (authCookie) {
|
if (authCookie) {
|
||||||
try {
|
let error = null
|
||||||
const db = database.getDB(StaticDatabases.GLOBAL.name)
|
const sessionId = authCookie.sessionId,
|
||||||
user = await db.get(authCookie.userId)
|
userId = authCookie.userId
|
||||||
delete user.password
|
const session = await getSession(userId, sessionId)
|
||||||
authenticated = true
|
if (!session) {
|
||||||
} catch (err) {
|
error = "No session found"
|
||||||
// remove the cookie as the use does not exist anymore
|
} else {
|
||||||
|
try {
|
||||||
|
user = await getUser(userId)
|
||||||
|
delete user.password
|
||||||
|
authenticated = true
|
||||||
|
} catch (err) {
|
||||||
|
error = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
// remove the cookie as the user does not exist anymore
|
||||||
clearCookie(ctx, Cookies.Auth)
|
clearCookie(ctx, Cookies.Auth)
|
||||||
|
} else {
|
||||||
|
// make sure we denote that the session is still in use
|
||||||
|
await updateSessionTTL(session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const apiKey = ctx.request.headers["x-budibase-api-key"]
|
const apiKey = ctx.request.headers["x-budibase-api-key"]
|
||||||
|
|
|
@ -7,6 +7,8 @@ const {
|
||||||
generateGlobalUserID,
|
generateGlobalUserID,
|
||||||
ViewNames,
|
ViewNames,
|
||||||
} = require("../../db/utils")
|
} = require("../../db/utils")
|
||||||
|
const { newid } = require("../../hashing")
|
||||||
|
const { createASession } = require("../../security/sessions")
|
||||||
|
|
||||||
async function authenticate(token, tokenSecret, profile, done) {
|
async function authenticate(token, tokenSecret, profile, done) {
|
||||||
// Check the user exists in the instance DB by email
|
// Check the user exists in the instance DB by email
|
||||||
|
@ -59,15 +61,16 @@ async function authenticate(token, tokenSecret, profile, done) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// authenticate
|
// authenticate
|
||||||
const payload = {
|
const sessionId = newid()
|
||||||
userId: dbUser._id,
|
await createASession(dbUser._id, sessionId)
|
||||||
builder: dbUser.builder,
|
|
||||||
email: dbUser.email,
|
|
||||||
}
|
|
||||||
|
|
||||||
dbUser.token = jwt.sign(payload, env.JWT_SECRET, {
|
dbUser.token = jwt.sign(
|
||||||
expiresIn: "1 day",
|
{
|
||||||
})
|
userId: dbUser._id,
|
||||||
|
sessionId,
|
||||||
|
},
|
||||||
|
env.JWT_SECRET
|
||||||
|
)
|
||||||
|
|
||||||
return done(null, dbUser)
|
return done(null, dbUser)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,8 @@ const { UserStatus } = require("../../constants")
|
||||||
const { compare } = require("../../hashing")
|
const { compare } = require("../../hashing")
|
||||||
const env = require("../../environment")
|
const env = require("../../environment")
|
||||||
const { getGlobalUserByEmail } = require("../../utils")
|
const { getGlobalUserByEmail } = require("../../utils")
|
||||||
|
const { newid } = require("../../hashing")
|
||||||
|
const { createASession } = require("../../security/sessions")
|
||||||
|
|
||||||
const INVALID_ERR = "Invalid Credentials"
|
const INVALID_ERR = "Invalid Credentials"
|
||||||
|
|
||||||
|
@ -31,13 +33,16 @@ exports.authenticate = async function (email, password, done) {
|
||||||
|
|
||||||
// authenticate
|
// authenticate
|
||||||
if (await compare(password, dbUser.password)) {
|
if (await compare(password, dbUser.password)) {
|
||||||
const payload = {
|
const sessionId = newid()
|
||||||
userId: dbUser._id,
|
await createASession(dbUser._id, sessionId)
|
||||||
}
|
|
||||||
|
|
||||||
dbUser.token = jwt.sign(payload, env.JWT_SECRET, {
|
dbUser.token = jwt.sign(
|
||||||
expiresIn: "1 day",
|
{
|
||||||
})
|
userId: dbUser._id,
|
||||||
|
sessionId,
|
||||||
|
},
|
||||||
|
env.JWT_SECRET
|
||||||
|
)
|
||||||
// Remove users password in payload
|
// Remove users password in payload
|
||||||
delete dbUser.password
|
delete dbUser.password
|
||||||
|
|
||||||
|
|
|
@ -22,11 +22,13 @@ const CONTENT_TYPE_MAP = {
|
||||||
html: "text/html",
|
html: "text/html",
|
||||||
css: "text/css",
|
css: "text/css",
|
||||||
js: "application/javascript",
|
js: "application/javascript",
|
||||||
|
json: "application/json",
|
||||||
}
|
}
|
||||||
const STRING_CONTENT_TYPES = [
|
const STRING_CONTENT_TYPES = [
|
||||||
CONTENT_TYPE_MAP.html,
|
CONTENT_TYPE_MAP.html,
|
||||||
CONTENT_TYPE_MAP.css,
|
CONTENT_TYPE_MAP.css,
|
||||||
CONTENT_TYPE_MAP.js,
|
CONTENT_TYPE_MAP.js,
|
||||||
|
CONTENT_TYPE_MAP.json,
|
||||||
]
|
]
|
||||||
|
|
||||||
// does normal sanitization and then swaps dev apps to apps
|
// does normal sanitization and then swaps dev apps to apps
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
const Client = require("./index")
|
||||||
|
const utils = require("./utils")
|
||||||
|
|
||||||
|
let userClient, sessionClient
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
userClient = await new Client(utils.Databases.USER_CACHE).init()
|
||||||
|
sessionClient = await new Client(utils.Databases.SESSIONS).init()
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("exit", async () => {
|
||||||
|
if (userClient) await userClient.finish()
|
||||||
|
if (sessionClient) await sessionClient.finish()
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getUserClient: async () => {
|
||||||
|
if (!userClient) {
|
||||||
|
await init()
|
||||||
|
}
|
||||||
|
return userClient
|
||||||
|
},
|
||||||
|
getSessionClient: async () => {
|
||||||
|
if (!sessionClient) {
|
||||||
|
await init()
|
||||||
|
}
|
||||||
|
return sessionClient
|
||||||
|
},
|
||||||
|
}
|
|
@ -1,7 +1,12 @@
|
||||||
const env = require("../environment")
|
const env = require("../environment")
|
||||||
// ioredis mock is all in memory
|
// ioredis mock is all in memory
|
||||||
const Redis = env.isTest() ? require("ioredis-mock") : require("ioredis")
|
const Redis = env.isTest() ? require("ioredis-mock") : require("ioredis")
|
||||||
const { addDbPrefix, removeDbPrefix, getRedisOptions } = require("./utils")
|
const {
|
||||||
|
addDbPrefix,
|
||||||
|
removeDbPrefix,
|
||||||
|
getRedisOptions,
|
||||||
|
SEPARATOR,
|
||||||
|
} = require("./utils")
|
||||||
|
|
||||||
const RETRY_PERIOD_MS = 2000
|
const RETRY_PERIOD_MS = 2000
|
||||||
const STARTUP_TIMEOUT_MS = 5000
|
const STARTUP_TIMEOUT_MS = 5000
|
||||||
|
@ -143,14 +148,15 @@ class RedisWrapper {
|
||||||
CLIENT.disconnect()
|
CLIENT.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
async scan() {
|
async scan(key = "") {
|
||||||
const db = this._db
|
const db = this._db
|
||||||
|
key = `${db}${SEPARATOR}${key}`
|
||||||
let stream
|
let stream
|
||||||
if (CLUSTERED) {
|
if (CLUSTERED) {
|
||||||
let node = CLIENT.nodes("master")
|
let node = CLIENT.nodes("master")
|
||||||
stream = node[0].scanStream({ match: db + "-*", count: 100 })
|
stream = node[0].scanStream({ match: key + "*", count: 100 })
|
||||||
} else {
|
} else {
|
||||||
stream = CLIENT.scanStream({ match: db + "-*", count: 100 })
|
stream = CLIENT.scanStream({ match: key + "*", count: 100 })
|
||||||
}
|
}
|
||||||
return promisifyStream(stream)
|
return promisifyStream(stream)
|
||||||
}
|
}
|
||||||
|
@ -182,6 +188,12 @@ class RedisWrapper {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setExpiry(key, expirySeconds) {
|
||||||
|
const db = this._db
|
||||||
|
const prefixedKey = addDbPrefix(db, key)
|
||||||
|
await CLIENT.expire(prefixedKey, expirySeconds)
|
||||||
|
}
|
||||||
|
|
||||||
async delete(key) {
|
async delete(key) {
|
||||||
const db = this._db
|
const db = this._db
|
||||||
await CLIENT.del(addDbPrefix(db, key))
|
await CLIENT.del(addDbPrefix(db, key))
|
||||||
|
|
|
@ -11,8 +11,12 @@ exports.Databases = {
|
||||||
INVITATIONS: "invitation",
|
INVITATIONS: "invitation",
|
||||||
DEV_LOCKS: "devLocks",
|
DEV_LOCKS: "devLocks",
|
||||||
DEBOUNCE: "debounce",
|
DEBOUNCE: "debounce",
|
||||||
|
SESSIONS: "session",
|
||||||
|
USER_CACHE: "users",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exports.SEPARATOR = SEPARATOR
|
||||||
|
|
||||||
exports.getRedisOptions = (clustered = false) => {
|
exports.getRedisOptions = (clustered = false) => {
|
||||||
const [host, port] = REDIS_URL.split(":")
|
const [host, port] = REDIS_URL.split(":")
|
||||||
const opts = {
|
const opts = {
|
||||||
|
|
|
@ -0,0 +1,69 @@
|
||||||
|
const redis = require("../redis/authRedis")
|
||||||
|
|
||||||
|
const EXPIRY_SECONDS = 86400
|
||||||
|
|
||||||
|
async function getSessionsForUser(userId) {
|
||||||
|
const client = await redis.getSessionClient()
|
||||||
|
const sessions = await client.scan(userId)
|
||||||
|
return sessions.map(session => session.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSessionID(userId, sessionId) {
|
||||||
|
return `${userId}/${sessionId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.createASession = async (userId, sessionId) => {
|
||||||
|
const client = await redis.getSessionClient()
|
||||||
|
const session = {
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastAccessedAt: new Date().toISOString(),
|
||||||
|
sessionId,
|
||||||
|
userId,
|
||||||
|
}
|
||||||
|
await client.store(makeSessionID(userId, sessionId), session, EXPIRY_SECONDS)
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.invalidateSessions = async (userId, sessionId = null) => {
|
||||||
|
let sessions = []
|
||||||
|
if (sessionId) {
|
||||||
|
sessions.push({ key: makeSessionID(userId, sessionId) })
|
||||||
|
} else {
|
||||||
|
sessions = await getSessionsForUser(userId)
|
||||||
|
}
|
||||||
|
const client = await redis.getSessionClient()
|
||||||
|
const promises = []
|
||||||
|
for (let session of sessions) {
|
||||||
|
promises.push(client.delete(session.key))
|
||||||
|
}
|
||||||
|
await Promise.all(promises)
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.updateSessionTTL = async session => {
|
||||||
|
const client = await redis.getSessionClient()
|
||||||
|
const key = makeSessionID(session.userId, session.sessionId)
|
||||||
|
session.lastAccessedAt = new Date().toISOString()
|
||||||
|
await client.store(key, session, EXPIRY_SECONDS)
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.endSession = async (userId, sessionId) => {
|
||||||
|
const client = await redis.getSessionClient()
|
||||||
|
await client.delete(makeSessionID(userId, sessionId))
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.getUserSessions = getSessionsForUser
|
||||||
|
|
||||||
|
exports.getSession = async (userId, sessionId) => {
|
||||||
|
try {
|
||||||
|
const client = await redis.getSessionClient()
|
||||||
|
return client.get(makeSessionID(userId, sessionId))
|
||||||
|
} catch (err) {
|
||||||
|
// if can't get session don't error, just don't return anything
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.getAllSessions = async () => {
|
||||||
|
const client = await redis.getSessionClient()
|
||||||
|
const sessions = await client.scan()
|
||||||
|
return sessions.map(session => session.value)
|
||||||
|
}
|
|
@ -64,23 +64,18 @@ exports.getCookie = (ctx, name) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store a cookie for the request, has a hardcoded expiry.
|
* Store a cookie for the request - it will not expire.
|
||||||
* @param {object} ctx The request which is to be manipulated.
|
* @param {object} ctx The request which is to be manipulated.
|
||||||
* @param {string} name The name of the cookie to set.
|
* @param {string} name The name of the cookie to set.
|
||||||
* @param {string|object} value The value of cookie which will be set.
|
* @param {string|object} value The value of cookie which will be set.
|
||||||
*/
|
*/
|
||||||
exports.setCookie = (ctx, value, name = "builder") => {
|
exports.setCookie = (ctx, value, name = "builder") => {
|
||||||
const expires = new Date()
|
|
||||||
expires.setDate(expires.getDate() + 1)
|
|
||||||
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
ctx.cookies.set(name)
|
ctx.cookies.set(name)
|
||||||
} else {
|
} else {
|
||||||
value = jwt.sign(value, options.secretOrKey, {
|
value = jwt.sign(value, options.secretOrKey)
|
||||||
expiresIn: "1 day",
|
|
||||||
})
|
|
||||||
ctx.cookies.set(name, value, {
|
ctx.cookies.set(name, value, {
|
||||||
expires,
|
maxAge: Number.MAX_SAFE_INTEGER,
|
||||||
path: "/",
|
path: "/",
|
||||||
httpOnly: false,
|
httpOnly: false,
|
||||||
overwrite: true,
|
overwrite: true,
|
||||||
|
|
|
@ -9,10 +9,10 @@
|
||||||
|
|
||||||
export let value
|
export let value
|
||||||
export let size = "M"
|
export let size = "M"
|
||||||
|
export let spectrumTheme
|
||||||
|
|
||||||
let open = false
|
let open = false
|
||||||
|
|
||||||
$: color = value || "transparent"
|
|
||||||
$: customValue = getCustomValue(value)
|
$: customValue = getCustomValue(value)
|
||||||
$: checkColor = getCheckColor(value)
|
$: checkColor = getCheckColor(value)
|
||||||
|
|
||||||
|
@ -21,7 +21,8 @@
|
||||||
{
|
{
|
||||||
label: "Grays",
|
label: "Grays",
|
||||||
colors: [
|
colors: [
|
||||||
"white",
|
"gray-50",
|
||||||
|
"gray-75",
|
||||||
"gray-100",
|
"gray-100",
|
||||||
"gray-200",
|
"gray-200",
|
||||||
"gray-300",
|
"gray-300",
|
||||||
|
@ -31,7 +32,6 @@
|
||||||
"gray-700",
|
"gray-700",
|
||||||
"gray-800",
|
"gray-800",
|
||||||
"gray-900",
|
"gray-900",
|
||||||
"black",
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -86,7 +86,7 @@
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
let found = false
|
let found = false
|
||||||
const comparisonValue = value.substring(35, value.length - 1)
|
const comparisonValue = value.substring(28, value.length - 1)
|
||||||
for (let category of categories) {
|
for (let category of categories) {
|
||||||
found = category.colors.includes(comparisonValue)
|
found = category.colors.includes(comparisonValue)
|
||||||
if (found) {
|
if (found) {
|
||||||
|
@ -102,17 +102,19 @@
|
||||||
|
|
||||||
const getCheckColor = value => {
|
const getCheckColor = value => {
|
||||||
return /^.*(white|(gray-(50|75|100|200|300|400|500)))\)$/.test(value)
|
return /^.*(white|(gray-(50|75|100|200|300|400|500)))\)$/.test(value)
|
||||||
? "black"
|
? "var(--spectrum-global-color-gray-900)"
|
||||||
: "white"
|
: "var(--spectrum-global-color-gray-50)"
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div
|
<div class="preview size--{size || 'M'}" on:click={() => (open = true)}>
|
||||||
class="preview size--{size || 'M'}"
|
<div
|
||||||
style="background: {color};"
|
class="fill {spectrumTheme || ''}"
|
||||||
on:click={() => (open = true)}
|
style={value ? `background: ${value};` : ""}
|
||||||
/>
|
class:placeholder={!value}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{#if open}
|
{#if open}
|
||||||
<div
|
<div
|
||||||
use:clickOutside={() => (open = false)}
|
use:clickOutside={() => (open = false)}
|
||||||
|
@ -126,15 +128,19 @@
|
||||||
{#each category.colors as color}
|
{#each category.colors as color}
|
||||||
<div
|
<div
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
onChange(`var(--spectrum-global-color-static-${color})`)
|
onChange(`var(--spectrum-global-color-${color})`)
|
||||||
}}
|
}}
|
||||||
class="color"
|
class="color"
|
||||||
style="background: var(--spectrum-global-color-static-{color}); color: {checkColor};"
|
|
||||||
title={prettyPrint(color)}
|
title={prettyPrint(color)}
|
||||||
>
|
>
|
||||||
{#if value === `var(--spectrum-global-color-static-${color})`}
|
<div
|
||||||
<Icon name="Checkmark" size="S" />
|
class="fill {spectrumTheme || ''}"
|
||||||
{/if}
|
style="background: var(--spectrum-global-color-{color}); color: {checkColor};"
|
||||||
|
>
|
||||||
|
{#if value === `var(--spectrum-global-color-${color})`}
|
||||||
|
<Icon name="Checkmark" size="S" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
@ -170,12 +176,43 @@
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
|
position: relative;
|
||||||
transition: border-color 130ms ease-in-out;
|
transition: border-color 130ms ease-in-out;
|
||||||
box-shadow: 0 0 0 1px var(--spectrum-global-color-gray-300);
|
box-shadow: 0 0 0 1px var(--spectrum-global-color-gray-400);
|
||||||
}
|
}
|
||||||
.preview:hover {
|
.preview:hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 0 2px 2px var(--spectrum-global-color-gray-300);
|
box-shadow: 0 0 2px 2px var(--spectrum-global-color-gray-400);
|
||||||
|
}
|
||||||
|
.fill {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 100%;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.fill.placeholder {
|
||||||
|
background-position: 0 0, 10px 10px;
|
||||||
|
background-size: 20px 20px;
|
||||||
|
background-image: linear-gradient(
|
||||||
|
45deg,
|
||||||
|
#eee 25%,
|
||||||
|
transparent 25%,
|
||||||
|
transparent 75%,
|
||||||
|
#eee 75%,
|
||||||
|
#eee 100%
|
||||||
|
),
|
||||||
|
linear-gradient(
|
||||||
|
45deg,
|
||||||
|
#eee 25%,
|
||||||
|
white 25%,
|
||||||
|
white 75%,
|
||||||
|
#eee 75%,
|
||||||
|
#eee 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
.size--S {
|
.size--S {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
|
@ -219,8 +256,7 @@
|
||||||
width: 16px;
|
width: 16px;
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
box-shadow: 0 0 0 1px var(--spectrum-global-color-gray-300);
|
box-shadow: 0 0 0 1px var(--spectrum-global-color-gray-300);
|
||||||
display: grid;
|
position: relative;
|
||||||
place-items: center;
|
|
||||||
}
|
}
|
||||||
.color:hover {
|
.color:hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
@ -236,4 +272,8 @@
|
||||||
.category--custom .heading {
|
.category--custom .heading {
|
||||||
margin-bottom: var(--spacing-xs);
|
margin-bottom: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.spectrum-wrapper {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -145,4 +145,7 @@
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
}
|
}
|
||||||
|
:global(.flatpickr-calendar) {
|
||||||
|
font-family: "Source Sans Pro", sans-serif;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -29,7 +29,7 @@ context("Create Bindings", () => {
|
||||||
// The builder preview pages don't have a real URL, so all we can do
|
// The builder preview pages don't have a real URL, so all we can do
|
||||||
// is check that we were able to bind to the property, and that the
|
// is check that we were able to bind to the property, and that the
|
||||||
// component exists on the page
|
// component exists on the page
|
||||||
cy.getComponent(componentId).should("have.text", "Placeholder text")
|
cy.getComponent(componentId).should("have.text", "New Paragraph")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,6 @@ import {
|
||||||
selectedComponent,
|
selectedComponent,
|
||||||
selectedAccessRole,
|
selectedAccessRole,
|
||||||
} from "builderStore"
|
} from "builderStore"
|
||||||
// Backendstores
|
|
||||||
import {
|
import {
|
||||||
datasources,
|
datasources,
|
||||||
integrations,
|
integrations,
|
||||||
|
@ -33,6 +32,10 @@ const INITIAL_FRONTEND_STATE = {
|
||||||
layouts: [],
|
layouts: [],
|
||||||
screens: [],
|
screens: [],
|
||||||
components: [],
|
components: [],
|
||||||
|
clientFeatures: {
|
||||||
|
spectrumThemes: false,
|
||||||
|
intelligentLoading: false,
|
||||||
|
},
|
||||||
currentFrontEndType: "none",
|
currentFrontEndType: "none",
|
||||||
selectedScreenId: "",
|
selectedScreenId: "",
|
||||||
selectedLayoutId: "",
|
selectedLayoutId: "",
|
||||||
|
@ -43,6 +46,7 @@ const INITIAL_FRONTEND_STATE = {
|
||||||
appId: "",
|
appId: "",
|
||||||
routes: {},
|
routes: {},
|
||||||
clientLibPath: "",
|
clientLibPath: "",
|
||||||
|
theme: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getFrontendStore = () => {
|
export const getFrontendStore = () => {
|
||||||
|
@ -56,16 +60,23 @@ export const getFrontendStore = () => {
|
||||||
...state,
|
...state,
|
||||||
libraries: application.componentLibraries,
|
libraries: application.componentLibraries,
|
||||||
components,
|
components,
|
||||||
|
clientFeatures: {
|
||||||
|
...state.clientFeatures,
|
||||||
|
...components.features,
|
||||||
|
},
|
||||||
name: application.name,
|
name: application.name,
|
||||||
description: application.description,
|
description: application.description,
|
||||||
appId: application.appId,
|
appId: application.appId,
|
||||||
url: application.url,
|
url: application.url,
|
||||||
layouts,
|
layouts,
|
||||||
screens,
|
screens,
|
||||||
|
theme: application.theme,
|
||||||
hasAppPackage: true,
|
hasAppPackage: true,
|
||||||
appInstance: application.instance,
|
appInstance: application.instance,
|
||||||
clientLibPath,
|
clientLibPath,
|
||||||
previousTopNavPath: {},
|
previousTopNavPath: {},
|
||||||
|
version: application.version,
|
||||||
|
revertableVersion: application.revertableVersion,
|
||||||
}))
|
}))
|
||||||
await hostingStore.actions.fetch()
|
await hostingStore.actions.fetch()
|
||||||
|
|
||||||
|
@ -79,6 +90,20 @@ export const getFrontendStore = () => {
|
||||||
database.set(application.instance)
|
database.set(application.instance)
|
||||||
tables.init()
|
tables.init()
|
||||||
},
|
},
|
||||||
|
theme: {
|
||||||
|
save: async theme => {
|
||||||
|
const appId = get(store).appId
|
||||||
|
const response = await api.put(`/api/applications/${appId}`, { theme })
|
||||||
|
if (response.status === 200) {
|
||||||
|
store.update(state => {
|
||||||
|
state.theme = theme
|
||||||
|
return state
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
throw new Error("Error updating theme")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
routing: {
|
routing: {
|
||||||
fetch: async () => {
|
fetch: async () => {
|
||||||
const response = await api.get("/api/routing")
|
const response = await api.get("/api/routing")
|
||||||
|
@ -199,6 +224,11 @@ export const getFrontendStore = () => {
|
||||||
const response = await api.post(`/api/layouts`, layoutToSave)
|
const response = await api.post(`/api/layouts`, layoutToSave)
|
||||||
const savedLayout = await response.json()
|
const savedLayout = await response.json()
|
||||||
|
|
||||||
|
// Abort if saving failed
|
||||||
|
if (response.status !== 200) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
store.update(state => {
|
store.update(state => {
|
||||||
const layoutIdx = state.layouts.findIndex(
|
const layoutIdx = state.layouts.findIndex(
|
||||||
stateLayout => stateLayout._id === savedLayout._id
|
stateLayout => stateLayout._id === savedLayout._id
|
||||||
|
@ -316,16 +346,6 @@ export const getFrontendStore = () => {
|
||||||
create: async (componentName, presetProps) => {
|
create: async (componentName, presetProps) => {
|
||||||
const selected = get(selectedComponent)
|
const selected = get(selectedComponent)
|
||||||
const asset = get(currentAsset)
|
const asset = get(currentAsset)
|
||||||
const state = get(store)
|
|
||||||
|
|
||||||
// Only allow one screen slot, and in the layout
|
|
||||||
if (componentName.endsWith("screenslot")) {
|
|
||||||
const isLayout = state.currentFrontEndType === FrontendTypes.LAYOUT
|
|
||||||
const slot = findComponentType(asset.props, componentName)
|
|
||||||
if (!isLayout || slot != null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new component
|
// Create new component
|
||||||
const componentInstance = store.actions.components.createInstance(
|
const componentInstance = store.actions.components.createInstance(
|
||||||
|
|
|
@ -38,8 +38,6 @@ const createScreen = table => {
|
||||||
.instanceName("Form")
|
.instanceName("Form")
|
||||||
.customProps({
|
.customProps({
|
||||||
actionType: "Create",
|
actionType: "Create",
|
||||||
theme: "spectrum--lightest",
|
|
||||||
size: "spectrum--medium",
|
|
||||||
dataSource: {
|
dataSource: {
|
||||||
label: table.name,
|
label: table.name,
|
||||||
tableId: table._id,
|
tableId: table._id,
|
||||||
|
|
|
@ -8,7 +8,6 @@ import {
|
||||||
makeTitleContainer,
|
makeTitleContainer,
|
||||||
makeSaveButton,
|
makeSaveButton,
|
||||||
makeMainForm,
|
makeMainForm,
|
||||||
spectrumColor,
|
|
||||||
makeDatasourceFormComponents,
|
makeDatasourceFormComponents,
|
||||||
} from "./utils/commonComponents"
|
} from "./utils/commonComponents"
|
||||||
|
|
||||||
|
@ -26,36 +25,13 @@ export const ROW_DETAIL_TEMPLATE = "ROW_DETAIL_TEMPLATE"
|
||||||
export const rowDetailUrl = table => sanitizeUrl(`/${table.name}/:id`)
|
export const rowDetailUrl = table => sanitizeUrl(`/${table.name}/:id`)
|
||||||
|
|
||||||
function generateTitleContainer(table, title, formId, repeaterId) {
|
function generateTitleContainer(table, title, formId, repeaterId) {
|
||||||
// have to override style for this, its missing margin
|
const saveButton = makeSaveButton(table, formId)
|
||||||
const saveButton = makeSaveButton(table, formId).normalStyle({
|
|
||||||
background: "#000000",
|
|
||||||
"border-width": "0",
|
|
||||||
"border-style": "None",
|
|
||||||
color: "#fff",
|
|
||||||
"font-weight": "600",
|
|
||||||
"font-size": "14px",
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleteButton = new Component("@budibase/standard-components/button")
|
const deleteButton = new Component("@budibase/standard-components/button")
|
||||||
.normalStyle({
|
|
||||||
background: "transparent",
|
|
||||||
"border-width": "0",
|
|
||||||
"border-style": "None",
|
|
||||||
color: "#9e9e9e",
|
|
||||||
"font-weight": "600",
|
|
||||||
"font-size": "14px",
|
|
||||||
"margin-right": "8px",
|
|
||||||
"margin-left": "16px",
|
|
||||||
})
|
|
||||||
.hoverStyle({
|
|
||||||
background: "transparent",
|
|
||||||
color: "#4285f4",
|
|
||||||
})
|
|
||||||
.customStyle(spectrumColor(700))
|
|
||||||
.text("Delete")
|
.text("Delete")
|
||||||
.customProps({
|
.customProps({
|
||||||
className: "",
|
type: "secondary",
|
||||||
disabled: false,
|
quiet: true,
|
||||||
|
size: "M",
|
||||||
onClick: [
|
onClick: [
|
||||||
{
|
{
|
||||||
parameters: {
|
parameters: {
|
||||||
|
@ -76,7 +52,19 @@ function generateTitleContainer(table, title, formId, repeaterId) {
|
||||||
})
|
})
|
||||||
.instanceName("Delete Button")
|
.instanceName("Delete Button")
|
||||||
|
|
||||||
return makeTitleContainer(title).addChild(deleteButton).addChild(saveButton)
|
const buttons = new Component("@budibase/standard-components/container")
|
||||||
|
.instanceName("Button Container")
|
||||||
|
.customProps({
|
||||||
|
direction: "row",
|
||||||
|
hAlign: "right",
|
||||||
|
vAlign: "middle",
|
||||||
|
size: "shrink",
|
||||||
|
gap: "M",
|
||||||
|
})
|
||||||
|
.addChild(deleteButton)
|
||||||
|
.addChild(saveButton)
|
||||||
|
|
||||||
|
return makeTitleContainer(title).addChild(buttons)
|
||||||
}
|
}
|
||||||
|
|
||||||
const createScreen = table => {
|
const createScreen = table => {
|
||||||
|
@ -98,7 +86,7 @@ const createScreen = table => {
|
||||||
valueType: "Binding",
|
valueType: "Binding",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
limit: 1,
|
limit: table.type === "external" ? undefined : 1,
|
||||||
paginate: false,
|
paginate: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -19,21 +19,10 @@ export const rowListUrl = table => sanitizeUrl(`/${table.name}`)
|
||||||
|
|
||||||
function generateTitleContainer(table) {
|
function generateTitleContainer(table) {
|
||||||
const newButton = new Component("@budibase/standard-components/button")
|
const newButton = new Component("@budibase/standard-components/button")
|
||||||
.normalStyle({
|
|
||||||
background: "#000000",
|
|
||||||
"border-width": "0",
|
|
||||||
"border-style": "None",
|
|
||||||
color: "#fff",
|
|
||||||
"font-weight": "600",
|
|
||||||
"font-size": "14px",
|
|
||||||
})
|
|
||||||
.hoverStyle({
|
|
||||||
background: "#4285f4",
|
|
||||||
})
|
|
||||||
.text("Create New")
|
.text("Create New")
|
||||||
.customProps({
|
.customProps({
|
||||||
className: "",
|
size: "M",
|
||||||
disabled: false,
|
type: "primary",
|
||||||
onClick: [
|
onClick: [
|
||||||
{
|
{
|
||||||
parameters: {
|
parameters: {
|
||||||
|
@ -46,12 +35,6 @@ function generateTitleContainer(table) {
|
||||||
.instanceName("New Button")
|
.instanceName("New Button")
|
||||||
|
|
||||||
const heading = new Component("@budibase/standard-components/heading")
|
const heading = new Component("@budibase/standard-components/heading")
|
||||||
.normalStyle({
|
|
||||||
margin: "0px",
|
|
||||||
flex: "1 1 auto",
|
|
||||||
"text-transform": "capitalize",
|
|
||||||
})
|
|
||||||
.type("h2")
|
|
||||||
.instanceName("Title")
|
.instanceName("Title")
|
||||||
.text(table.name)
|
.text(table.name)
|
||||||
.customProps({
|
.customProps({
|
||||||
|
@ -60,14 +43,12 @@ function generateTitleContainer(table) {
|
||||||
})
|
})
|
||||||
|
|
||||||
return new Component("@budibase/standard-components/container")
|
return new Component("@budibase/standard-components/container")
|
||||||
.normalStyle({
|
|
||||||
"margin-bottom": "32px",
|
|
||||||
})
|
|
||||||
.customProps({
|
.customProps({
|
||||||
direction: "row",
|
direction: "row",
|
||||||
hAlign: "stretch",
|
hAlign: "stretch",
|
||||||
vAlign: "middle",
|
vAlign: "middle",
|
||||||
size: "shrink",
|
size: "shrink",
|
||||||
|
gap: "M",
|
||||||
})
|
})
|
||||||
.instanceName("Title Container")
|
.instanceName("Title Container")
|
||||||
.addChild(heading)
|
.addChild(heading)
|
||||||
|
@ -91,68 +72,35 @@ const createScreen = table => {
|
||||||
const spectrumTable = new Component("@budibase/standard-components/table")
|
const spectrumTable = new Component("@budibase/standard-components/table")
|
||||||
.customProps({
|
.customProps({
|
||||||
dataProvider: `{{ literal ${makePropSafe(provider._json._id)} }}`,
|
dataProvider: `{{ literal ${makePropSafe(provider._json._id)} }}`,
|
||||||
theme: "spectrum--lightest",
|
|
||||||
showAutoColumns: false,
|
showAutoColumns: false,
|
||||||
quiet: true,
|
quiet: false,
|
||||||
size: "spectrum--medium",
|
|
||||||
rowCount: 8,
|
rowCount: 8,
|
||||||
})
|
})
|
||||||
.instanceName(`${table.name} Table`)
|
.instanceName(`${table.name} Table`)
|
||||||
|
|
||||||
const safeTableId = makePropSafe(spectrumTable._json._id)
|
const safeTableId = makePropSafe(spectrumTable._json._id)
|
||||||
const safeRowId = makePropSafe("_id")
|
const safeRowId = makePropSafe("_id")
|
||||||
const viewButton = new Component("@budibase/standard-components/button")
|
const viewLink = new Component("@budibase/standard-components/link")
|
||||||
.customProps({
|
.customProps({
|
||||||
text: "View",
|
text: "View",
|
||||||
onClick: [
|
url: `${rowListUrl(table)}/{{ ${safeTableId}.${safeRowId} }}`,
|
||||||
{
|
size: "S",
|
||||||
"##eventHandlerType": "Navigate To",
|
color: "var(--spectrum-global-color-gray-600)",
|
||||||
parameters: {
|
align: "left",
|
||||||
url: `${rowListUrl(table)}/{{ ${safeTableId}.${safeRowId} }}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
.instanceName("View Button")
|
|
||||||
.normalStyle({
|
.normalStyle({
|
||||||
background: "transparent",
|
["margin-left"]: "16px",
|
||||||
"font-weight": "600",
|
["margin-right"]: "16px",
|
||||||
color: "#888",
|
|
||||||
"border-width": "0",
|
|
||||||
})
|
|
||||||
.hoverStyle({
|
|
||||||
color: "#4285f4",
|
|
||||||
})
|
})
|
||||||
|
.instanceName("View Link")
|
||||||
|
|
||||||
spectrumTable.addChild(viewButton)
|
spectrumTable.addChild(viewLink)
|
||||||
provider.addChild(spectrumTable)
|
provider.addChild(spectrumTable)
|
||||||
|
|
||||||
const mainContainer = new Component("@budibase/standard-components/container")
|
|
||||||
.normalStyle({
|
|
||||||
background: "white",
|
|
||||||
"border-radius": "0.5rem",
|
|
||||||
"box-shadow": "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
|
||||||
"border-width": "2px",
|
|
||||||
"border-color": "rgba(0, 0, 0, 0.1)",
|
|
||||||
"border-style": "None",
|
|
||||||
"padding-top": "48px",
|
|
||||||
"padding-bottom": "48px",
|
|
||||||
"padding-right": "48px",
|
|
||||||
"padding-left": "48px",
|
|
||||||
})
|
|
||||||
.customProps({
|
|
||||||
direction: "column",
|
|
||||||
hAlign: "stretch",
|
|
||||||
vAlign: "top",
|
|
||||||
size: "shrink",
|
|
||||||
})
|
|
||||||
.instanceName("Container")
|
|
||||||
.addChild(generateTitleContainer(table))
|
|
||||||
.addChild(provider)
|
|
||||||
|
|
||||||
return new Screen()
|
return new Screen()
|
||||||
.route(rowListUrl(table))
|
.route(rowListUrl(table))
|
||||||
.instanceName(`${table.name} - List`)
|
.instanceName(`${table.name} - List`)
|
||||||
.addChild(mainContainer)
|
.addChild(generateTitleContainer(table))
|
||||||
|
.addChild(provider)
|
||||||
.json()
|
.json()
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,23 +8,16 @@ export function spectrumColor(number) {
|
||||||
// God knows why. It seems to think optional chaining further down the
|
// God knows why. It seems to think optional chaining further down the
|
||||||
// file is invalid if the word g-l-o-b-a-l is found - hence the reason this
|
// file is invalid if the word g-l-o-b-a-l is found - hence the reason this
|
||||||
// statement is split into parts.
|
// statement is split into parts.
|
||||||
return "color: var(--spectrum-glo" + `bal-color-gray-${number});`
|
return "var(--spectrum-glo" + `bal-color-gray-${number})`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeLinkComponent(tableName) {
|
export function makeLinkComponent(tableName) {
|
||||||
return new Component("@budibase/standard-components/link")
|
return new Component("@budibase/standard-components/link")
|
||||||
.normalStyle({
|
|
||||||
color: "#757575",
|
|
||||||
"text-transform": "capitalize",
|
|
||||||
})
|
|
||||||
.hoverStyle({
|
|
||||||
color: "#4285f4",
|
|
||||||
})
|
|
||||||
.customStyle(spectrumColor(700))
|
|
||||||
.text(tableName)
|
.text(tableName)
|
||||||
.customProps({
|
.customProps({
|
||||||
url: `/${tableName.toLowerCase()}`,
|
url: `/${tableName.toLowerCase()}`,
|
||||||
openInNewTab: false,
|
openInNewTab: false,
|
||||||
|
color: spectrumColor(700),
|
||||||
size: "S",
|
size: "S",
|
||||||
align: "left",
|
align: "left",
|
||||||
})
|
})
|
||||||
|
@ -33,19 +26,12 @@ export function makeLinkComponent(tableName) {
|
||||||
export function makeMainForm() {
|
export function makeMainForm() {
|
||||||
return new Component("@budibase/standard-components/form")
|
return new Component("@budibase/standard-components/form")
|
||||||
.normalStyle({
|
.normalStyle({
|
||||||
width: "700px",
|
width: "600px",
|
||||||
padding: "0px",
|
|
||||||
"border-radius": "0.5rem",
|
|
||||||
"box-shadow": "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
|
||||||
"padding-top": "48px",
|
|
||||||
"padding-bottom": "48px",
|
|
||||||
"padding-right": "48px",
|
|
||||||
"padding-left": "48px",
|
|
||||||
})
|
})
|
||||||
.instanceName("Form")
|
.instanceName("Form")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeBreadcrumbContainer(tableName, text, capitalise = false) {
|
export function makeBreadcrumbContainer(tableName, text) {
|
||||||
const link = makeLinkComponent(tableName).instanceName("Back Link")
|
const link = makeLinkComponent(tableName).instanceName("Back Link")
|
||||||
|
|
||||||
const arrowText = new Component("@budibase/standard-components/text")
|
const arrowText = new Component("@budibase/standard-components/text")
|
||||||
|
@ -53,42 +39,27 @@ export function makeBreadcrumbContainer(tableName, text, capitalise = false) {
|
||||||
.normalStyle({
|
.normalStyle({
|
||||||
"margin-right": "4px",
|
"margin-right": "4px",
|
||||||
"margin-left": "4px",
|
"margin-left": "4px",
|
||||||
"margin-top": "0px",
|
|
||||||
"margin-bottom": "0px",
|
|
||||||
})
|
})
|
||||||
.customStyle(spectrumColor(700))
|
|
||||||
.text(">")
|
.text(">")
|
||||||
.instanceName("Arrow")
|
.instanceName("Arrow")
|
||||||
.customProps({
|
.customProps({
|
||||||
|
color: spectrumColor(700),
|
||||||
size: "S",
|
size: "S",
|
||||||
align: "left",
|
align: "left",
|
||||||
})
|
})
|
||||||
|
|
||||||
const textStyling = {
|
|
||||||
color: "#000000",
|
|
||||||
"margin-top": "0px",
|
|
||||||
"margin-bottom": "0px",
|
|
||||||
}
|
|
||||||
if (capitalise) {
|
|
||||||
textStyling["text-transform"] = "capitalize"
|
|
||||||
}
|
|
||||||
const identifierText = new Component("@budibase/standard-components/text")
|
const identifierText = new Component("@budibase/standard-components/text")
|
||||||
.type("none")
|
|
||||||
.normalStyle(textStyling)
|
|
||||||
.customStyle(spectrumColor(700))
|
|
||||||
.text(text)
|
.text(text)
|
||||||
.instanceName("Identifier")
|
.instanceName("Identifier")
|
||||||
.customProps({
|
.customProps({
|
||||||
|
color: spectrumColor(700),
|
||||||
size: "S",
|
size: "S",
|
||||||
align: "left",
|
align: "left",
|
||||||
})
|
})
|
||||||
|
|
||||||
return new Component("@budibase/standard-components/container")
|
return new Component("@budibase/standard-components/container")
|
||||||
.normalStyle({
|
|
||||||
"font-size": "14px",
|
|
||||||
color: "#757575",
|
|
||||||
})
|
|
||||||
.customProps({
|
.customProps({
|
||||||
|
gap: "N",
|
||||||
direction: "row",
|
direction: "row",
|
||||||
hAlign: "left",
|
hAlign: "left",
|
||||||
vAlign: "middle",
|
vAlign: "middle",
|
||||||
|
@ -102,22 +73,10 @@ export function makeBreadcrumbContainer(tableName, text, capitalise = false) {
|
||||||
|
|
||||||
export function makeSaveButton(table, formId) {
|
export function makeSaveButton(table, formId) {
|
||||||
return new Component("@budibase/standard-components/button")
|
return new Component("@budibase/standard-components/button")
|
||||||
.normalStyle({
|
|
||||||
background: "#000000",
|
|
||||||
"border-width": "0",
|
|
||||||
"border-style": "None",
|
|
||||||
color: "#fff",
|
|
||||||
"font-weight": "600",
|
|
||||||
"font-size": "14px",
|
|
||||||
"margin-left": "16px",
|
|
||||||
})
|
|
||||||
.hoverStyle({
|
|
||||||
background: "#4285f4",
|
|
||||||
})
|
|
||||||
.text("Save")
|
.text("Save")
|
||||||
.customProps({
|
.customProps({
|
||||||
className: "",
|
type: "primary",
|
||||||
disabled: false,
|
size: "M",
|
||||||
onClick: [
|
onClick: [
|
||||||
{
|
{
|
||||||
"##eventHandlerType": "Validate Form",
|
"##eventHandlerType": "Validate Form",
|
||||||
|
@ -145,12 +104,6 @@ export function makeSaveButton(table, formId) {
|
||||||
|
|
||||||
export function makeTitleContainer(title) {
|
export function makeTitleContainer(title) {
|
||||||
const heading = new Component("@budibase/standard-components/heading")
|
const heading = new Component("@budibase/standard-components/heading")
|
||||||
.normalStyle({
|
|
||||||
margin: "0px",
|
|
||||||
flex: "1 1 auto",
|
|
||||||
})
|
|
||||||
.customStyle(spectrumColor(900))
|
|
||||||
.type("h2")
|
|
||||||
.instanceName("Title")
|
.instanceName("Title")
|
||||||
.text(title)
|
.text(title)
|
||||||
.customProps({
|
.customProps({
|
||||||
|
@ -168,6 +121,7 @@ export function makeTitleContainer(title) {
|
||||||
hAlign: "stretch",
|
hAlign: "stretch",
|
||||||
vAlign: "middle",
|
vAlign: "middle",
|
||||||
size: "shrink",
|
size: "shrink",
|
||||||
|
gap: "M",
|
||||||
})
|
})
|
||||||
.instanceName("Title Container")
|
.instanceName("Title Container")
|
||||||
.addChild(heading)
|
.addChild(heading)
|
||||||
|
|
|
@ -24,7 +24,7 @@
|
||||||
name: $views.selected?.name,
|
name: $views.selected?.name,
|
||||||
}
|
}
|
||||||
$: type = $tables.selected?.type
|
$: type = $tables.selected?.type
|
||||||
$: isInternal = type === "internal"
|
$: isInternal = type !== "external"
|
||||||
|
|
||||||
// Fetch rows for specified table
|
// Fetch rows for specified table
|
||||||
$: {
|
$: {
|
||||||
|
@ -72,9 +72,7 @@
|
||||||
{#if isUsersTable}
|
{#if isUsersTable}
|
||||||
<EditRolesButton />
|
<EditRolesButton />
|
||||||
{/if}
|
{/if}
|
||||||
{#if isInternal}
|
<HideAutocolumnButton bind:hideAutocolumns />
|
||||||
<HideAutocolumnButton bind:hideAutocolumns />
|
|
||||||
{/if}
|
|
||||||
<!-- always have the export last -->
|
<!-- always have the export last -->
|
||||||
<ExportButton view={$tables.selected?._id} />
|
<ExportButton view={$tables.selected?._id} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -53,7 +53,7 @@
|
||||||
let deletion
|
let deletion
|
||||||
|
|
||||||
$: tableOptions = $tables.list.filter(
|
$: tableOptions = $tables.list.filter(
|
||||||
table => table._id !== $tables.draft._id
|
table => table._id !== $tables.draft._id && table.type !== "external"
|
||||||
)
|
)
|
||||||
$: required = !!field?.constraints?.presence || primaryDisplay
|
$: required = !!field?.constraints?.presence || primaryDisplay
|
||||||
$: uneditable =
|
$: uneditable =
|
||||||
|
@ -172,11 +172,6 @@
|
||||||
alt: `Many ${table.name} rows → many ${linkTable.name} rows`,
|
alt: `Many ${table.name} rows → many ${linkTable.name} rows`,
|
||||||
value: RelationshipTypes.MANY_TO_MANY,
|
value: RelationshipTypes.MANY_TO_MANY,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: `One ${linkName} row → many ${thisName} rows`,
|
|
||||||
alt: `One ${linkTable.name} rows → many ${table.name} rows`,
|
|
||||||
value: RelationshipTypes.ONE_TO_MANY,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: `One ${thisName} row → many ${linkName} rows`,
|
name: `One ${thisName} row → many ${linkName} rows`,
|
||||||
alt: `One ${table.name} rows → many ${linkTable.name} rows`,
|
alt: `One ${table.name} rows → many ${linkTable.name} rows`,
|
||||||
|
|
|
@ -5,14 +5,17 @@
|
||||||
import ICONS from "../icons"
|
import ICONS from "../icons"
|
||||||
|
|
||||||
export let integration = {}
|
export let integration = {}
|
||||||
|
|
||||||
let integrations = []
|
let integrations = []
|
||||||
|
const INTERNAL = "BUDIBASE"
|
||||||
|
|
||||||
async function fetchIntegrations() {
|
async function fetchIntegrations() {
|
||||||
const response = await api.get("/api/integrations")
|
const response = await api.get("/api/integrations")
|
||||||
const json = await response.json()
|
const json = await response.json()
|
||||||
|
|
||||||
integrations = json
|
integrations = {
|
||||||
|
[INTERNAL]: { datasource: {}, name: "INTERNAL/CSV" },
|
||||||
|
...json,
|
||||||
|
}
|
||||||
return json
|
return json
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -21,7 +24,7 @@
|
||||||
|
|
||||||
// build the schema
|
// build the schema
|
||||||
const schema = {}
|
const schema = {}
|
||||||
for (let key in selected.datasource) {
|
for (let key of Object.keys(selected.datasource)) {
|
||||||
schema[key] = selected.datasource[key].default
|
schema[key] = selected.datasource[key].default
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -39,7 +42,7 @@
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="integration-list">
|
<div class="integration-list">
|
||||||
{#each Object.keys(integrations) as integrationType}
|
{#each Object.entries(integrations) as [integrationType, schema]}
|
||||||
<div
|
<div
|
||||||
class="integration hoverable"
|
class="integration hoverable"
|
||||||
class:selected={integration.type === integrationType}
|
class:selected={integration.type === integrationType}
|
||||||
|
@ -50,7 +53,7 @@
|
||||||
height="50"
|
height="50"
|
||||||
width="50"
|
width="50"
|
||||||
/>
|
/>
|
||||||
<Body size="XS">{integrationType}</Body>
|
<Body size="XS">{schema.name || integrationType}</Body>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,15 +2,21 @@
|
||||||
import { goto } from "@roxi/routify"
|
import { goto } from "@roxi/routify"
|
||||||
import { datasources } from "stores/backend"
|
import { datasources } from "stores/backend"
|
||||||
import { notifications } from "@budibase/bbui"
|
import { notifications } from "@budibase/bbui"
|
||||||
import { Input, Label, ModalContent } from "@budibase/bbui"
|
import { Input, Label, ModalContent, Modal, Context } from "@budibase/bbui"
|
||||||
import TableIntegrationMenu from "../TableIntegrationMenu/index.svelte"
|
import TableIntegrationMenu from "../TableIntegrationMenu/index.svelte"
|
||||||
|
import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte"
|
||||||
import analytics from "analytics"
|
import analytics from "analytics"
|
||||||
|
import { getContext } from "svelte"
|
||||||
|
|
||||||
let error = ""
|
const modalContext = getContext(Context.Modal)
|
||||||
|
|
||||||
|
let tableModal
|
||||||
let name
|
let name
|
||||||
|
let error = ""
|
||||||
let integration
|
let integration
|
||||||
|
|
||||||
|
$: checkOpenModal(integration && integration.type === "BUDIBASE")
|
||||||
|
|
||||||
function checkValid(evt) {
|
function checkValid(evt) {
|
||||||
const datasourceName = evt.target.value
|
const datasourceName = evt.target.value
|
||||||
if (
|
if (
|
||||||
|
@ -22,6 +28,12 @@
|
||||||
error = ""
|
error = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkOpenModal(isInternal) {
|
||||||
|
if (isInternal) {
|
||||||
|
tableModal.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveDatasource() {
|
async function saveDatasource() {
|
||||||
const { type, plus, ...config } = integration
|
const { type, plus, ...config } = integration
|
||||||
|
|
||||||
|
@ -40,6 +52,9 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<Modal bind:this={tableModal} on:hide={modalContext.hide}>
|
||||||
|
<CreateTableModal bind:name />
|
||||||
|
</Modal>
|
||||||
<ModalContent
|
<ModalContent
|
||||||
title="Create Datasource"
|
title="Create Datasource"
|
||||||
size="L"
|
size="L"
|
||||||
|
|
|
@ -6,9 +6,14 @@
|
||||||
import EditViewPopover from "./popovers/EditViewPopover.svelte"
|
import EditViewPopover from "./popovers/EditViewPopover.svelte"
|
||||||
import NavItem from "components/common/NavItem.svelte"
|
import NavItem from "components/common/NavItem.svelte"
|
||||||
|
|
||||||
|
const alphabetical = (a, b) => a.name?.toLowerCase() > b.name?.toLowerCase()
|
||||||
|
|
||||||
export let sourceId
|
export let sourceId
|
||||||
|
|
||||||
$: selectedView = $views.selected && $views.selected.name
|
$: selectedView = $views.selected && $views.selected.name
|
||||||
|
$: sortedTables = $tables.list
|
||||||
|
.filter(table => table.sourceId === sourceId)
|
||||||
|
.sort(alphabetical)
|
||||||
|
|
||||||
function selectTable(table) {
|
function selectTable(table) {
|
||||||
tables.select(table)
|
tables.select(table)
|
||||||
|
@ -33,7 +38,7 @@
|
||||||
|
|
||||||
{#if $database?._id}
|
{#if $database?._id}
|
||||||
<div class="hierarchy-items-container">
|
<div class="hierarchy-items-container">
|
||||||
{#each $tables.list.filter(table => table.sourceId === sourceId) as table, idx}
|
{#each sortedTables as table, idx}
|
||||||
<NavItem
|
<NavItem
|
||||||
indentLevel={1}
|
indentLevel={1}
|
||||||
border={idx > 0}
|
border={idx > 0}
|
||||||
|
@ -46,7 +51,7 @@
|
||||||
<EditTablePopover {table} />
|
<EditTablePopover {table} />
|
||||||
{/if}
|
{/if}
|
||||||
</NavItem>
|
</NavItem>
|
||||||
{#each Object.keys(table.views || {}) as viewName, idx (idx)}
|
{#each [...Object.keys(table.views || {})].sort() as viewName, idx (idx)}
|
||||||
<NavItem
|
<NavItem
|
||||||
indentLevel={2}
|
indentLevel={2}
|
||||||
icon="Remove"
|
icon="Remove"
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script>
|
<script>
|
||||||
import { goto } from "@roxi/routify"
|
import { goto, url } from "@roxi/routify"
|
||||||
import { store } from "builderStore"
|
import { store } from "builderStore"
|
||||||
import { tables } from "stores/backend"
|
import { tables } from "stores/backend"
|
||||||
import { notifications } from "@budibase/bbui"
|
import { notifications } from "@budibase/bbui"
|
||||||
|
@ -27,7 +27,7 @@
|
||||||
|
|
||||||
$: tableNames = $tables.list.map(table => table.name)
|
$: tableNames = $tables.list.map(table => table.name)
|
||||||
|
|
||||||
let name
|
export let name
|
||||||
let dataImport
|
let dataImport
|
||||||
let error = ""
|
let error = ""
|
||||||
let createAutoscreens = true
|
let createAutoscreens = true
|
||||||
|
@ -91,7 +91,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate to new table
|
// Navigate to new table
|
||||||
$goto(`../../table/${table._id}`)
|
const currentUrl = $url()
|
||||||
|
const path = currentUrl.endsWith("data")
|
||||||
|
? `./table/${table._id}`
|
||||||
|
: `../../table/${table._id}`
|
||||||
|
$goto(path)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,97 @@
|
||||||
|
<script>
|
||||||
|
import { Icon, Combobox, Drawer, Button } from "@budibase/bbui"
|
||||||
|
import {
|
||||||
|
readableToRuntimeBinding,
|
||||||
|
runtimeToReadableBinding,
|
||||||
|
} from "builderStore/dataBinding"
|
||||||
|
import BindingPanel from "components/common/bindings/BindingPanel.svelte"
|
||||||
|
import { createEventDispatcher } from "svelte"
|
||||||
|
|
||||||
|
export let panel = BindingPanel
|
||||||
|
export let value = ""
|
||||||
|
export let bindings = []
|
||||||
|
export let title = "Bindings"
|
||||||
|
export let placeholder
|
||||||
|
export let label
|
||||||
|
export let disabled = false
|
||||||
|
export let options
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher()
|
||||||
|
let bindingDrawer
|
||||||
|
$: tempValue = Array.isArray(value) ? value : []
|
||||||
|
$: readableValue = runtimeToReadableBinding(bindings, value)
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onChange(tempValue)
|
||||||
|
bindingDrawer.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onChange = value => {
|
||||||
|
dispatch("change", readableToRuntimeBinding(bindings, value))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="control">
|
||||||
|
<Combobox
|
||||||
|
{label}
|
||||||
|
{disabled}
|
||||||
|
value={readableValue}
|
||||||
|
on:change={event => onChange(event.detail)}
|
||||||
|
{placeholder}
|
||||||
|
{options}
|
||||||
|
/>
|
||||||
|
{#if !disabled}
|
||||||
|
<div class="icon" on:click={bindingDrawer.show}>
|
||||||
|
<Icon size="S" name="FlashOn" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<Drawer bind:this={bindingDrawer} {title}>
|
||||||
|
<svelte:fragment slot="description">
|
||||||
|
Add the objects on the left to enrich your text.
|
||||||
|
</svelte:fragment>
|
||||||
|
<Button cta slot="buttons" on:click={handleClose}>Save</Button>
|
||||||
|
<svelte:component
|
||||||
|
this={panel}
|
||||||
|
slot="body"
|
||||||
|
value={readableValue}
|
||||||
|
close={handleClose}
|
||||||
|
on:update={event => (tempValue = event.detail)}
|
||||||
|
bindableProperties={bindings}
|
||||||
|
/>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.control {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
right: 31px;
|
||||||
|
bottom: 1px;
|
||||||
|
position: absolute;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-left: 1px solid var(--spectrum-alias-border-color);
|
||||||
|
border-right: 1px solid var(--spectrum-alias-border-color);
|
||||||
|
width: 31px;
|
||||||
|
color: var(--spectrum-alias-text-color);
|
||||||
|
background-color: var(--spectrum-global-color-gray-75);
|
||||||
|
transition: background-color
|
||||||
|
var(--spectrum-global-animation-duration-100, 130ms),
|
||||||
|
box-shadow var(--spectrum-global-animation-duration-100, 130ms),
|
||||||
|
border-color var(--spectrum-global-animation-duration-100, 130ms);
|
||||||
|
height: calc(var(--spectrum-alias-item-height-m) - 2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--spectrum-alias-text-color-hover);
|
||||||
|
background-color: var(--spectrum-global-color-gray-50);
|
||||||
|
border-color: var(--spectrum-alias-border-color-hover);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,120 @@
|
||||||
|
<script>
|
||||||
|
import {
|
||||||
|
Icon,
|
||||||
|
Modal,
|
||||||
|
notifications,
|
||||||
|
ModalContent,
|
||||||
|
Body,
|
||||||
|
Button,
|
||||||
|
} from "@budibase/bbui"
|
||||||
|
import { store } from "builderStore"
|
||||||
|
import api from "builderStore/api"
|
||||||
|
import clientPackage from "@budibase/client/package.json"
|
||||||
|
|
||||||
|
let updateModal
|
||||||
|
|
||||||
|
$: appId = $store.appId
|
||||||
|
$: updateAvailable = clientPackage.version !== $store.version
|
||||||
|
$: revertAvailable = $store.revertableVersion != null
|
||||||
|
|
||||||
|
const refreshAppPackage = async () => {
|
||||||
|
const applicationPkg = await api.get(
|
||||||
|
`/api/applications/${appId}/appPackage`
|
||||||
|
)
|
||||||
|
const pkg = await applicationPkg.json()
|
||||||
|
if (applicationPkg.ok) {
|
||||||
|
await store.actions.initialise(pkg)
|
||||||
|
} else {
|
||||||
|
throw new Error(pkg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.post(
|
||||||
|
`/api/applications/${appId}/client/update`
|
||||||
|
)
|
||||||
|
const json = await response.json()
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw json.message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't wait for the async refresh, since this causes modal flashing
|
||||||
|
refreshAppPackage()
|
||||||
|
notifications.success(
|
||||||
|
`App updated successfully to version ${clientPackage.version}`
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
notifications.error(`Error updating app: ${err}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const revert = async () => {
|
||||||
|
try {
|
||||||
|
const revertableVersion = $store.revertableVersion
|
||||||
|
const response = await api.post(
|
||||||
|
`/api/applications/${appId}/client/revert`
|
||||||
|
)
|
||||||
|
const json = await response.json()
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw json.message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't wait for the async refresh, since this causes modal flashing
|
||||||
|
refreshAppPackage()
|
||||||
|
notifications.success(
|
||||||
|
`App reverted successfully to version ${revertableVersion}`
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
notifications.error(`Error reverting app: ${err}`)
|
||||||
|
}
|
||||||
|
updateModal.hide()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="icon-wrapper" class:highlight={updateAvailable}>
|
||||||
|
<Icon name="Refresh" hoverable on:click={updateModal.show} />
|
||||||
|
</div>
|
||||||
|
<Modal bind:this={updateModal}>
|
||||||
|
<ModalContent
|
||||||
|
title="App version"
|
||||||
|
confirmText="Update"
|
||||||
|
cancelText={updateAvailable ? "Cancel" : "Close"}
|
||||||
|
onConfirm={update}
|
||||||
|
showConfirmButton={updateAvailable}
|
||||||
|
>
|
||||||
|
<div slot="footer">
|
||||||
|
{#if revertAvailable}
|
||||||
|
<Button quiet secondary on:click={revert}>Revert</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if updateAvailable}
|
||||||
|
<Body size="S">
|
||||||
|
This app is currently using version <b>{$store.version}</b>, but version
|
||||||
|
<b>{clientPackage.version}</b> is available. Updates can contain new features,
|
||||||
|
performance improvements and bug fixes.
|
||||||
|
</Body>
|
||||||
|
{:else}
|
||||||
|
<Body size="S">
|
||||||
|
This app is currently using version <b>{$store.version}</b> which is the
|
||||||
|
latest version available.
|
||||||
|
</Body>
|
||||||
|
{/if}
|
||||||
|
{#if revertAvailable}
|
||||||
|
<Body size="S">
|
||||||
|
You can revert this app to version
|
||||||
|
<b>{$store.revertableVersion}</b>
|
||||||
|
if you're experiencing issues with the current version.
|
||||||
|
</Body>
|
||||||
|
{/if}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.icon-wrapper {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
.icon-wrapper.highlight :global(svg) {
|
||||||
|
color: var(--spectrum-global-color-blue-600);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,38 @@
|
||||||
|
<script>
|
||||||
|
import { Select } from "@budibase/bbui"
|
||||||
|
import { store } from "builderStore"
|
||||||
|
|
||||||
|
const themeOptions = [
|
||||||
|
{
|
||||||
|
label: "Lightest",
|
||||||
|
value: "spectrum--lightest",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Light",
|
||||||
|
value: "spectrum--light",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Dark",
|
||||||
|
value: "spectrum--dark",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Darkest",
|
||||||
|
value: "spectrum--darkest",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Select
|
||||||
|
value={$store.theme || "spectrum--light"}
|
||||||
|
options={themeOptions}
|
||||||
|
placeholder={null}
|
||||||
|
on:change={e => store.actions.theme.save(e.detail)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
div {
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -48,6 +48,7 @@
|
||||||
screen,
|
screen,
|
||||||
selectedComponentId,
|
selectedComponentId,
|
||||||
previewType: $store.currentFrontEndType,
|
previewType: $store.currentFrontEndType,
|
||||||
|
theme: $store.theme,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Saving pages and screens to the DB causes them to have _revs.
|
// Saving pages and screens to the DB causes them to have _revs.
|
||||||
|
@ -74,19 +75,16 @@
|
||||||
iframe.contentWindow.addEventListener(
|
iframe.contentWindow.addEventListener(
|
||||||
"ready",
|
"ready",
|
||||||
() => {
|
() => {
|
||||||
loading = false
|
// Display preview immediately if the intelligent loading feature
|
||||||
|
// is not supported
|
||||||
|
if (!$store.clientFeatures.intelligentLoading) {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
refreshContent(strippedJson)
|
refreshContent(strippedJson)
|
||||||
},
|
},
|
||||||
{ once: true }
|
{ once: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Use iframe loading event to support old client versions
|
|
||||||
iframe.contentWindow.addEventListener(
|
|
||||||
"iframe-loaded",
|
|
||||||
() => (loading = false),
|
|
||||||
{ once: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
// Catch any app errors
|
// Catch any app errors
|
||||||
iframe.contentWindow.addEventListener(
|
iframe.contentWindow.addEventListener(
|
||||||
"error",
|
"error",
|
||||||
|
@ -108,7 +106,9 @@
|
||||||
idToDelete = data.id
|
idToDelete = data.id
|
||||||
confirmDeleteDialog.show()
|
confirmDeleteDialog.show()
|
||||||
} else if (type === "preview-loaded") {
|
} else if (type === "preview-loaded") {
|
||||||
// loading = false
|
// Wait for this event to show the client library if intelligent
|
||||||
|
// loading is supported
|
||||||
|
loading = false
|
||||||
} else {
|
} else {
|
||||||
console.warning(`Client sent unknown event type: ${type}`)
|
console.warning(`Client sent unknown event type: ${type}`)
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,9 +27,7 @@
|
||||||
"name": "Card",
|
"name": "Card",
|
||||||
"icon": "Card",
|
"icon": "Card",
|
||||||
"children": [
|
"children": [
|
||||||
"stackedlist",
|
"spectrumcard",
|
||||||
"card",
|
|
||||||
"cardhorizontal",
|
|
||||||
"cardstat"
|
"cardstat"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -57,13 +55,6 @@
|
||||||
"icon",
|
"icon",
|
||||||
"embed"
|
"embed"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Other",
|
|
||||||
"icon": "More",
|
|
||||||
"children": [
|
|
||||||
"screenslot"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -48,7 +48,14 @@ export default `
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract data from message
|
// Extract data from message
|
||||||
const { selectedComponentId, layout, screen, previewType, appId } = JSON.parse(event.data)
|
const {
|
||||||
|
selectedComponentId,
|
||||||
|
layout,
|
||||||
|
screen,
|
||||||
|
previewType,
|
||||||
|
appId,
|
||||||
|
theme
|
||||||
|
} = JSON.parse(event.data)
|
||||||
|
|
||||||
// Set some flags so the app knows we're in the builder
|
// Set some flags so the app knows we're in the builder
|
||||||
window["##BUDIBASE_IN_BUILDER##"] = true
|
window["##BUDIBASE_IN_BUILDER##"] = true
|
||||||
|
@ -58,6 +65,7 @@ export default `
|
||||||
window["##BUDIBASE_SELECTED_COMPONENT_ID##"] = selectedComponentId
|
window["##BUDIBASE_SELECTED_COMPONENT_ID##"] = selectedComponentId
|
||||||
window["##BUDIBASE_PREVIEW_ID##"] = Math.random()
|
window["##BUDIBASE_PREVIEW_ID##"] = Math.random()
|
||||||
window["##BUDIBASE_PREVIEW_TYPE##"] = previewType
|
window["##BUDIBASE_PREVIEW_TYPE##"] = previewType
|
||||||
|
window["##BUDIBASE_PREVIEW_THEME##"] = theme
|
||||||
|
|
||||||
// Initialise app
|
// Initialise app
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -65,52 +65,56 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ActionMenu>
|
{#if definition?.editable !== false}
|
||||||
<div slot="control" class="icon">
|
<ActionMenu>
|
||||||
<Icon size="S" hoverable name="MoreSmallList" />
|
<div slot="control" class="icon">
|
||||||
</div>
|
<Icon size="S" hoverable name="MoreSmallList" />
|
||||||
<MenuItem icon="Delete" on:click={confirmDeleteDialog.show}>Delete</MenuItem>
|
</div>
|
||||||
<MenuItem noClose icon="ChevronUp" on:click={moveUpComponent}>
|
<MenuItem icon="Delete" on:click={confirmDeleteDialog.show}>
|
||||||
Move up
|
Delete
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem noClose icon="ChevronDown" on:click={moveDownComponent}>
|
<MenuItem noClose icon="ChevronUp" on:click={moveUpComponent}>
|
||||||
Move down
|
Move up
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem noClose icon="Duplicate" on:click={duplicateComponent}>
|
<MenuItem noClose icon="ChevronDown" on:click={moveDownComponent}>
|
||||||
Duplicate
|
Move down
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem icon="Cut" on:click={() => storeComponentForCopy(true)}>
|
<MenuItem noClose icon="Duplicate" on:click={duplicateComponent}>
|
||||||
Cut
|
Duplicate
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem icon="Copy" on:click={() => storeComponentForCopy(false)}>
|
<MenuItem icon="Cut" on:click={() => storeComponentForCopy(true)}>
|
||||||
Copy
|
Cut
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem icon="Copy" on:click={() => storeComponentForCopy(false)}>
|
||||||
icon="LayersBringToFront"
|
Copy
|
||||||
on:click={() => pasteComponent("above")}
|
</MenuItem>
|
||||||
disabled={noPaste}
|
<MenuItem
|
||||||
>
|
icon="LayersBringToFront"
|
||||||
Paste above
|
on:click={() => pasteComponent("above")}
|
||||||
</MenuItem>
|
disabled={noPaste}
|
||||||
<MenuItem
|
>
|
||||||
icon="LayersSendToBack"
|
Paste above
|
||||||
on:click={() => pasteComponent("below")}
|
</MenuItem>
|
||||||
disabled={noPaste}
|
<MenuItem
|
||||||
>
|
icon="LayersSendToBack"
|
||||||
Paste below
|
on:click={() => pasteComponent("below")}
|
||||||
</MenuItem>
|
disabled={noPaste}
|
||||||
<MenuItem
|
>
|
||||||
icon="ShowOneLayer"
|
Paste below
|
||||||
on:click={() => pasteComponent("inside")}
|
</MenuItem>
|
||||||
disabled={noPaste || noChildrenAllowed}
|
<MenuItem
|
||||||
>
|
icon="ShowOneLayer"
|
||||||
Paste inside
|
on:click={() => pasteComponent("inside")}
|
||||||
</MenuItem>
|
disabled={noPaste || noChildrenAllowed}
|
||||||
</ActionMenu>
|
>
|
||||||
<ConfirmDialog
|
Paste inside
|
||||||
bind:this={confirmDeleteDialog}
|
</MenuItem>
|
||||||
title="Confirm Deletion"
|
</ActionMenu>
|
||||||
body={`Are you sure you wish to delete this '${definition?.name}' component?`}
|
<ConfirmDialog
|
||||||
okText="Delete Component"
|
bind:this={confirmDeleteDialog}
|
||||||
onOk={deleteComponent}
|
title="Confirm Deletion"
|
||||||
/>
|
body={`Are you sure you wish to delete this '${definition?.name}' component?`}
|
||||||
|
okText="Delete Component"
|
||||||
|
onOk={deleteComponent}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
import { DropEffect, DropPosition } from "./dragDropStore"
|
import { DropEffect, DropPosition } from "./dragDropStore"
|
||||||
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"
|
||||||
|
|
||||||
export let components = []
|
export let components = []
|
||||||
export let currentComponent
|
export let currentComponent
|
||||||
|
@ -10,8 +11,6 @@
|
||||||
export let level = 0
|
export let level = 0
|
||||||
export let dragDropStore
|
export let dragDropStore
|
||||||
|
|
||||||
const isScreenslot = name => name?.endsWith("screenslot")
|
|
||||||
|
|
||||||
const selectComponent = component => {
|
const selectComponent = component => {
|
||||||
store.actions.components.select(component)
|
store.actions.components.select(component)
|
||||||
}
|
}
|
||||||
|
@ -42,6 +41,16 @@
|
||||||
|
|
||||||
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)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
|
@ -63,9 +72,7 @@
|
||||||
on:dragstart={dragstart(component)}
|
on:dragstart={dragstart(component)}
|
||||||
on:dragover={dragover(component, index)}
|
on:dragover={dragover(component, index)}
|
||||||
on:drop={dragDropStore.actions.drop}
|
on:drop={dragDropStore.actions.drop}
|
||||||
text={isScreenslot(component._component)
|
text={getComponentText(component)}
|
||||||
? "Screenslot"
|
|
||||||
: component._instanceName}
|
|
||||||
withArrow
|
withArrow
|
||||||
indentLevel={level + 1}
|
indentLevel={level + 1}
|
||||||
selected={$store.selectedComponentId === component._id}
|
selected={$store.selectedComponentId === component._id}
|
||||||
|
|
|
@ -1,12 +1,6 @@
|
||||||
<script>
|
<script>
|
||||||
import { isEmpty } from "lodash/fp"
|
import { isEmpty } from "lodash/fp"
|
||||||
import {
|
import { Checkbox, Input, Select, DetailSummary } from "@budibase/bbui"
|
||||||
Checkbox,
|
|
||||||
Input,
|
|
||||||
Select,
|
|
||||||
DetailSummary,
|
|
||||||
ColorPicker,
|
|
||||||
} from "@budibase/bbui"
|
|
||||||
import { store } from "builderStore"
|
import { store } from "builderStore"
|
||||||
import PropertyControl from "./PropertyControls/PropertyControl.svelte"
|
import PropertyControl from "./PropertyControls/PropertyControl.svelte"
|
||||||
import LayoutSelect from "./PropertyControls/LayoutSelect.svelte"
|
import LayoutSelect from "./PropertyControls/LayoutSelect.svelte"
|
||||||
|
@ -31,6 +25,8 @@
|
||||||
import AttachmentFieldSelect from "./PropertyControls/AttachmentFieldSelect.svelte"
|
import AttachmentFieldSelect from "./PropertyControls/AttachmentFieldSelect.svelte"
|
||||||
import RelationshipFieldSelect from "./PropertyControls/RelationshipFieldSelect.svelte"
|
import RelationshipFieldSelect from "./PropertyControls/RelationshipFieldSelect.svelte"
|
||||||
import ResetFieldsButton from "./PropertyControls/ResetFieldsButton.svelte"
|
import ResetFieldsButton from "./PropertyControls/ResetFieldsButton.svelte"
|
||||||
|
import ColorPicker from "./PropertyControls/ColorPicker.svelte"
|
||||||
|
import URLSelect from "./PropertyControls/URLSelect.svelte"
|
||||||
|
|
||||||
export let componentDefinition
|
export let componentDefinition
|
||||||
export let componentInstance
|
export let componentInstance
|
||||||
|
@ -66,6 +62,7 @@
|
||||||
section: SectionSelect,
|
section: SectionSelect,
|
||||||
navigation: NavigationEditor,
|
navigation: NavigationEditor,
|
||||||
filter: FilterEditor,
|
filter: FilterEditor,
|
||||||
|
url: URLSelect,
|
||||||
"field/string": StringFieldSelect,
|
"field/string": StringFieldSelect,
|
||||||
"field/number": NumberFieldSelect,
|
"field/number": NumberFieldSelect,
|
||||||
"field/options": OptionsFieldSelect,
|
"field/options": OptionsFieldSelect,
|
||||||
|
|
|
@ -35,17 +35,19 @@
|
||||||
<ActionButton on:click={openDrawer}>Edit custom CSS</ActionButton>
|
<ActionButton on:click={openDrawer}>Edit custom CSS</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
</DetailSummary>
|
</DetailSummary>
|
||||||
<Drawer bind:this={drawer} title="Custom CSS">
|
{#key componentInstance?._id}
|
||||||
<Button cta slot="buttons" on:click={save}>Save</Button>
|
<Drawer bind:this={drawer} title="Custom CSS">
|
||||||
<DrawerContent slot="body">
|
<Button cta slot="buttons" on:click={save}>Save</Button>
|
||||||
<div class="content">
|
<DrawerContent slot="body">
|
||||||
<Layout gap="S">
|
<div class="content">
|
||||||
<Body size="S">Custom CSS overrides all other component styles.</Body>
|
<Layout gap="S">
|
||||||
<TextArea bind:value={tempValue} placeholder="Enter some CSS..." />
|
<Body size="S">Custom CSS overrides all other component styles.</Body>
|
||||||
</Layout>
|
<TextArea bind:value={tempValue} placeholder="Enter some CSS..." />
|
||||||
</div>
|
</Layout>
|
||||||
</DrawerContent>
|
</div>
|
||||||
</Drawer>
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
{/key}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.content {
|
.content {
|
||||||
|
|
|
@ -1,42 +1,8 @@
|
||||||
<script>
|
<script>
|
||||||
import { createEventDispatcher } from "svelte"
|
import { ColorPicker } from "@budibase/bbui"
|
||||||
import Colorpicker from "@budibase/colorpicker"
|
import { store } from "builderStore"
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
|
||||||
|
|
||||||
export let value
|
export let value
|
||||||
|
|
||||||
const WAIT = 150
|
|
||||||
|
|
||||||
function throttle(callback, wait, immediate = false) {
|
|
||||||
let timeout = null
|
|
||||||
let initialCall = true
|
|
||||||
|
|
||||||
return function () {
|
|
||||||
const callNow = immediate && initialCall
|
|
||||||
const next = () => {
|
|
||||||
callback.apply(this, arguments)
|
|
||||||
timeout = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (callNow) {
|
|
||||||
initialCall = false
|
|
||||||
next()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!timeout) {
|
|
||||||
timeout = setTimeout(next, wait)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onChange = throttle(
|
|
||||||
e => {
|
|
||||||
dispatch("change", e.detail)
|
|
||||||
},
|
|
||||||
WAIT,
|
|
||||||
true
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Colorpicker value={value || "#C4C4C4"} on:change={onChange} />
|
<ColorPicker {value} on:change spectrumTheme={$store.theme} />
|
||||||
|
|
|
@ -51,7 +51,7 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ActionButton on:click={drawer.show}>Define Actions</ActionButton>
|
<ActionButton on:click={drawer.show}>Define actions</ActionButton>
|
||||||
<Drawer bind:this={drawer} title={"Actions"}>
|
<Drawer bind:this={drawer} title={"Actions"}>
|
||||||
<svelte:fragment slot="description">
|
<svelte:fragment slot="description">
|
||||||
Define what actions to run.
|
Define what actions to run.
|
||||||
|
|
|
@ -38,7 +38,7 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ActionButton on:click={drawer.show}>Define Filters</ActionButton>
|
<ActionButton on:click={drawer.show}>Define filters</ActionButton>
|
||||||
<Drawer bind:this={drawer} title="Filtering">
|
<Drawer bind:this={drawer} title="Filtering">
|
||||||
<Button cta slot="buttons" on:click={saveFilter}>Save</Button>
|
<Button cta slot="buttons" on:click={saveFilter}>Save</Button>
|
||||||
<DrawerContent slot="body">
|
<DrawerContent slot="body">
|
||||||
|
|
|
@ -107,7 +107,7 @@
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
$: displayValue = value ? value.substring(3) : "Pick Icon"
|
$: displayValue = value ? value.substring(3) : "Pick icon"
|
||||||
|
|
||||||
$: totalPages = Math.ceil(filteredIcons.length / maxIconsPerPage)
|
$: totalPages = Math.ceil(filteredIcons.length / maxIconsPerPage)
|
||||||
$: pageEndIdx = maxIconsPerPage * currentPage
|
$: pageEndIdx = maxIconsPerPage * currentPage
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
<script>
|
||||||
|
import { store } from "builderStore"
|
||||||
|
import DrawerBindableCombobox from "components/common/bindings/DrawerBindableCombobox.svelte"
|
||||||
|
|
||||||
|
export let value
|
||||||
|
|
||||||
|
$: urlOptions = $store.screens
|
||||||
|
.map(screen => screen.routing?.route)
|
||||||
|
.filter(x => x != null)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DrawerBindableCombobox {value} on:change options={urlOptions} />
|
|
@ -1,4 +1,5 @@
|
||||||
import { Input, Select, ColorPicker } from "@budibase/bbui"
|
import { Input, Select } from "@budibase/bbui"
|
||||||
|
import ColorPicker from "./PropertyControls/ColorPicker.svelte"
|
||||||
|
|
||||||
export const margin = {
|
export const margin = {
|
||||||
label: "Margin",
|
label: "Margin",
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
import { Icon, ActionGroup, Tabs, Tab } from "@budibase/bbui"
|
import { Icon, ActionGroup, Tabs, Tab } from "@budibase/bbui"
|
||||||
import DeployModal from "components/deploy/DeployModal.svelte"
|
import DeployModal from "components/deploy/DeployModal.svelte"
|
||||||
import RevertModal from "components/deploy/RevertModal.svelte"
|
import RevertModal from "components/deploy/RevertModal.svelte"
|
||||||
|
import VersionModal from "components/deploy/VersionModal.svelte"
|
||||||
import { get } from "builderStore/api"
|
import { get } from "builderStore/api"
|
||||||
import { isActive, goto, layout } from "@roxi/routify"
|
import { isActive, goto, layout } from "@roxi/routify"
|
||||||
import Logo from "assets/bb-emblem.svg"
|
import Logo from "assets/bb-emblem.svg"
|
||||||
|
@ -80,6 +81,7 @@
|
||||||
<ActionGroup />
|
<ActionGroup />
|
||||||
</div>
|
</div>
|
||||||
<div class="toprightnav">
|
<div class="toprightnav">
|
||||||
|
<VersionModal />
|
||||||
<RevertModal />
|
<RevertModal />
|
||||||
<Icon
|
<Icon
|
||||||
name="Play"
|
name="Play"
|
||||||
|
|
|
@ -7,7 +7,6 @@
|
||||||
|
|
||||||
let selected = "Sources"
|
let selected = "Sources"
|
||||||
let modal
|
let modal
|
||||||
|
|
||||||
$: isExternal =
|
$: isExternal =
|
||||||
$params.selectedDatasource &&
|
$params.selectedDatasource &&
|
||||||
$params.selectedDatasource !== BUDIBASE_INTERNAL_DB
|
$params.selectedDatasource !== BUDIBASE_INTERNAL_DB
|
||||||
|
|
|
@ -0,0 +1,286 @@
|
||||||
|
<script>
|
||||||
|
import { RelationshipTypes } from "constants/backend"
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
ModalContent,
|
||||||
|
Select,
|
||||||
|
Detail,
|
||||||
|
Body,
|
||||||
|
} from "@budibase/bbui"
|
||||||
|
import { tables } from "stores/backend"
|
||||||
|
import { uuid } from "builderStore/uuid"
|
||||||
|
import { writable } from "svelte/store"
|
||||||
|
|
||||||
|
export let save
|
||||||
|
export let datasource
|
||||||
|
export let plusTables = []
|
||||||
|
export let fromRelationship = {}
|
||||||
|
export let toRelationship = {}
|
||||||
|
export let close
|
||||||
|
|
||||||
|
let originalFromName = fromRelationship.name,
|
||||||
|
originalToName = toRelationship.name
|
||||||
|
|
||||||
|
function inSchema(table, prop, ogName) {
|
||||||
|
if (!table || !prop || prop === ogName) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const keys = Object.keys(table.schema).map(key => key.toLowerCase())
|
||||||
|
return keys.indexOf(prop.toLowerCase()) !== -1
|
||||||
|
}
|
||||||
|
|
||||||
|
const touched = writable({})
|
||||||
|
|
||||||
|
function checkForErrors(
|
||||||
|
fromTable,
|
||||||
|
toTable,
|
||||||
|
throughTable,
|
||||||
|
fromRelate,
|
||||||
|
toRelate
|
||||||
|
) {
|
||||||
|
const isMany =
|
||||||
|
fromRelate.relationshipType === RelationshipTypes.MANY_TO_MANY
|
||||||
|
const tableNotSet = "Please specify a table"
|
||||||
|
const errors = {}
|
||||||
|
if ($touched.from && !fromTable) {
|
||||||
|
errors.from = tableNotSet
|
||||||
|
}
|
||||||
|
if ($touched.to && !toTable) {
|
||||||
|
errors.to = tableNotSet
|
||||||
|
}
|
||||||
|
if ($touched.through && isMany && !fromRelate.through) {
|
||||||
|
errors.through = tableNotSet
|
||||||
|
}
|
||||||
|
if ($touched.foreign && !isMany && !fromRelate.fieldName) {
|
||||||
|
errors.foreign = "Please pick the foreign key"
|
||||||
|
}
|
||||||
|
const colNotSet = "Please specify a column name"
|
||||||
|
if ($touched.fromCol && !fromRelate.name) {
|
||||||
|
errors.fromCol = colNotSet
|
||||||
|
}
|
||||||
|
if ($touched.toCol && !toRelate.name) {
|
||||||
|
errors.toCol = colNotSet
|
||||||
|
}
|
||||||
|
// currently don't support relationships back onto the table itself, needs to relate out
|
||||||
|
const tableError = "From/to/through tables must be different"
|
||||||
|
if (fromTable && (fromTable === toTable || fromTable === throughTable)) {
|
||||||
|
errors.from = tableError
|
||||||
|
}
|
||||||
|
if (toTable && (toTable === fromTable || toTable === throughTable)) {
|
||||||
|
errors.to = tableError
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
throughTable &&
|
||||||
|
(throughTable === fromTable || throughTable === toTable)
|
||||||
|
) {
|
||||||
|
errors.through = tableError
|
||||||
|
}
|
||||||
|
const colError = "Column name cannot be an existing column"
|
||||||
|
if (inSchema(fromTable, fromRelate.name, originalFromName)) {
|
||||||
|
errors.fromCol = colError
|
||||||
|
}
|
||||||
|
if (inSchema(toTable, toRelate.name, originalToName)) {
|
||||||
|
errors.toCol = colError
|
||||||
|
}
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
$: tableOptions = plusTables.map(table => ({
|
||||||
|
label: table.name,
|
||||||
|
value: table._id,
|
||||||
|
}))
|
||||||
|
$: fromTable = plusTables.find(table => table._id === toRelationship?.tableId)
|
||||||
|
$: toTable = plusTables.find(table => table._id === fromRelationship?.tableId)
|
||||||
|
$: through = plusTables.find(table => table._id === fromRelationship?.through)
|
||||||
|
$: errors = checkForErrors(
|
||||||
|
fromTable,
|
||||||
|
toTable,
|
||||||
|
through,
|
||||||
|
fromRelationship,
|
||||||
|
toRelationship
|
||||||
|
)
|
||||||
|
$: valid =
|
||||||
|
Object.keys(errors).length === 0 && Object.keys($touched).length !== 0
|
||||||
|
$: linkTable = through || toTable
|
||||||
|
$: relationshipTypes = [
|
||||||
|
{
|
||||||
|
label: "Many",
|
||||||
|
value: RelationshipTypes.MANY_TO_MANY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "One",
|
||||||
|
value: RelationshipTypes.MANY_TO_ONE,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
$: updateRelationshipType(fromRelationship?.relationshipType)
|
||||||
|
|
||||||
|
function updateRelationshipType(fromType) {
|
||||||
|
if (fromType === RelationshipTypes.MANY_TO_MANY) {
|
||||||
|
toRelationship.relationshipType = RelationshipTypes.MANY_TO_MANY
|
||||||
|
} else {
|
||||||
|
toRelationship.relationshipType = RelationshipTypes.MANY_TO_ONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRelationships() {
|
||||||
|
// if any to many only need to check from
|
||||||
|
const manyToMany =
|
||||||
|
fromRelationship.relationshipType === RelationshipTypes.MANY_TO_MANY
|
||||||
|
// main is simply used to know this is the side the user configured it from
|
||||||
|
const id = uuid()
|
||||||
|
if (!manyToMany) {
|
||||||
|
delete fromRelationship.through
|
||||||
|
delete toRelationship.through
|
||||||
|
}
|
||||||
|
let relateFrom = {
|
||||||
|
...fromRelationship,
|
||||||
|
type: "link",
|
||||||
|
main: true,
|
||||||
|
_id: id,
|
||||||
|
}
|
||||||
|
let relateTo = {
|
||||||
|
...toRelationship,
|
||||||
|
type: "link",
|
||||||
|
_id: id,
|
||||||
|
}
|
||||||
|
|
||||||
|
// [0] is because we don't support composite keys for relationships right now
|
||||||
|
if (manyToMany) {
|
||||||
|
relateFrom = {
|
||||||
|
...relateFrom,
|
||||||
|
through: through._id,
|
||||||
|
fieldName: toTable.primary[0],
|
||||||
|
}
|
||||||
|
relateTo = {
|
||||||
|
...relateTo,
|
||||||
|
through: through._id,
|
||||||
|
fieldName: fromTable.primary[0],
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
relateFrom = {
|
||||||
|
...relateFrom,
|
||||||
|
foreignKey: relateFrom.fieldName,
|
||||||
|
fieldName: fromTable.primary[0],
|
||||||
|
}
|
||||||
|
relateTo = {
|
||||||
|
...relateTo,
|
||||||
|
relationshipType: RelationshipTypes.ONE_TO_MANY,
|
||||||
|
foreignKey: relateFrom.fieldName,
|
||||||
|
fieldName: fromTable.primary[0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fromRelationship = relateFrom
|
||||||
|
toRelationship = relateTo
|
||||||
|
}
|
||||||
|
|
||||||
|
// save the relationship on to the datasource
|
||||||
|
async function saveRelationship() {
|
||||||
|
buildRelationships()
|
||||||
|
// source of relationship
|
||||||
|
datasource.entities[fromTable.name].schema[fromRelationship.name] =
|
||||||
|
fromRelationship
|
||||||
|
// save other side of relationship in the other schema
|
||||||
|
datasource.entities[toTable.name].schema[toRelationship.name] =
|
||||||
|
toRelationship
|
||||||
|
|
||||||
|
// If relationship has been renamed
|
||||||
|
if (originalFromName !== fromRelationship.name) {
|
||||||
|
delete datasource.entities[fromTable.name].schema[originalFromName]
|
||||||
|
}
|
||||||
|
if (originalToName !== toRelationship.name) {
|
||||||
|
delete datasource.entities[toTable.name].schema[originalToName]
|
||||||
|
}
|
||||||
|
|
||||||
|
await save()
|
||||||
|
await tables.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRelationship() {
|
||||||
|
delete datasource.entities[fromTable.name].schema[fromRelationship.name]
|
||||||
|
delete datasource.entities[toTable.name].schema[toRelationship.name]
|
||||||
|
await save()
|
||||||
|
await tables.fetch()
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ModalContent
|
||||||
|
title="Create Relationship"
|
||||||
|
confirmText="Save"
|
||||||
|
onConfirm={saveRelationship}
|
||||||
|
disabled={!valid}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
label="Relationship type"
|
||||||
|
options={relationshipTypes}
|
||||||
|
bind:value={fromRelationship.relationshipType}
|
||||||
|
/>
|
||||||
|
<div class="headings">
|
||||||
|
<Detail>Tables</Detail>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
label="Select from table"
|
||||||
|
options={tableOptions}
|
||||||
|
on:change={() => ($touched.from = true)}
|
||||||
|
bind:error={errors.from}
|
||||||
|
bind:value={toRelationship.tableId}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label={"Select to table"}
|
||||||
|
options={tableOptions}
|
||||||
|
on:change={() => ($touched.to = true)}
|
||||||
|
bind:error={errors.to}
|
||||||
|
bind:value={fromRelationship.tableId}
|
||||||
|
/>
|
||||||
|
{#if fromRelationship?.relationshipType === RelationshipTypes.MANY_TO_MANY}
|
||||||
|
<Select
|
||||||
|
label={"Through"}
|
||||||
|
options={tableOptions}
|
||||||
|
on:change={() => ($touched.through = true)}
|
||||||
|
bind:error={errors.through}
|
||||||
|
bind:value={fromRelationship.through}
|
||||||
|
/>
|
||||||
|
{:else if fromRelationship?.relationshipType && toTable}
|
||||||
|
<Select
|
||||||
|
label={`Foreign Key (${toTable?.name})`}
|
||||||
|
options={Object.keys(toTable?.schema).filter(
|
||||||
|
field => toTable?.primary.indexOf(field) === -1
|
||||||
|
)}
|
||||||
|
on:change={() => ($touched.foreign = true)}
|
||||||
|
bind:error={errors.foreign}
|
||||||
|
bind:value={fromRelationship.fieldName}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<div class="headings">
|
||||||
|
<Detail>Column names</Detail>
|
||||||
|
</div>
|
||||||
|
<Body>
|
||||||
|
Budibase manages SQL relationships as a new column in the table, please
|
||||||
|
provide a name for these columns.
|
||||||
|
</Body>
|
||||||
|
<Input
|
||||||
|
on:blur={() => ($touched.fromCol = true)}
|
||||||
|
bind:error={errors.fromCol}
|
||||||
|
label="From table column"
|
||||||
|
bind:value={fromRelationship.name}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
on:blur={() => ($touched.toCol = true)}
|
||||||
|
bind:error={errors.toCol}
|
||||||
|
label="To table column"
|
||||||
|
bind:value={toRelationship.name}
|
||||||
|
/>
|
||||||
|
<div slot="footer">
|
||||||
|
{#if originalFromName != null}
|
||||||
|
<Button warning text on:click={deleteRelationship}>Delete</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</ModalContent>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.headings {
|
||||||
|
margin-top: var(--spacing-s);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,21 @@
|
||||||
|
<script>
|
||||||
|
import { Menu, Icon, MenuSection, MenuItem } from "@budibase/bbui"
|
||||||
|
|
||||||
|
export let heading
|
||||||
|
export let tables
|
||||||
|
export let selected = false
|
||||||
|
export let select
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Menu>
|
||||||
|
<MenuSection {heading}>
|
||||||
|
{#each tables as table}
|
||||||
|
<MenuItem noClose icon="Table" on:click={() => select(table)}>
|
||||||
|
{table.name}
|
||||||
|
{#if selected}
|
||||||
|
<Icon size="S" name="Checkmark" />
|
||||||
|
{/if}
|
||||||
|
</MenuItem>
|
||||||
|
{/each}
|
||||||
|
</MenuSection>
|
||||||
|
</Menu>
|
|
@ -1,16 +1,70 @@
|
||||||
<script>
|
<script>
|
||||||
import { goto, beforeUrlChange } from "@roxi/routify"
|
import { goto, beforeUrlChange } from "@roxi/routify"
|
||||||
import { Button, Heading, Body, Divider, Layout } from "@budibase/bbui"
|
import { Button, Heading, Body, Divider, Layout, Modal } from "@budibase/bbui"
|
||||||
import { datasources, integrations, queries, tables } from "stores/backend"
|
import { datasources, integrations, queries, tables } from "stores/backend"
|
||||||
import { notifications } from "@budibase/bbui"
|
import { notifications } from "@budibase/bbui"
|
||||||
import IntegrationConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte"
|
import IntegrationConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte"
|
||||||
|
import CreateEditRelationship from "./CreateEditRelationship/CreateEditRelationship.svelte"
|
||||||
|
import DisplayColumnModal from "./modals/EditDisplayColumnsModal.svelte"
|
||||||
import ICONS from "components/backend/DatasourceNavigator/icons"
|
import ICONS from "components/backend/DatasourceNavigator/icons"
|
||||||
import { capitalise } from "helpers"
|
import { capitalise } from "helpers"
|
||||||
|
|
||||||
let unsaved = false
|
let unsaved = false
|
||||||
|
let relationshipModal
|
||||||
|
let displayColumnModal
|
||||||
|
let selectedFromRelationship, selectedToRelationship
|
||||||
|
|
||||||
$: datasource = $datasources.list.find(ds => ds._id === $datasources.selected)
|
$: datasource = $datasources.list.find(ds => ds._id === $datasources.selected)
|
||||||
$: integration = datasource && $integrations[datasource.source]
|
$: integration = datasource && $integrations[datasource.source]
|
||||||
|
$: plusTables = datasource?.plus
|
||||||
|
? Object.values(datasource.entities || {})
|
||||||
|
: []
|
||||||
|
$: relationships = getRelationships(plusTables)
|
||||||
|
|
||||||
|
function getRelationships(tables) {
|
||||||
|
if (!tables || !Array.isArray(tables)) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
let pairs = {}
|
||||||
|
for (let table of tables) {
|
||||||
|
for (let column of Object.values(table.schema)) {
|
||||||
|
if (column.type !== "link") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// these relationships have an id to pair them to each other
|
||||||
|
// one has a main for the from side
|
||||||
|
const key = column.main ? "from" : "to"
|
||||||
|
pairs[column._id] = {
|
||||||
|
...pairs[column._id],
|
||||||
|
[key]: column,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pairs
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRelationshipDisplayString(fromCol, toCol) {
|
||||||
|
function getTableName(tableId) {
|
||||||
|
if (!tableId || typeof tableId !== "string") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return plusTables.find(table => table._id === tableId)?.name || "Unknown"
|
||||||
|
}
|
||||||
|
if (!toCol || !fromCol) {
|
||||||
|
return "Cannot build name"
|
||||||
|
}
|
||||||
|
const fromTableName = getTableName(toCol.tableId)
|
||||||
|
const toTableName = getTableName(fromCol.tableId)
|
||||||
|
const throughTableName = getTableName(fromCol.through)
|
||||||
|
|
||||||
|
let displayString
|
||||||
|
if (throughTableName) {
|
||||||
|
displayString = `${fromTableName} through ${throughTableName} → ${toTableName}`
|
||||||
|
} else {
|
||||||
|
displayString = `${fromTableName} → ${toTableName}`
|
||||||
|
}
|
||||||
|
return displayString
|
||||||
|
}
|
||||||
|
|
||||||
async function saveDatasource() {
|
async function saveDatasource() {
|
||||||
try {
|
try {
|
||||||
|
@ -48,6 +102,16 @@
|
||||||
unsaved = true
|
unsaved = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openRelationshipModal(fromRelationship, toRelationship) {
|
||||||
|
selectedFromRelationship = fromRelationship || {}
|
||||||
|
selectedToRelationship = toRelationship || {}
|
||||||
|
relationshipModal.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDisplayColumnModal() {
|
||||||
|
displayColumnModal.show()
|
||||||
|
}
|
||||||
|
|
||||||
$beforeUrlChange(() => {
|
$beforeUrlChange(() => {
|
||||||
if (unsaved) {
|
if (unsaved) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
|
@ -59,6 +123,21 @@
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<Modal bind:this={relationshipModal}>
|
||||||
|
<CreateEditRelationship
|
||||||
|
{datasource}
|
||||||
|
save={saveDatasource}
|
||||||
|
close={relationshipModal.hide}
|
||||||
|
{plusTables}
|
||||||
|
fromRelationship={selectedFromRelationship}
|
||||||
|
toRelationship={selectedToRelationship}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal bind:this={displayColumnModal}>
|
||||||
|
<DisplayColumnModal {datasource} {plusTables} save={saveDatasource} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{#if datasource && integration}
|
{#if datasource && integration}
|
||||||
<section>
|
<section>
|
||||||
<Layout>
|
<Layout>
|
||||||
|
@ -92,9 +171,18 @@
|
||||||
<Divider />
|
<Divider />
|
||||||
<div class="query-header">
|
<div class="query-header">
|
||||||
<Heading size="S">Tables</Heading>
|
<Heading size="S">Tables</Heading>
|
||||||
<Button primary on:click={updateDatasourceSchema}
|
<div class="table-buttons">
|
||||||
>Fetch Tables From Database</Button
|
{#if plusTables && plusTables.length !== 0}
|
||||||
>
|
<Button primary on:click={openDisplayColumnModal}>
|
||||||
|
Update display columns
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
|
<Button primary on:click={updateDatasourceSchema}>
|
||||||
|
Fetch tables from database
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Body>
|
<Body>
|
||||||
This datasource can determine tables automatically. Budibase can fetch
|
This datasource can determine tables automatically. Budibase can fetch
|
||||||
|
@ -102,18 +190,44 @@
|
||||||
having to write any queries at all.
|
having to write any queries at all.
|
||||||
</Body>
|
</Body>
|
||||||
<div class="query-list">
|
<div class="query-list">
|
||||||
{#if datasource.entities}
|
{#each plusTables as table}
|
||||||
{#each Object.keys(datasource.entities) as entity}
|
<div class="query-list-item" on:click={() => onClickTable(table)}>
|
||||||
<div
|
<p class="query-name">{table.name}</p>
|
||||||
class="query-list-item"
|
<p>Primary Key: {table.primary}</p>
|
||||||
on:click={() => onClickTable(datasource.entities[entity])}
|
<p>→</p>
|
||||||
>
|
</div>
|
||||||
<p class="query-name">{entity}</p>
|
{/each}
|
||||||
<p>Primary Key: {datasource.entities[entity].primary}</p>
|
</div>
|
||||||
<p>→</p>
|
{#if plusTables?.length !== 0}
|
||||||
</div>
|
<Divider />
|
||||||
{/each}
|
<div class="query-header">
|
||||||
{/if}
|
<Heading size="S">Relationships</Heading>
|
||||||
|
<Button primary on:click={() => openRelationshipModal()}
|
||||||
|
>Create relationship</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<Body>
|
||||||
|
Tell budibase how your tables are related to get even more smart
|
||||||
|
features.
|
||||||
|
</Body>
|
||||||
|
{/if}
|
||||||
|
<div class="query-list">
|
||||||
|
{#each Object.values(relationships) as relationship}
|
||||||
|
<div
|
||||||
|
class="query-list-item"
|
||||||
|
on:click={() =>
|
||||||
|
openRelationshipModal(relationship.from, relationship.to)}
|
||||||
|
>
|
||||||
|
<p class="query-name">
|
||||||
|
{buildRelationshipDisplayString(
|
||||||
|
relationship.from,
|
||||||
|
relationship.to
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p>{relationship.from?.name} to {relationship.to?.name}</p>
|
||||||
|
<p>→</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<Divider />
|
<Divider />
|
||||||
|
@ -202,4 +316,14 @@
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
font-size: var(--font-size-s);
|
font-size: var(--font-size-s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-buttons {
|
||||||
|
display: grid;
|
||||||
|
grid-gap: var(--spacing-l);
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-buttons div {
|
||||||
|
grid-column-end: -1;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
<script>
|
||||||
|
import { ModalContent, Select, Body } from "@budibase/bbui"
|
||||||
|
import { tables } from "stores/backend"
|
||||||
|
|
||||||
|
export let datasource
|
||||||
|
export let plusTables
|
||||||
|
export let save
|
||||||
|
|
||||||
|
async function saveDisplayColumns() {
|
||||||
|
// be explicit about copying over
|
||||||
|
for (let table of plusTables) {
|
||||||
|
datasource.entities[table.name].primaryDisplay = table.primaryDisplay
|
||||||
|
}
|
||||||
|
save()
|
||||||
|
await tables.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColumnOptions(table) {
|
||||||
|
if (!table || !table.schema) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return Object.entries(table.schema)
|
||||||
|
.filter(field => field[1].type !== "link")
|
||||||
|
.map(([fieldName]) => fieldName)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ModalContent
|
||||||
|
title="Edit display columns"
|
||||||
|
confirmText="Save"
|
||||||
|
onConfirm={saveDisplayColumns}
|
||||||
|
>
|
||||||
|
<Body
|
||||||
|
>Select the columns that will be shown when displaying relationships.</Body
|
||||||
|
>
|
||||||
|
{#each plusTables as table}
|
||||||
|
<Select
|
||||||
|
label={table.name}
|
||||||
|
options={getColumnOptions(table)}
|
||||||
|
bind:value={table.primaryDisplay}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</ModalContent>
|
|
@ -13,6 +13,7 @@
|
||||||
import { FrontendTypes } from "constants"
|
import { FrontendTypes } from "constants"
|
||||||
import { findComponent, findComponentPath } from "builderStore/storeUtils"
|
import { findComponent, findComponentPath } from "builderStore/storeUtils"
|
||||||
import { get } from "svelte/store"
|
import { get } from "svelte/store"
|
||||||
|
import AppThemeSelect from "components/design/AppPreview/AppThemeSelect.svelte"
|
||||||
|
|
||||||
// Cache previous values so we don't update the URL more than necessary
|
// Cache previous values so we don't update the URL more than necessary
|
||||||
let previousType
|
let previousType
|
||||||
|
@ -147,9 +148,16 @@
|
||||||
|
|
||||||
<div class="preview-pane">
|
<div class="preview-pane">
|
||||||
{#if $currentAsset}
|
{#if $currentAsset}
|
||||||
<ComponentSelectionList />
|
<div class="preview-header">
|
||||||
|
<ComponentSelectionList />
|
||||||
|
{#if $store.clientFeatures.spectrumThemes}
|
||||||
|
<AppThemeSelect />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<div class="preview-content">
|
<div class="preview-content">
|
||||||
<CurrentItemPreview />
|
{#key $store.version}
|
||||||
|
<CurrentItemPreview />
|
||||||
|
{/key}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
@ -193,6 +201,10 @@
|
||||||
gap: var(--spacing-m);
|
gap: var(--spacing-m);
|
||||||
padding: var(--spacing-xl) 40px;
|
padding: var(--spacing-xl) 40px;
|
||||||
}
|
}
|
||||||
|
.preview-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 100px;
|
||||||
|
}
|
||||||
.preview-content {
|
.preview-content {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
|
@ -60,6 +60,16 @@
|
||||||
|
|
||||||
let toggleDisabled = false
|
let toggleDisabled = false
|
||||||
|
|
||||||
|
async function updateUserFirstName(evt) {
|
||||||
|
await users.save({ ...$userFetch?.data, firstName: evt.target.value })
|
||||||
|
await userFetch.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateUserLastName(evt) {
|
||||||
|
await users.save({ ...$userFetch?.data, lastName: evt.target.value })
|
||||||
|
await userFetch.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleFlag(flagName, detail) {
|
async function toggleFlag(flagName, detail) {
|
||||||
toggleDisabled = true
|
toggleDisabled = true
|
||||||
await users.save({ ...$userFetch?.data, [flagName]: { global: detail } })
|
await users.save({ ...$userFetch?.data, [flagName]: { global: detail } })
|
||||||
|
@ -113,11 +123,19 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<Label size="L">First name</Label>
|
<Label size="L">First name</Label>
|
||||||
<Input disabled thin value={$userFetch?.data?.firstName} />
|
<Input
|
||||||
|
thin
|
||||||
|
value={$userFetch?.data?.firstName}
|
||||||
|
on:blur={updateUserFirstName}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<Label size="L">Last name</Label>
|
<Label size="L">Last name</Label>
|
||||||
<Input disabled thin value={$userFetch?.data?.lastName} />
|
<Input
|
||||||
|
thin
|
||||||
|
value={$userFetch?.data?.lastName}
|
||||||
|
on:blur={updateUserLastName}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- don't let a user remove the privileges that let them be here -->
|
<!-- don't let a user remove the privileges that let them be here -->
|
||||||
{#if userId !== $auth.user._id}
|
{#if userId !== $auth.user._id}
|
||||||
|
|
|
@ -9,7 +9,9 @@
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
const roles = app.roles
|
const roles = app.roles
|
||||||
let options = roles.map(role => role._id).filter(id => id !== "PUBLIC")
|
let options = roles
|
||||||
|
.map(role => ({ value: role._id, label: role.name }))
|
||||||
|
.filter(role => role.value !== "PUBLIC")
|
||||||
let selectedRole = user?.roles?.[app?._id]
|
let selectedRole = user?.roles?.[app?._id]
|
||||||
|
|
||||||
async function updateUserRoles() {
|
async function updateUserRoles() {
|
||||||
|
|
|
@ -24,7 +24,7 @@
|
||||||
"docker-compose": "^0.23.6",
|
"docker-compose": "^0.23.6",
|
||||||
"inquirer": "^8.0.0",
|
"inquirer": "^8.0.0",
|
||||||
"lookpath": "^1.1.0",
|
"lookpath": "^1.1.0",
|
||||||
"pkg": "^4.4.9",
|
"pkg": "^5.3.0",
|
||||||
"posthog-node": "1.0.7",
|
"posthog-node": "1.0.7",
|
||||||
"randomstring": "^1.1.5"
|
"randomstring": "^1.1.5"
|
||||||
},
|
},
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -3,8 +3,8 @@ import API from "./api"
|
||||||
/**
|
/**
|
||||||
* Fetches screen definition for an app.
|
* Fetches screen definition for an app.
|
||||||
*/
|
*/
|
||||||
export const fetchAppDefinition = async appId => {
|
export const fetchAppPackage = async appId => {
|
||||||
return await API.get({
|
return await API.get({
|
||||||
url: `/api/applications/${appId}/definition`,
|
url: `/api/applications/${appId}/appPackage`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
authStore,
|
authStore,
|
||||||
routeStore,
|
routeStore,
|
||||||
builderStore,
|
builderStore,
|
||||||
|
appStore,
|
||||||
} from "../store"
|
} from "../store"
|
||||||
import { TableNames, ActionTypes } from "../constants"
|
import { TableNames, ActionTypes } from "../constants"
|
||||||
import SettingsBar from "./preview/SettingsBar.svelte"
|
import SettingsBar from "./preview/SettingsBar.svelte"
|
||||||
|
@ -70,6 +71,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$: themeClass =
|
||||||
|
$builderStore.theme || $appStore.application?.theme || "spectrum--light"
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if dataLoaded}
|
{#if dataLoaded}
|
||||||
|
@ -77,7 +81,7 @@
|
||||||
id="spectrum-root"
|
id="spectrum-root"
|
||||||
lang="en"
|
lang="en"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
class="spectrum spectrum--medium spectrum--light"
|
class="spectrum spectrum--medium {themeClass}"
|
||||||
>
|
>
|
||||||
{#if permissionError}
|
{#if permissionError}
|
||||||
<div class="error">
|
<div class="error">
|
||||||
|
@ -126,6 +130,28 @@
|
||||||
}
|
}
|
||||||
#app-root {
|
#app-root {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
border: 1px solid var(--spectrum-global-color-gray-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbars */
|
||||||
|
:global(::-webkit-scrollbar) {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
:global(::-webkit-scrollbar-track) {
|
||||||
|
background: var(--spectrum-alias-background-color-default);
|
||||||
|
}
|
||||||
|
:global(::-webkit-scrollbar-thumb) {
|
||||||
|
background-color: var(--spectrum-global-color-gray-400);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
:global(::-webkit-scrollbar-corner) {
|
||||||
|
background: var(--spectrum-alias-background-color-default);
|
||||||
|
}
|
||||||
|
:global(*) {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--spectrum-global-color-gray-400)
|
||||||
|
var(--spectrum-alias-background-color-default);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
|
|
|
@ -29,4 +29,9 @@
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<IndicatorSet {componentId} color="rgb(120, 170, 244)" transition {zIndex} />
|
<IndicatorSet
|
||||||
|
{componentId}
|
||||||
|
color="var(--spectrum-global-color-static-blue-200)"
|
||||||
|
transition
|
||||||
|
{zIndex}
|
||||||
|
/>
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
<IndicatorSet
|
<IndicatorSet
|
||||||
componentId={$builderStore.selectedComponentId}
|
componentId={$builderStore.selectedComponentId}
|
||||||
color="rgb(66, 133, 244)"
|
color="var(--spectrum-global-color-static-blue-600)"
|
||||||
zIndex="910"
|
zIndex="910"
|
||||||
transition
|
transition
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -138,11 +138,11 @@
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
background: var(--background);
|
background: var(--spectrum-alias-background-color-primary);
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
transition: opacity 0.13s ease-in-out;
|
transition: opacity 0.13s ease-in-out;
|
||||||
}
|
}
|
||||||
|
@ -155,4 +155,14 @@
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
background-color: var(--spectrum-global-color-gray-300);
|
background-color: var(--spectrum-global-color-gray-300);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Theme overrides */
|
||||||
|
:global(.spectrum--dark) .bar,
|
||||||
|
:global(.spectrum--darkest) .bar {
|
||||||
|
background: var(--spectrum-global-color-gray-200);
|
||||||
|
}
|
||||||
|
:global(.spectrum--dark) .divider,
|
||||||
|
:global(.spectrum--darkest) .divider {
|
||||||
|
background: var(--spectrum-global-color-gray-400);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import ClientApp from "./components/ClientApp.svelte"
|
import ClientApp from "./components/ClientApp.svelte"
|
||||||
import { builderStore } from "./store"
|
import { builderStore, appStore } from "./store"
|
||||||
|
|
||||||
let app
|
let app
|
||||||
|
|
||||||
|
@ -7,14 +7,18 @@ const loadBudibase = () => {
|
||||||
// Update builder store with any builder flags
|
// Update builder store with any builder flags
|
||||||
builderStore.set({
|
builderStore.set({
|
||||||
inBuilder: !!window["##BUDIBASE_IN_BUILDER##"],
|
inBuilder: !!window["##BUDIBASE_IN_BUILDER##"],
|
||||||
appId: window["##BUDIBASE_APP_ID##"],
|
|
||||||
layout: window["##BUDIBASE_PREVIEW_LAYOUT##"],
|
layout: window["##BUDIBASE_PREVIEW_LAYOUT##"],
|
||||||
screen: window["##BUDIBASE_PREVIEW_SCREEN##"],
|
screen: window["##BUDIBASE_PREVIEW_SCREEN##"],
|
||||||
selectedComponentId: window["##BUDIBASE_SELECTED_COMPONENT_ID##"],
|
selectedComponentId: window["##BUDIBASE_SELECTED_COMPONENT_ID##"],
|
||||||
previewId: window["##BUDIBASE_PREVIEW_ID##"],
|
previewId: window["##BUDIBASE_PREVIEW_ID##"],
|
||||||
previewType: window["##BUDIBASE_PREVIEW_TYPE##"],
|
previewType: window["##BUDIBASE_PREVIEW_TYPE##"],
|
||||||
|
theme: window["##BUDIBASE_PREVIEW_THEME##"],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Set app ID - this window flag is set by both the preview and the real
|
||||||
|
// server rendered app HTML
|
||||||
|
appStore.actions.setAppID(window["##BUDIBASE_APP_ID##"])
|
||||||
|
|
||||||
// Create app if one hasn't been created yet
|
// Create app if one hasn't been created yet
|
||||||
if (!app) {
|
if (!app) {
|
||||||
app = new ClientApp({
|
app = new ClientApp({
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
import * as API from "../api"
|
||||||
|
import { get, writable } from "svelte/store"
|
||||||
|
|
||||||
|
const createAppStore = () => {
|
||||||
|
const store = writable({})
|
||||||
|
|
||||||
|
// Fetches the app definition including screens, layouts and theme
|
||||||
|
const fetchAppDefinition = async () => {
|
||||||
|
const appDefinition = await API.fetchAppPackage(get(store).appId)
|
||||||
|
store.set(appDefinition)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sets the initial app ID
|
||||||
|
const setAppID = id => {
|
||||||
|
store.update(state => {
|
||||||
|
state.appId = id
|
||||||
|
return state
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe: store.subscribe,
|
||||||
|
actions: { setAppID, fetchAppDefinition },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appStore = createAppStore()
|
|
@ -1,26 +1,13 @@
|
||||||
import * as API from "../api"
|
import * as API from "../api"
|
||||||
import { writable, get } from "svelte/store"
|
import { writable } from "svelte/store"
|
||||||
import { builderStore } from "./builder"
|
|
||||||
import { TableNames } from "../constants"
|
|
||||||
|
|
||||||
const createAuthStore = () => {
|
const createAuthStore = () => {
|
||||||
const store = writable(null)
|
const store = writable(null)
|
||||||
|
|
||||||
// Fetches the user object if someone is logged in and has reloaded the page
|
// Fetches the user object if someone is logged in and has reloaded the page
|
||||||
const fetchUser = async () => {
|
const fetchUser = async () => {
|
||||||
// Fetch the first user if inside the builder
|
const user = await API.fetchSelf()
|
||||||
if (get(builderStore).inBuilder) {
|
store.set(user)
|
||||||
const users = await API.fetchTableData(TableNames.USERS)
|
|
||||||
if (!users.error && users[0] != null) {
|
|
||||||
store.set(users[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Or fetch the current user from localstorage in a real app
|
|
||||||
else {
|
|
||||||
const user = await API.fetchSelf()
|
|
||||||
store.set(user)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
export { authStore } from "./auth"
|
export { authStore } from "./auth"
|
||||||
|
export { appStore } from "./app"
|
||||||
export { notificationStore } from "./notification"
|
export { notificationStore } from "./notification"
|
||||||
export { routeStore } from "./routes"
|
export { routeStore } from "./routes"
|
||||||
export { screenStore } from "./screens"
|
export { screenStore } from "./screens"
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { routeStore } from "./routes"
|
import { routeStore } from "./routes"
|
||||||
import { screenStore } from "./screens"
|
import { appStore } from "./app"
|
||||||
|
|
||||||
export async function initialise() {
|
export async function initialise() {
|
||||||
await routeStore.actions.fetchRoutes()
|
await routeStore.actions.fetchRoutes()
|
||||||
await screenStore.actions.fetchScreens()
|
await appStore.actions.fetchAppDefinition()
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,12 @@
|
||||||
import { writable, derived, get } from "svelte/store"
|
import { derived } from "svelte/store"
|
||||||
import { routeStore } from "./routes"
|
import { routeStore } from "./routes"
|
||||||
import { builderStore } from "./builder"
|
import { builderStore } from "./builder"
|
||||||
import * as API from "../api"
|
import { appStore } from "./app"
|
||||||
|
|
||||||
const createScreenStore = () => {
|
const createScreenStore = () => {
|
||||||
const config = writable({
|
|
||||||
screens: [],
|
|
||||||
layouts: [],
|
|
||||||
})
|
|
||||||
const store = derived(
|
const store = derived(
|
||||||
[config, routeStore, builderStore],
|
[appStore, routeStore, builderStore],
|
||||||
([$config, $routeStore, $builderStore]) => {
|
([$appStore, $routeStore, $builderStore]) => {
|
||||||
let activeLayout, activeScreen
|
let activeLayout, activeScreen
|
||||||
let layouts, screens
|
let layouts, screens
|
||||||
if ($builderStore.inBuilder) {
|
if ($builderStore.inBuilder) {
|
||||||
|
@ -23,8 +19,8 @@ const createScreenStore = () => {
|
||||||
activeLayout = { props: { _component: "screenslot" } }
|
activeLayout = { props: { _component: "screenslot" } }
|
||||||
|
|
||||||
// Find the correct screen by matching the current route
|
// Find the correct screen by matching the current route
|
||||||
screens = $config.screens
|
screens = $appStore.screens
|
||||||
layouts = $config.layouts
|
layouts = $appStore.layouts
|
||||||
if ($routeStore.activeRoute) {
|
if ($routeStore.activeRoute) {
|
||||||
activeScreen = screens.find(
|
activeScreen = screens.find(
|
||||||
screen => screen._id === $routeStore.activeRoute.screenId
|
screen => screen._id === $routeStore.activeRoute.screenId
|
||||||
|
@ -40,17 +36,8 @@ const createScreenStore = () => {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const fetchScreens = async () => {
|
|
||||||
const appDefinition = await API.fetchAppDefinition(get(builderStore).appId)
|
|
||||||
config.set({
|
|
||||||
screens: appDefinition.screens,
|
|
||||||
layouts: appDefinition.layouts,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subscribe: store.subscribe,
|
subscribe: store.subscribe,
|
||||||
actions: { fetchScreens },
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -29,7 +29,7 @@ export const styleable = (node, styles = {}) => {
|
||||||
// overridden by any user specified styles
|
// overridden by any user specified styles
|
||||||
let baseStyles = {}
|
let baseStyles = {}
|
||||||
if (newStyles.empty) {
|
if (newStyles.empty) {
|
||||||
baseStyles.border = "2px dashed var(--grey-5)"
|
baseStyles.border = "2px dashed var(--spectrum-global-color-gray-600)"
|
||||||
baseStyles.padding = "var(--spacing-l)"
|
baseStyles.padding = "var(--spacing-l)"
|
||||||
baseStyles.overflow = "hidden"
|
baseStyles.overflow = "hidden"
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ node_modules/
|
||||||
myapps/
|
myapps/
|
||||||
.env
|
.env
|
||||||
builder/*
|
builder/*
|
||||||
|
client/*
|
||||||
public/
|
public/
|
||||||
db/dev.db/
|
db/dev.db/
|
||||||
dist
|
dist
|
||||||
|
|
|
@ -5,10 +5,13 @@
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
|
"name": "Start Server",
|
||||||
"type": "node",
|
"type": "node",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "Start Server",
|
"runtimeExecutable": "node",
|
||||||
"program": "${workspaceFolder}/src/index.js"
|
"runtimeArgs": ["--nolazy", "-r", "ts-node/register/transpile-only"],
|
||||||
|
"args": ["src/index.ts"],
|
||||||
|
"cwd": "${workspaceRoot}",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "node",
|
"type": "node",
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"watch": ["src"],
|
||||||
|
"ext": "js,ts,json",
|
||||||
|
"ignore": ["src/**/*.spec.ts", "src/**/*.spec.js"],
|
||||||
|
"exec": "ts-node src/index.ts"
|
||||||
|
}
|
|
@ -13,12 +13,13 @@
|
||||||
"postbuild": "copyfiles -u 1 src/**/*.svelte dist/ && copyfiles -u 1 src/**/*.hbs dist/ && copyfiles -u 1 src/**/*.json dist/",
|
"postbuild": "copyfiles -u 1 src/**/*.svelte dist/ && copyfiles -u 1 src/**/*.hbs dist/ && copyfiles -u 1 src/**/*.json dist/",
|
||||||
"test": "jest --coverage --maxWorkers=2",
|
"test": "jest --coverage --maxWorkers=2",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"build:docker": "docker build . -t app-service",
|
"predocker": "copyfiles -f ../client/dist/budibase-client.js ../standard-components/manifest.json client",
|
||||||
|
"build:docker": "yarn run predocker && docker build . -t app-service",
|
||||||
"run:docker": "node dist/index.js",
|
"run:docker": "node dist/index.js",
|
||||||
"dev:stack:up": "node scripts/dev/manage.js up",
|
"dev:stack:up": "node scripts/dev/manage.js up",
|
||||||
"dev:stack:down": "node scripts/dev/manage.js down",
|
"dev:stack:down": "node scripts/dev/manage.js down",
|
||||||
"dev:stack:nuke": "node scripts/dev/manage.js nuke",
|
"dev:stack:nuke": "node scripts/dev/manage.js nuke",
|
||||||
"dev:builder": "yarn run dev:stack:up && ts-node src/index.ts",
|
"dev:builder": "yarn run dev:stack:up && nodemon",
|
||||||
"format": "prettier --config ../../.prettierrc.json 'src/**/*.ts' --write",
|
"format": "prettier --config ../../.prettierrc.json 'src/**/*.ts' --write",
|
||||||
"lint": "eslint --fix src/",
|
"lint": "eslint --fix src/",
|
||||||
"lint:fix": "yarn run format && yarn run lint",
|
"lint:fix": "yarn run format && yarn run lint",
|
||||||
|
|
|
@ -1,9 +1,22 @@
|
||||||
CREATE DATABASE IF NOT EXISTS main;
|
CREATE DATABASE IF NOT EXISTS main;
|
||||||
USE main;
|
USE main;
|
||||||
CREATE TABLE Persons (
|
CREATE TABLE Persons (
|
||||||
PersonID int NOT NULL PRIMARY KEY,
|
PersonID int NOT NULL AUTO_INCREMENT,
|
||||||
LastName varchar(255),
|
LastName varchar(255),
|
||||||
FirstName varchar(255),
|
FirstName varchar(255),
|
||||||
Address varchar(255),
|
Address varchar(255),
|
||||||
City varchar(255)
|
City varchar(255),
|
||||||
|
PRIMARY KEY (PersonID)
|
||||||
);
|
);
|
||||||
|
CREATE TABLE Tasks (
|
||||||
|
TaskID int NOT NULL AUTO_INCREMENT,
|
||||||
|
PersonID INT,
|
||||||
|
TaskName varchar(255),
|
||||||
|
PRIMARY KEY (TaskID),
|
||||||
|
CONSTRAINT fkPersons
|
||||||
|
FOREIGN KEY(PersonID)
|
||||||
|
REFERENCES Persons(PersonID)
|
||||||
|
);
|
||||||
|
INSERT INTO Persons (FirstName, LastName, Address, City) VALUES ('Mike', 'Hughes', '123 Fake Street', 'Belfast');
|
||||||
|
INSERT INTO Tasks (PersonID, TaskName) VALUES (1, 'assembling');
|
||||||
|
INSERT INTO Tasks (PersonID, TaskName) VALUES (1, 'processing');
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
#!/bin/bash
|
||||||
|
docker-compose down
|
||||||
|
docker volume prune -f
|
|
@ -1,17 +1,42 @@
|
||||||
SELECT 'CREATE DATABASE main'
|
SELECT 'CREATE DATABASE main'
|
||||||
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'main')\gexec
|
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'main')\gexec
|
||||||
CREATE TABLE Persons (
|
CREATE TABLE Persons (
|
||||||
PersonID INT NOT NULL PRIMARY KEY,
|
PersonID SERIAL PRIMARY KEY,
|
||||||
LastName varchar(255),
|
LastName varchar(255),
|
||||||
FirstName varchar(255),
|
FirstName varchar(255),
|
||||||
Address varchar(255),
|
Address varchar(255),
|
||||||
City varchar(255)
|
City varchar(255) DEFAULT 'Belfast'
|
||||||
);
|
);
|
||||||
CREATE TABLE Tasks (
|
CREATE TABLE Tasks (
|
||||||
TaskID INT NOT NULL PRIMARY KEY,
|
TaskID SERIAL PRIMARY KEY,
|
||||||
PersonID INT,
|
PersonID INT,
|
||||||
TaskName varchar(255),
|
TaskName varchar(255),
|
||||||
CONSTRAINT fkPersons
|
CONSTRAINT fkPersons
|
||||||
FOREIGN KEY(PersonID)
|
FOREIGN KEY(PersonID)
|
||||||
REFERENCES Persons(PersonID)
|
REFERENCES Persons(PersonID)
|
||||||
);
|
);
|
||||||
|
CREATE TABLE Products (
|
||||||
|
ProductID SERIAL PRIMARY KEY,
|
||||||
|
ProductName varchar(255)
|
||||||
|
);
|
||||||
|
CREATE TABLE Products_Tasks (
|
||||||
|
ProductID INT NOT NULL,
|
||||||
|
TaskID INT NOT NULL,
|
||||||
|
CONSTRAINT fkProducts
|
||||||
|
FOREIGN KEY(ProductID)
|
||||||
|
REFERENCES Products(ProductID),
|
||||||
|
CONSTRAINT fkTasks
|
||||||
|
FOREIGN KEY(TaskID)
|
||||||
|
REFERENCES Tasks(TaskID),
|
||||||
|
PRIMARY KEY (ProductID, TaskID)
|
||||||
|
);
|
||||||
|
INSERT INTO Persons (FirstName, LastName, Address, City) VALUES ('Mike', 'Hughes', '123 Fake Street', 'Belfast');
|
||||||
|
INSERT INTO Tasks (PersonID, TaskName) VALUES (1, 'assembling');
|
||||||
|
INSERT INTO Tasks (PersonID, TaskName) VALUES (1, 'processing');
|
||||||
|
INSERT INTO Products (ProductName) VALUES ('Computers');
|
||||||
|
INSERT INTO Products (ProductName) VALUES ('Laptops');
|
||||||
|
INSERT INTO Products (ProductName) VALUES ('Chairs');
|
||||||
|
INSERT INTO Products_Tasks (ProductID, TaskID) VALUES (1, 1);
|
||||||
|
INSERT INTO Products_Tasks (ProductID, TaskID) VALUES (2, 1);
|
||||||
|
INSERT INTO Products_Tasks (ProductID, TaskID) VALUES (3, 1);
|
||||||
|
INSERT INTO Products_Tasks (ProductID, TaskID) VALUES (1, 2);
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
#!/bin/bash
|
||||||
|
docker-compose down
|
||||||
|
docker volume prune -f
|
|
@ -33,6 +33,11 @@ const {
|
||||||
} = require("../../utilities/workerRequests")
|
} = require("../../utilities/workerRequests")
|
||||||
const { clientLibraryPath } = require("../../utilities")
|
const { clientLibraryPath } = require("../../utilities")
|
||||||
const { getAllLocks } = require("../../utilities/redis")
|
const { getAllLocks } = require("../../utilities/redis")
|
||||||
|
const {
|
||||||
|
updateClientLibrary,
|
||||||
|
backupClientLibrary,
|
||||||
|
revertClientLibrary,
|
||||||
|
} = require("../../utilities/fileSystem/clientLibrary")
|
||||||
|
|
||||||
const URL_REGEX_SLASH = /\/|\\/g
|
const URL_REGEX_SLASH = /\/|\\/g
|
||||||
|
|
||||||
|
@ -67,15 +72,18 @@ async function getAppUrlIfNotInUse(ctx) {
|
||||||
let url
|
let url
|
||||||
if (ctx.request.body.url) {
|
if (ctx.request.body.url) {
|
||||||
url = encodeURI(ctx.request.body.url)
|
url = encodeURI(ctx.request.body.url)
|
||||||
} else {
|
} else if (ctx.request.body.name) {
|
||||||
url = encodeURI(`${ctx.request.body.name}`)
|
url = encodeURI(`${ctx.request.body.name}`)
|
||||||
}
|
}
|
||||||
url = `/${url.replace(URL_REGEX_SLASH, "")}`.toLowerCase()
|
if (url) {
|
||||||
|
url = `/${url.replace(URL_REGEX_SLASH, "")}`.toLowerCase()
|
||||||
|
}
|
||||||
if (!env.SELF_HOSTED) {
|
if (!env.SELF_HOSTED) {
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
const deployedApps = await getDeployedApps(ctx)
|
const deployedApps = await getDeployedApps(ctx)
|
||||||
if (
|
if (
|
||||||
|
url &&
|
||||||
deployedApps[url] != null &&
|
deployedApps[url] != null &&
|
||||||
deployedApps[url].appId !== ctx.params.appId
|
deployedApps[url].appId !== ctx.params.appId
|
||||||
) {
|
) {
|
||||||
|
@ -161,7 +169,15 @@ exports.fetchAppDefinition = async function (ctx) {
|
||||||
exports.fetchAppPackage = async function (ctx) {
|
exports.fetchAppPackage = async function (ctx) {
|
||||||
const db = new CouchDB(ctx.params.appId)
|
const db = new CouchDB(ctx.params.appId)
|
||||||
const application = await db.get(DocumentTypes.APP_METADATA)
|
const application = await db.get(DocumentTypes.APP_METADATA)
|
||||||
const [layouts, screens] = await Promise.all([getLayouts(db), getScreens(db)])
|
const layouts = await getLayouts(db)
|
||||||
|
let screens = await getScreens(db)
|
||||||
|
|
||||||
|
// Only filter screens if the user is not a builder
|
||||||
|
if (!(ctx.user.builder && ctx.user.builder.global)) {
|
||||||
|
const userRoleId = getUserRoleId(ctx)
|
||||||
|
const accessController = new AccessController(ctx.params.appId)
|
||||||
|
screens = await accessController.checkScreensAccess(screens, userRoleId)
|
||||||
|
}
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
application,
|
application,
|
||||||
|
@ -220,27 +236,54 @@ exports.create = async function (ctx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.update = async function (ctx) {
|
exports.update = async function (ctx) {
|
||||||
const url = await getAppUrlIfNotInUse(ctx)
|
const data = await updateAppPackage(ctx, ctx.request.body, ctx.params.appId)
|
||||||
|
ctx.status = 200
|
||||||
|
ctx.body = data
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.updateClient = async function (ctx) {
|
||||||
|
// Get current app version
|
||||||
const db = new CouchDB(ctx.params.appId)
|
const db = new CouchDB(ctx.params.appId)
|
||||||
const application = await db.get(DocumentTypes.APP_METADATA)
|
const application = await db.get(DocumentTypes.APP_METADATA)
|
||||||
|
const currentVersion = application.version
|
||||||
|
|
||||||
const data = ctx.request.body
|
// Update client library and manifest
|
||||||
const newData = { ...application, ...data, url }
|
if (!env.isTest()) {
|
||||||
if (ctx.request.body._rev !== application._rev) {
|
await backupClientLibrary(ctx.params.appId)
|
||||||
newData._rev = application._rev
|
await updateClientLibrary(ctx.params.appId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// the locked by property is attached by server but generated from
|
// Update versions in app package
|
||||||
// Redis, shouldn't ever store it
|
const appPackageUpdates = {
|
||||||
if (newData.lockedBy) {
|
version: packageJson.version,
|
||||||
delete newData.lockedBy
|
revertableVersion: currentVersion,
|
||||||
}
|
}
|
||||||
|
const data = await updateAppPackage(ctx, appPackageUpdates, ctx.params.appId)
|
||||||
const response = await db.put(newData)
|
|
||||||
data._rev = response.rev
|
|
||||||
|
|
||||||
ctx.status = 200
|
ctx.status = 200
|
||||||
ctx.body = response
|
ctx.body = data
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.revertClient = async function (ctx) {
|
||||||
|
// Check app can be reverted
|
||||||
|
const db = new CouchDB(ctx.params.appId)
|
||||||
|
const application = await db.get(DocumentTypes.APP_METADATA)
|
||||||
|
if (!application.revertableVersion) {
|
||||||
|
ctx.throw(400, "There is no version to revert to")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update client library and manifest
|
||||||
|
if (!env.isTest()) {
|
||||||
|
await revertClientLibrary(ctx.params.appId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update versions in app package
|
||||||
|
const appPackageUpdates = {
|
||||||
|
version: application.revertableVersion,
|
||||||
|
revertableVersion: null,
|
||||||
|
}
|
||||||
|
const data = await updateAppPackage(ctx, appPackageUpdates, ctx.params.appId)
|
||||||
|
ctx.status = 200
|
||||||
|
ctx.body = data
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.delete = async function (ctx) {
|
exports.delete = async function (ctx) {
|
||||||
|
@ -258,6 +301,23 @@ exports.delete = async function (ctx) {
|
||||||
ctx.body = result
|
ctx.body = result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateAppPackage = async (ctx, appPackage, appId) => {
|
||||||
|
const url = await getAppUrlIfNotInUse(ctx)
|
||||||
|
const db = new CouchDB(appId)
|
||||||
|
const application = await db.get(DocumentTypes.APP_METADATA)
|
||||||
|
|
||||||
|
const newAppPackage = { ...application, ...appPackage, url }
|
||||||
|
if (appPackage._rev !== application._rev) {
|
||||||
|
newAppPackage._rev = application._rev
|
||||||
|
}
|
||||||
|
|
||||||
|
// the locked by property is attached by server but generated from
|
||||||
|
// Redis, shouldn't ever store it
|
||||||
|
delete newAppPackage.lockedBy
|
||||||
|
|
||||||
|
return await db.put(newAppPackage)
|
||||||
|
}
|
||||||
|
|
||||||
const createEmptyAppPackage = async (ctx, app) => {
|
const createEmptyAppPackage = async (ctx, app) => {
|
||||||
const db = new CouchDB(app.appId)
|
const db = new CouchDB(app.appId)
|
||||||
|
|
||||||
|
|
|
@ -20,10 +20,14 @@ exports.fetchAppComponentDefinitions = async function (ctx) {
|
||||||
const definitions = {}
|
const definitions = {}
|
||||||
for (let { manifest, library } of componentManifests) {
|
for (let { manifest, library } of componentManifests) {
|
||||||
for (let key of Object.keys(manifest)) {
|
for (let key of Object.keys(manifest)) {
|
||||||
const fullComponentName = `${library}/${key}`.toLowerCase()
|
if (key === "features") {
|
||||||
definitions[fullComponentName] = {
|
definitions[key] = manifest[key]
|
||||||
component: fullComponentName,
|
} else {
|
||||||
...manifest[key],
|
const fullComponentName = `${library}/${key}`.toLowerCase()
|
||||||
|
definitions[fullComponentName] = {
|
||||||
|
component: fullComponentName,
|
||||||
|
...manifest[key],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,7 +48,7 @@ exports.buildSchemaFromDb = async function (ctx) {
|
||||||
|
|
||||||
// Connect to the DB and build the schema
|
// Connect to the DB and build the schema
|
||||||
const connector = new Connector(datasource.config)
|
const connector = new Connector(datasource.config)
|
||||||
await connector.buildSchema(datasource._id)
|
await connector.buildSchema(datasource._id, datasource.entities)
|
||||||
datasource.entities = connector.tables
|
datasource.entities = connector.tables
|
||||||
|
|
||||||
const response = await db.post(datasource)
|
const response = await db.post(datasource)
|
||||||
|
|
|
@ -0,0 +1,525 @@
|
||||||
|
import {
|
||||||
|
Operation,
|
||||||
|
SearchFilters,
|
||||||
|
SortJson,
|
||||||
|
PaginationJson,
|
||||||
|
RelationshipsJson,
|
||||||
|
} from "../../../definitions/datasource"
|
||||||
|
import {
|
||||||
|
Row,
|
||||||
|
Table,
|
||||||
|
FieldSchema,
|
||||||
|
Datasource,
|
||||||
|
} from "../../../definitions/common"
|
||||||
|
import {
|
||||||
|
breakRowIdField,
|
||||||
|
generateRowIdField,
|
||||||
|
} from "../../../integrations/utils"
|
||||||
|
|
||||||
|
interface ManyRelationship {
|
||||||
|
tableId?: string
|
||||||
|
id?: string
|
||||||
|
isUpdate?: boolean
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RunConfig {
|
||||||
|
id: string
|
||||||
|
row: Row
|
||||||
|
filters: SearchFilters
|
||||||
|
sort: SortJson
|
||||||
|
paginate: PaginationJson
|
||||||
|
}
|
||||||
|
|
||||||
|
module External {
|
||||||
|
const { makeExternalQuery } = require("./utils")
|
||||||
|
const { DataSourceOperation, FieldTypes } = require("../../../constants")
|
||||||
|
const { breakExternalTableId, isSQL } = require("../../../integrations/utils")
|
||||||
|
const { processObjectSync } = require("@budibase/string-templates")
|
||||||
|
const { cloneDeep } = require("lodash/fp")
|
||||||
|
const { isEqual } = require("lodash")
|
||||||
|
const CouchDB = require("../../../db")
|
||||||
|
|
||||||
|
function buildFilters(
|
||||||
|
id: string | undefined,
|
||||||
|
filters: SearchFilters,
|
||||||
|
table: Table
|
||||||
|
) {
|
||||||
|
const primary = table.primary
|
||||||
|
// if passed in array need to copy for shifting etc
|
||||||
|
let idCopy = cloneDeep(id)
|
||||||
|
if (filters) {
|
||||||
|
// need to map over the filters and make sure the _id field isn't present
|
||||||
|
for (let filter of Object.values(filters)) {
|
||||||
|
if (filter._id && primary) {
|
||||||
|
const parts = breakRowIdField(filter._id)
|
||||||
|
for (let field of primary) {
|
||||||
|
filter[field] = parts.shift()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// make sure this field doesn't exist on any filter
|
||||||
|
delete filter._id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// there is no id, just use the user provided filters
|
||||||
|
if (!idCopy || !table) {
|
||||||
|
return filters
|
||||||
|
}
|
||||||
|
// if used as URL parameter it will have been joined
|
||||||
|
if (!Array.isArray(idCopy)) {
|
||||||
|
idCopy = breakRowIdField(idCopy)
|
||||||
|
}
|
||||||
|
const equal: any = {}
|
||||||
|
if (primary && idCopy) {
|
||||||
|
for (let field of primary) {
|
||||||
|
// work through the ID and get the parts
|
||||||
|
equal[field] = idCopy.shift()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
equal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateIdForRow(row: Row, table: Table): string {
|
||||||
|
const primary = table.primary
|
||||||
|
if (!row || !primary) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// build id array
|
||||||
|
let idParts = []
|
||||||
|
for (let field of primary) {
|
||||||
|
if (row[field]) {
|
||||||
|
idParts.push(row[field])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (idParts.length === 0) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return generateRowIdField(idParts)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEndpoint(tableId: string | undefined, operation: string) {
|
||||||
|
if (!tableId) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
const { datasourceId, tableName } = breakExternalTableId(tableId)
|
||||||
|
return {
|
||||||
|
datasourceId,
|
||||||
|
entityId: tableName,
|
||||||
|
operation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function basicProcessing(row: Row, table: Table) {
|
||||||
|
const thisRow: { [key: string]: any } = {}
|
||||||
|
// filter the row down to what is actually the row (not joined)
|
||||||
|
for (let fieldName of Object.keys(table.schema)) {
|
||||||
|
thisRow[fieldName] = row[fieldName]
|
||||||
|
}
|
||||||
|
thisRow._id = generateIdForRow(row, table)
|
||||||
|
thisRow.tableId = table._id
|
||||||
|
thisRow._rev = "rev"
|
||||||
|
return thisRow
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMany(field: FieldSchema) {
|
||||||
|
return (
|
||||||
|
field.relationshipType && field.relationshipType.split("-")[0] === "many"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExternalRequest {
|
||||||
|
private readonly appId: string
|
||||||
|
private operation: Operation
|
||||||
|
private tableId: string
|
||||||
|
private datasource: Datasource
|
||||||
|
private tables: { [key: string]: Table } = {}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
appId: string,
|
||||||
|
operation: Operation,
|
||||||
|
tableId: string,
|
||||||
|
datasource: Datasource
|
||||||
|
) {
|
||||||
|
this.appId = appId
|
||||||
|
this.operation = operation
|
||||||
|
this.tableId = tableId
|
||||||
|
this.datasource = datasource
|
||||||
|
if (datasource && datasource.entities) {
|
||||||
|
this.tables = datasource.entities
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inputProcessing(row: Row, table: Table) {
|
||||||
|
if (!row) {
|
||||||
|
return { row, manyRelationships: [] }
|
||||||
|
}
|
||||||
|
// we don't really support composite keys for relationships, this is why [0] is used
|
||||||
|
// @ts-ignore
|
||||||
|
const tablePrimary: string = table.primary[0]
|
||||||
|
let newRow: Row = {},
|
||||||
|
manyRelationships: ManyRelationship[] = []
|
||||||
|
for (let [key, field] of Object.entries(table.schema)) {
|
||||||
|
// if set already, or not set just skip it
|
||||||
|
if (!row[key] || newRow[key] || field.autocolumn) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// if its not a link then just copy it over
|
||||||
|
if (field.type !== FieldTypes.LINK) {
|
||||||
|
newRow[key] = row[key]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const { tableName: linkTableName } = breakExternalTableId(field.tableId)
|
||||||
|
// table has to exist for many to many
|
||||||
|
if (!this.tables[linkTableName]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const linkTable = this.tables[linkTableName]
|
||||||
|
// @ts-ignore
|
||||||
|
const linkTablePrimary = linkTable.primary[0]
|
||||||
|
if (!isMany(field)) {
|
||||||
|
newRow[field.foreignKey || linkTablePrimary] = breakRowIdField(
|
||||||
|
row[key][0]
|
||||||
|
)[0]
|
||||||
|
} else {
|
||||||
|
// we're not inserting a doc, will be a bunch of update calls
|
||||||
|
const isUpdate = !field.through
|
||||||
|
const thisKey: string = isUpdate ? "id" : linkTablePrimary
|
||||||
|
// @ts-ignore
|
||||||
|
const otherKey: string = isUpdate ? field.foreignKey : tablePrimary
|
||||||
|
row[key].map((relationship: any) => {
|
||||||
|
// we don't really support composite keys for relationships, this is why [0] is used
|
||||||
|
manyRelationships.push({
|
||||||
|
tableId: field.through || field.tableId,
|
||||||
|
isUpdate,
|
||||||
|
[thisKey]: breakRowIdField(relationship)[0],
|
||||||
|
// leave the ID for enrichment later
|
||||||
|
[otherKey]: `{{ literal ${tablePrimary} }}`,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// we return the relationships that may need to be created in the through table
|
||||||
|
// we do this so that if the ID is generated by the DB it can be inserted
|
||||||
|
// after the fact
|
||||||
|
return { row: newRow, manyRelationships }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This iterates through the returned rows and works out what elements of the rows
|
||||||
|
* actually match up to another row (based on primary keys) - this is pretty specific
|
||||||
|
* to SQL and the way that SQL relationships are returned based on joins.
|
||||||
|
*/
|
||||||
|
updateRelationshipColumns(
|
||||||
|
row: Row,
|
||||||
|
rows: { [key: string]: Row },
|
||||||
|
relationships: RelationshipsJson[]
|
||||||
|
) {
|
||||||
|
const columns: { [key: string]: any } = {}
|
||||||
|
for (let relationship of relationships) {
|
||||||
|
const linkedTable = this.tables[relationship.tableName]
|
||||||
|
if (!linkedTable) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let linked = basicProcessing(row, linkedTable)
|
||||||
|
if (!linked._id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// if not returning full docs then get the minimal links out
|
||||||
|
const display = linkedTable.primaryDisplay
|
||||||
|
linked = {
|
||||||
|
primaryDisplay: display ? linked[display] : undefined,
|
||||||
|
_id: linked._id,
|
||||||
|
}
|
||||||
|
columns[relationship.column] = linked
|
||||||
|
}
|
||||||
|
for (let [column, related] of Object.entries(columns)) {
|
||||||
|
if (!row._id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const rowId: string = row._id
|
||||||
|
if (!Array.isArray(rows[rowId][column])) {
|
||||||
|
rows[rowId][column] = []
|
||||||
|
}
|
||||||
|
// make sure relationship hasn't been found already
|
||||||
|
if (
|
||||||
|
!rows[rowId][column].find(
|
||||||
|
(relation: Row) => relation._id === related._id
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
rows[rowId][column].push(related)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
outputProcessing(
|
||||||
|
rows: Row[],
|
||||||
|
table: Table,
|
||||||
|
relationships: RelationshipsJson[]
|
||||||
|
) {
|
||||||
|
if (rows[0].read === true) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
let finalRows: { [key: string]: Row } = {}
|
||||||
|
for (let row of rows) {
|
||||||
|
const rowId = generateIdForRow(row, table)
|
||||||
|
row._id = rowId
|
||||||
|
// this is a relationship of some sort
|
||||||
|
if (finalRows[rowId]) {
|
||||||
|
finalRows = this.updateRelationshipColumns(
|
||||||
|
row,
|
||||||
|
finalRows,
|
||||||
|
relationships
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const thisRow = basicProcessing(row, table)
|
||||||
|
finalRows[thisRow._id] = thisRow
|
||||||
|
// do this at end once its been added to the final rows
|
||||||
|
finalRows = this.updateRelationshipColumns(
|
||||||
|
row,
|
||||||
|
finalRows,
|
||||||
|
relationships
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return Object.values(finalRows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the list of relationship JSON structures based on the columns in the table,
|
||||||
|
* this will be used by the underlying library to build whatever relationship mechanism
|
||||||
|
* it has (e.g. SQL joins).
|
||||||
|
*/
|
||||||
|
buildRelationships(table: Table): RelationshipsJson[] {
|
||||||
|
const relationships = []
|
||||||
|
for (let [fieldName, field] of Object.entries(table.schema)) {
|
||||||
|
if (field.type !== FieldTypes.LINK) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const { tableName: linkTableName } = breakExternalTableId(field.tableId)
|
||||||
|
// no table to link to, this is not a valid relationships
|
||||||
|
if (!this.tables[linkTableName]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const linkTable = this.tables[linkTableName]
|
||||||
|
if (!table.primary || !linkTable.primary) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const definition = {
|
||||||
|
// if no foreign key specified then use the name of the field in other table
|
||||||
|
from: field.foreignKey || table.primary[0],
|
||||||
|
to: field.fieldName,
|
||||||
|
tableName: linkTableName,
|
||||||
|
through: undefined,
|
||||||
|
// need to specify where to put this back into
|
||||||
|
column: fieldName,
|
||||||
|
}
|
||||||
|
if (field.through) {
|
||||||
|
const { tableName: throughTableName } = breakExternalTableId(
|
||||||
|
field.through
|
||||||
|
)
|
||||||
|
definition.through = throughTableName
|
||||||
|
// don't support composite keys for relationships
|
||||||
|
definition.from = table.primary[0]
|
||||||
|
definition.to = linkTable.primary[0]
|
||||||
|
}
|
||||||
|
relationships.push(definition)
|
||||||
|
}
|
||||||
|
return relationships
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a cached lookup, of relationship records, this is mainly for creating/deleting junction
|
||||||
|
* information.
|
||||||
|
*/
|
||||||
|
async lookup(
|
||||||
|
row: Row,
|
||||||
|
relationship: ManyRelationship,
|
||||||
|
cache: { [key: string]: Row[] } = {}
|
||||||
|
) {
|
||||||
|
const { tableId, isUpdate, id, ...rest } = relationship
|
||||||
|
const { tableName } = breakExternalTableId(tableId)
|
||||||
|
const table = this.tables[tableName]
|
||||||
|
if (isUpdate) {
|
||||||
|
return { rows: [], table }
|
||||||
|
}
|
||||||
|
// if not updating need to make sure we have a list of all possible options
|
||||||
|
let fullKey: string = tableId + "/",
|
||||||
|
rowKey: string = ""
|
||||||
|
for (let key of Object.keys(rest)) {
|
||||||
|
if (row[key]) {
|
||||||
|
fullKey += key
|
||||||
|
rowKey = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cache[fullKey] == null) {
|
||||||
|
cache[fullKey] = await makeExternalQuery(this.appId, {
|
||||||
|
endpoint: getEndpoint(tableId, DataSourceOperation.READ),
|
||||||
|
filters: {
|
||||||
|
equal: {
|
||||||
|
[rowKey]: row[rowKey],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { rows: cache[fullKey], table }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Once a row has been written we may need to update a many field, e.g. updating foreign keys
|
||||||
|
* in a bunch of rows in another table, or inserting/deleting rows from a junction table (many to many).
|
||||||
|
* This is quite a complex process and is handled by this function, there are a few things going on here:
|
||||||
|
* 1. If updating foreign keys its relatively simple, just create a filter for the row that needs updated
|
||||||
|
* and write the various components.
|
||||||
|
* 2. If junction table, then we lookup what exists already, write what doesn't exist, work out what
|
||||||
|
* isn't supposed to exist anymore and delete those. This is better than the usual method of delete them
|
||||||
|
* all and then re-create, as theres no chance of losing data (e.g. delete succeed, but write fail).
|
||||||
|
*/
|
||||||
|
async handleManyRelationships(row: Row, relationships: ManyRelationship[]) {
|
||||||
|
const { appId } = this
|
||||||
|
if (relationships.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// if we're creating (in a through table) need to wipe the existing ones first
|
||||||
|
const promises = []
|
||||||
|
const cache: { [key: string]: Row[] } = {}
|
||||||
|
for (let relationship of relationships) {
|
||||||
|
const { tableId, isUpdate, id, ...rest } = relationship
|
||||||
|
const body = processObjectSync(rest, row)
|
||||||
|
const { table, rows } = await this.lookup(row, relationship, cache)
|
||||||
|
const found = rows.find(row => isEqual(body, row))
|
||||||
|
const operation = isUpdate
|
||||||
|
? DataSourceOperation.UPDATE
|
||||||
|
: DataSourceOperation.CREATE
|
||||||
|
if (!found) {
|
||||||
|
promises.push(
|
||||||
|
makeExternalQuery(appId, {
|
||||||
|
endpoint: getEndpoint(tableId, operation),
|
||||||
|
// if we're doing many relationships then we're writing, only one response
|
||||||
|
body,
|
||||||
|
filters: buildFilters(id, {}, table),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// remove the relationship from the rows
|
||||||
|
rows.splice(rows.indexOf(found), 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// finally if creating, cleanup any rows that aren't supposed to be here
|
||||||
|
for (let [key, rows] of Object.entries(cache)) {
|
||||||
|
// @ts-ignore
|
||||||
|
const tableId: string = key.split("/").shift()
|
||||||
|
const { tableName } = breakExternalTableId(tableId)
|
||||||
|
const table = this.tables[tableName]
|
||||||
|
for (let row of rows) {
|
||||||
|
promises.push(
|
||||||
|
makeExternalQuery(this.appId, {
|
||||||
|
endpoint: getEndpoint(tableId, DataSourceOperation.DELETE),
|
||||||
|
filters: buildFilters(generateIdForRow(row, table), {}, table),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(promises)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is a bit crazy, but the exact purpose of it is to protect against the scenario in which
|
||||||
|
* you have column overlap in relationships, e.g. we join a few different tables and they all have the
|
||||||
|
* concept of an ID, but for some of them it will be null (if they say don't have a relationship).
|
||||||
|
* Creating the specific list of fields that we desire, and excluding the ones that are no use to us
|
||||||
|
* is more performant and has the added benefit of protecting against this scenario.
|
||||||
|
*/
|
||||||
|
buildFields(table: Table) {
|
||||||
|
function extractNonLinkFieldNames(table: Table, existing: string[] = []) {
|
||||||
|
return Object.entries(table.schema)
|
||||||
|
.filter(
|
||||||
|
column =>
|
||||||
|
column[1].type !== FieldTypes.LINK &&
|
||||||
|
!existing.find((field: string) => field.includes(column[0]))
|
||||||
|
)
|
||||||
|
.map(column => `${table.name}.${column[0]}`)
|
||||||
|
}
|
||||||
|
let fields = extractNonLinkFieldNames(table)
|
||||||
|
for (let field of Object.values(table.schema)) {
|
||||||
|
if (field.type !== FieldTypes.LINK) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const { tableName: linkTableName } = breakExternalTableId(field.tableId)
|
||||||
|
const linkTable = this.tables[linkTableName]
|
||||||
|
if (linkTable) {
|
||||||
|
const linkedFields = extractNonLinkFieldNames(linkTable, fields)
|
||||||
|
fields = fields.concat(linkedFields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
async run({ id, row, filters, sort, paginate }: RunConfig) {
|
||||||
|
const { appId, operation, tableId } = this
|
||||||
|
let { datasourceId, tableName } = breakExternalTableId(tableId)
|
||||||
|
if (!this.datasource) {
|
||||||
|
const db = new CouchDB(appId)
|
||||||
|
this.datasource = await db.get(datasourceId)
|
||||||
|
if (!this.datasource || !this.datasource.entities) {
|
||||||
|
throw "No tables found, fetch tables before query."
|
||||||
|
}
|
||||||
|
this.tables = this.datasource.entities
|
||||||
|
}
|
||||||
|
const table = this.tables[tableName]
|
||||||
|
let isSql = isSQL(this.datasource)
|
||||||
|
if (!table) {
|
||||||
|
throw `Unable to process query, table "${tableName}" not defined.`
|
||||||
|
}
|
||||||
|
// clean up row on ingress using schema
|
||||||
|
filters = buildFilters(id, filters, table)
|
||||||
|
const relationships = this.buildRelationships(table)
|
||||||
|
const processed = this.inputProcessing(row, table)
|
||||||
|
row = processed.row
|
||||||
|
if (
|
||||||
|
operation === DataSourceOperation.DELETE &&
|
||||||
|
(filters == null || Object.keys(filters).length === 0)
|
||||||
|
) {
|
||||||
|
throw "Deletion must be filtered"
|
||||||
|
}
|
||||||
|
let json = {
|
||||||
|
endpoint: {
|
||||||
|
datasourceId,
|
||||||
|
entityId: tableName,
|
||||||
|
operation,
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
// have to specify the fields to avoid column overlap (for SQL)
|
||||||
|
fields: isSql ? this.buildFields(table) : [],
|
||||||
|
},
|
||||||
|
filters,
|
||||||
|
sort,
|
||||||
|
paginate,
|
||||||
|
relationships,
|
||||||
|
body: row,
|
||||||
|
// pass an id filter into extra, purely for mysql/returning
|
||||||
|
extra: {
|
||||||
|
idFilter: buildFilters(id || generateIdForRow(row, table), {}, table),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// can't really use response right now
|
||||||
|
const response = await makeExternalQuery(appId, json)
|
||||||
|
// handle many to many relationships now if we know the ID (could be auto increment)
|
||||||
|
if (processed.manyRelationships) {
|
||||||
|
await this.handleManyRelationships(
|
||||||
|
response[0],
|
||||||
|
processed.manyRelationships
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const output = this.outputProcessing(response, table, relationships)
|
||||||
|
// if reading it'll just be an array of rows, return whole thing
|
||||||
|
return operation === DataSourceOperation.READ && Array.isArray(response)
|
||||||
|
? output
|
||||||
|
: { row: output[0], table }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ExternalRequest
|
||||||
|
}
|
|
@ -1,136 +1,19 @@
|
||||||
const { makeExternalQuery } = require("./utils")
|
const {
|
||||||
const { DataSourceOperation, SortDirection } = require("../../../constants")
|
DataSourceOperation,
|
||||||
const { getExternalTable } = require("../table/utils")
|
SortDirection,
|
||||||
|
FieldTypes,
|
||||||
|
} = require("../../../constants")
|
||||||
const {
|
const {
|
||||||
breakExternalTableId,
|
breakExternalTableId,
|
||||||
generateRowIdField,
|
|
||||||
breakRowIdField,
|
breakRowIdField,
|
||||||
} = require("../../../integrations/utils")
|
} = require("../../../integrations/utils")
|
||||||
const { cloneDeep } = require("lodash/fp")
|
const ExternalRequest = require("./ExternalRequest")
|
||||||
|
const CouchDB = require("../../../db")
|
||||||
|
|
||||||
function inputProcessing(row, table) {
|
async function handleRequest(appId, operation, tableId, opts = {}) {
|
||||||
if (!row) {
|
return new ExternalRequest(appId, operation, tableId, opts.datasource).run(
|
||||||
return row
|
opts
|
||||||
}
|
)
|
||||||
let newRow = {}
|
|
||||||
for (let key of Object.keys(table.schema)) {
|
|
||||||
// currently excludes empty strings
|
|
||||||
if (row[key]) {
|
|
||||||
newRow[key] = row[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return newRow
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateIdForRow(row, table) {
|
|
||||||
if (!row) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const primary = table.primary
|
|
||||||
// build id array
|
|
||||||
let idParts = []
|
|
||||||
for (let field of primary) {
|
|
||||||
idParts.push(row[field])
|
|
||||||
}
|
|
||||||
return generateRowIdField(idParts)
|
|
||||||
}
|
|
||||||
|
|
||||||
function outputProcessing(rows, table) {
|
|
||||||
// if no rows this is what is returned? Might be PG only
|
|
||||||
if (rows[0].read === true) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
for (let row of rows) {
|
|
||||||
row._id = generateIdForRow(row, table)
|
|
||||||
row.tableId = table._id
|
|
||||||
row._rev = "rev"
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFilters(id, filters, table) {
|
|
||||||
const primary = table.primary
|
|
||||||
// if passed in array need to copy for shifting etc
|
|
||||||
let idCopy = cloneDeep(id)
|
|
||||||
if (filters) {
|
|
||||||
// need to map over the filters and make sure the _id field isn't present
|
|
||||||
for (let filter of Object.values(filters)) {
|
|
||||||
if (filter._id) {
|
|
||||||
const parts = breakRowIdField(filter._id)
|
|
||||||
for (let field of primary) {
|
|
||||||
filter[field] = parts.shift()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// make sure this field doesn't exist on any filter
|
|
||||||
delete filter._id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// there is no id, just use the user provided filters
|
|
||||||
if (!idCopy || !table) {
|
|
||||||
return filters
|
|
||||||
}
|
|
||||||
// if used as URL parameter it will have been joined
|
|
||||||
if (typeof idCopy === "string") {
|
|
||||||
idCopy = breakRowIdField(idCopy)
|
|
||||||
}
|
|
||||||
const equal = {}
|
|
||||||
for (let field of primary) {
|
|
||||||
// work through the ID and get the parts
|
|
||||||
equal[field] = idCopy.shift()
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
equal,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRequest(
|
|
||||||
appId,
|
|
||||||
operation,
|
|
||||||
tableId,
|
|
||||||
{ id, row, filters, sort, paginate } = {}
|
|
||||||
) {
|
|
||||||
let { datasourceId, tableName } = breakExternalTableId(tableId)
|
|
||||||
const table = await getExternalTable(appId, datasourceId, tableName)
|
|
||||||
if (!table) {
|
|
||||||
throw `Unable to process query, table "${tableName}" not defined.`
|
|
||||||
}
|
|
||||||
// clean up row on ingress using schema
|
|
||||||
filters = buildFilters(id, filters, table)
|
|
||||||
row = inputProcessing(row, table)
|
|
||||||
if (
|
|
||||||
operation === DataSourceOperation.DELETE &&
|
|
||||||
(filters == null || Object.keys(filters).length === 0)
|
|
||||||
) {
|
|
||||||
throw "Deletion must be filtered"
|
|
||||||
}
|
|
||||||
let json = {
|
|
||||||
endpoint: {
|
|
||||||
datasourceId,
|
|
||||||
entityId: tableName,
|
|
||||||
operation,
|
|
||||||
},
|
|
||||||
resource: {
|
|
||||||
// not specifying any fields means "*"
|
|
||||||
fields: [],
|
|
||||||
},
|
|
||||||
filters,
|
|
||||||
sort,
|
|
||||||
paginate,
|
|
||||||
body: row,
|
|
||||||
// pass an id filter into extra, purely for mysql/returning
|
|
||||||
extra: {
|
|
||||||
idFilter: buildFilters(id || generateIdForRow(row, table), {}, table),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
// can't really use response right now
|
|
||||||
const response = await makeExternalQuery(appId, json)
|
|
||||||
// we searched for rows in someway
|
|
||||||
if (operation === DataSourceOperation.READ && Array.isArray(response)) {
|
|
||||||
return outputProcessing(response, table)
|
|
||||||
} else {
|
|
||||||
row = outputProcessing(response, table)[0]
|
|
||||||
return { row, table }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.patch = async ctx => {
|
exports.patch = async ctx => {
|
||||||
|
@ -172,9 +55,15 @@ exports.find = async ctx => {
|
||||||
const appId = ctx.appId
|
const appId = ctx.appId
|
||||||
const id = ctx.params.rowId
|
const id = ctx.params.rowId
|
||||||
const tableId = ctx.params.tableId
|
const tableId = ctx.params.tableId
|
||||||
return handleRequest(appId, DataSourceOperation.READ, tableId, {
|
const response = await handleRequest(
|
||||||
id,
|
appId,
|
||||||
})
|
DataSourceOperation.READ,
|
||||||
|
tableId,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return response ? response[0] : response
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.destroy = async ctx => {
|
exports.destroy = async ctx => {
|
||||||
|
@ -270,7 +159,56 @@ exports.validate = async () => {
|
||||||
return { valid: true }
|
return { valid: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.fetchEnrichedRow = async () => {
|
exports.fetchEnrichedRow = async ctx => {
|
||||||
// TODO: How does this work
|
const appId = ctx.appId
|
||||||
throw "Not Implemented"
|
const id = ctx.params.rowId
|
||||||
|
const tableId = ctx.params.tableId
|
||||||
|
const { datasourceId, tableName } = breakExternalTableId(tableId)
|
||||||
|
const db = new CouchDB(appId)
|
||||||
|
const datasource = await db.get(datasourceId)
|
||||||
|
if (!datasource || !datasource.entities) {
|
||||||
|
ctx.throw(400, "Datasource has not been configured for plus API.")
|
||||||
|
}
|
||||||
|
const tables = datasource.entities
|
||||||
|
const response = await handleRequest(
|
||||||
|
appId,
|
||||||
|
DataSourceOperation.READ,
|
||||||
|
tableId,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
datasource,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const table = tables[tableName]
|
||||||
|
const row = response[0]
|
||||||
|
// this seems like a lot of work, but basically we need to dig deeper for the enrich
|
||||||
|
// for a single row, there is probably a better way to do this with some smart multi-layer joins
|
||||||
|
for (let [fieldName, field] of Object.entries(table.schema)) {
|
||||||
|
if (
|
||||||
|
field.type !== FieldTypes.LINK ||
|
||||||
|
!row[fieldName] ||
|
||||||
|
row[fieldName].length === 0
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const links = row[fieldName]
|
||||||
|
const linkedTableId = field.tableId
|
||||||
|
const linkedTable = tables[breakExternalTableId(linkedTableId).tableName]
|
||||||
|
// don't support composite keys right now
|
||||||
|
const linkedIds = links.map(link => breakRowIdField(link._id)[0])
|
||||||
|
row[fieldName] = await handleRequest(
|
||||||
|
appId,
|
||||||
|
DataSourceOperation.READ,
|
||||||
|
linkedTableId,
|
||||||
|
{
|
||||||
|
tables,
|
||||||
|
filters: {
|
||||||
|
oneOf: {
|
||||||
|
[linkedTable.primary]: linkedIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return row
|
||||||
}
|
}
|
||||||
|
|
|
@ -204,15 +204,18 @@ class TableSaveFunctions {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.getExternalTable = async (appId, datasourceId, tableName) => {
|
exports.getAllExternalTables = async (appId, datasourceId) => {
|
||||||
const db = new CouchDB(appId)
|
const db = new CouchDB(appId)
|
||||||
const datasource = await db.get(datasourceId)
|
const datasource = await db.get(datasourceId)
|
||||||
if (!datasource || !datasource.entities) {
|
if (!datasource || !datasource.entities) {
|
||||||
throw "Datasource is not configured fully."
|
throw "Datasource is not configured fully."
|
||||||
}
|
}
|
||||||
return Object.values(datasource.entities).find(
|
return datasource.entities
|
||||||
entity => entity.name === tableName
|
}
|
||||||
)
|
|
||||||
|
exports.getExternalTable = async (appId, datasourceId, tableName) => {
|
||||||
|
const entities = await exports.getAllExternalTables(appId, datasourceId)
|
||||||
|
return entities[tableName]
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.TableSaveFunctions = TableSaveFunctions
|
exports.TableSaveFunctions = TableSaveFunctions
|
||||||
|
|
|
@ -11,6 +11,16 @@ router
|
||||||
.get("/api/applications/:appId/appPackage", controller.fetchAppPackage)
|
.get("/api/applications/:appId/appPackage", controller.fetchAppPackage)
|
||||||
.put("/api/applications/:appId", authorized(BUILDER), controller.update)
|
.put("/api/applications/:appId", authorized(BUILDER), controller.update)
|
||||||
.post("/api/applications", authorized(BUILDER), controller.create)
|
.post("/api/applications", authorized(BUILDER), controller.create)
|
||||||
|
.post(
|
||||||
|
"/api/applications/:appId/client/update",
|
||||||
|
authorized(BUILDER),
|
||||||
|
controller.updateClient
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/api/applications/:appId/client/revert",
|
||||||
|
authorized(BUILDER),
|
||||||
|
controller.revertClient
|
||||||
|
)
|
||||||
.delete("/api/applications/:appId", authorized(BUILDER), controller.delete)
|
.delete("/api/applications/:appId", authorized(BUILDER), controller.delete)
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|
|
@ -94,7 +94,7 @@ describe("/applications", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("update", () => {
|
describe("update", () => {
|
||||||
it("should be able to fetch the app package", async () => {
|
it("should be able to update the app package", async () => {
|
||||||
const res = await request
|
const res = await request
|
||||||
.put(`/api/applications/${config.getAppId()}`)
|
.put(`/api/applications/${config.getAppId()}`)
|
||||||
.send({
|
.send({
|
||||||
|
@ -107,6 +107,30 @@ describe("/applications", () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("manage client library version", () => {
|
||||||
|
it("should be able to update the app client library version", async () => {
|
||||||
|
console.log(config.getAppId())
|
||||||
|
await request
|
||||||
|
.post(`/api/applications/${config.getAppId()}/client/update`)
|
||||||
|
.set(config.defaultHeaders())
|
||||||
|
.expect("Content-Type", /json/)
|
||||||
|
.expect(200)
|
||||||
|
})
|
||||||
|
it("should be able to revert the app client library version", async () => {
|
||||||
|
// We need to first update the version so that we can then revert
|
||||||
|
await request
|
||||||
|
.post(`/api/applications/${config.getAppId()}/client/update`)
|
||||||
|
.set(config.defaultHeaders())
|
||||||
|
.expect("Content-Type", /json/)
|
||||||
|
.expect(200)
|
||||||
|
await request
|
||||||
|
.post(`/api/applications/${config.getAppId()}/client/revert`)
|
||||||
|
.set(config.defaultHeaders())
|
||||||
|
.expect("Content-Type", /json/)
|
||||||
|
.expect(200)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("edited at", () => {
|
describe("edited at", () => {
|
||||||
it("middleware should set edited at", async () => {
|
it("middleware should set edited at", async () => {
|
||||||
const headers = config.defaultHeaders()
|
const headers = config.defaultHeaders()
|
||||||
|
|
|
@ -94,7 +94,7 @@ describe("/datasources", () => {
|
||||||
.expect(200)
|
.expect(200)
|
||||||
// this is mock data, can't test it
|
// this is mock data, can't test it
|
||||||
expect(res.body).toBeDefined()
|
expect(res.body).toBeDefined()
|
||||||
expect(pg.queryMock).toHaveBeenCalledWith(`select "name", "age" from "users" where "name" like $1 limit $2`, ["John%", 5000])
|
expect(pg.queryMock).toHaveBeenCalledWith(`select "name", "age" from "users" where "users"."name" like $1 limit $2`, ["John%", 5000])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,6 @@ const setup = require("./utilities")
|
||||||
const { basicScreen } = setup.structures
|
const { basicScreen } = setup.structures
|
||||||
const { checkBuilderEndpoint } = require("./utilities/TestFunctions")
|
const { checkBuilderEndpoint } = require("./utilities/TestFunctions")
|
||||||
const { BUILTIN_ROLE_IDS } = require("@budibase/auth/roles")
|
const { BUILTIN_ROLE_IDS } = require("@budibase/auth/roles")
|
||||||
const workerRequests = require("../../../utilities/workerRequests")
|
|
||||||
|
|
||||||
const route = "/test"
|
const route = "/test"
|
||||||
|
|
||||||
|
|
|
@ -26,3 +26,17 @@ export interface Table {
|
||||||
primaryDisplay?: string
|
primaryDisplay?: string
|
||||||
sourceId?: string
|
sourceId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BudibaseAppMetadata {
|
||||||
|
_id: string
|
||||||
|
_rev?: string
|
||||||
|
appId: string
|
||||||
|
type: string
|
||||||
|
version: string
|
||||||
|
componentlibraries: string[]
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
instance: { _id: string }
|
||||||
|
updatedAt: Date
|
||||||
|
createdAt: Date
|
||||||
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ const EMPTY_LAYOUT = {
|
||||||
{
|
{
|
||||||
_id: "7fcf11e4-6f5b-4085-8e0d-9f3d44c98967",
|
_id: "7fcf11e4-6f5b-4085-8e0d-9f3d44c98967",
|
||||||
_component: "@budibase/standard-components/screenslot",
|
_component: "@budibase/standard-components/screenslot",
|
||||||
|
_instanceName: "Screen slot",
|
||||||
_styles: {
|
_styles: {
|
||||||
normal: {
|
normal: {
|
||||||
flex: "1 1 auto",
|
flex: "1 1 auto",
|
||||||
|
@ -63,6 +64,7 @@ const BASE_LAYOUTS = [
|
||||||
{
|
{
|
||||||
_id: "7fcf11e4-6f5b-4085-8e0d-9f3d44c98967",
|
_id: "7fcf11e4-6f5b-4085-8e0d-9f3d44c98967",
|
||||||
_component: "@budibase/standard-components/screenslot",
|
_component: "@budibase/standard-components/screenslot",
|
||||||
|
_instanceName: "Screen slot",
|
||||||
_styles: {
|
_styles: {
|
||||||
normal: {
|
normal: {
|
||||||
flex: "1 1 auto",
|
flex: "1 1 auto",
|
||||||
|
@ -84,6 +86,7 @@ const BASE_LAYOUTS = [
|
||||||
normal: {},
|
normal: {},
|
||||||
selected: {},
|
selected: {},
|
||||||
},
|
},
|
||||||
|
title: "{{ name }}",
|
||||||
navigation: "Top",
|
navigation: "Top",
|
||||||
width: "Large",
|
width: "Large",
|
||||||
links: [
|
links: [
|
||||||
|
@ -109,6 +112,7 @@ const BASE_LAYOUTS = [
|
||||||
{
|
{
|
||||||
_id: "7fcf11e4-6f5b-4085-8e0d-9f3d44c98967",
|
_id: "7fcf11e4-6f5b-4085-8e0d-9f3d44c98967",
|
||||||
_component: "@budibase/standard-components/screenslot",
|
_component: "@budibase/standard-components/screenslot",
|
||||||
|
_instanceName: "Screen slot",
|
||||||
_styles: {
|
_styles: {
|
||||||
normal: {
|
normal: {
|
||||||
flex: "1 1 auto",
|
flex: "1 1 auto",
|
||||||
|
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { SourceNames } from "./datasource"
|
||||||
|
|
||||||
|
interface Base {
|
||||||
|
_id?: string
|
||||||
|
_rev?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldSchema {
|
||||||
|
// TODO: replace with field types enum when done
|
||||||
|
type: string
|
||||||
|
fieldName?: string
|
||||||
|
name: string
|
||||||
|
tableId?: string
|
||||||
|
relationshipType?: string
|
||||||
|
through?: string
|
||||||
|
foreignKey?: string
|
||||||
|
autocolumn?: boolean
|
||||||
|
constraints?: {
|
||||||
|
type?: string
|
||||||
|
email?: boolean
|
||||||
|
inclusion?: string[]
|
||||||
|
length?: {
|
||||||
|
minimum?: string | number
|
||||||
|
maximum?: string | number
|
||||||
|
}
|
||||||
|
presence?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableSchema {
|
||||||
|
[key: string]: FieldSchema
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Table extends Base {
|
||||||
|
type?: string
|
||||||
|
views?: {}
|
||||||
|
name?: string
|
||||||
|
primary?: string[]
|
||||||
|
schema: TableSchema
|
||||||
|
primaryDisplay?: string
|
||||||
|
sourceId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Row extends Base {
|
||||||
|
type?: string
|
||||||
|
tableId?: string
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JsonSchemaField {
|
||||||
|
properties: {
|
||||||
|
[key: string]: {
|
||||||
|
type: string
|
||||||
|
title: string
|
||||||
|
customType?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
required?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutomationStep {
|
||||||
|
description: string
|
||||||
|
event?: string
|
||||||
|
icon: string
|
||||||
|
id: string
|
||||||
|
inputs: {
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
name: string
|
||||||
|
schema: {
|
||||||
|
inputs: JsonSchemaField
|
||||||
|
outputs: JsonSchemaField
|
||||||
|
}
|
||||||
|
stepId: string
|
||||||
|
tagline: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Automation extends Base {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
appId?: string
|
||||||
|
definition: {
|
||||||
|
steps: AutomationStep[]
|
||||||
|
trigger?: AutomationStep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Datasource extends Base {
|
||||||
|
type: string
|
||||||
|
name: string
|
||||||
|
source: SourceNames
|
||||||
|
// the config is defined by the schema
|
||||||
|
config: {
|
||||||
|
[key: string]: string | number | boolean
|
||||||
|
}
|
||||||
|
plus: boolean
|
||||||
|
entities?: {
|
||||||
|
[key: string]: Table
|
||||||
|
}
|
||||||
|
}
|
|
@ -26,6 +26,20 @@ export enum DatasourceFieldTypes {
|
||||||
JSON = "json",
|
JSON = "json",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum SourceNames {
|
||||||
|
POSTGRES = "POSTGRES",
|
||||||
|
DYNAMODB = "DYNAMODB",
|
||||||
|
MONGODB = "MONGODB",
|
||||||
|
ELASTICSEARCH = "ELASTICSEARCH",
|
||||||
|
COUCHDB = "COUCHDB",
|
||||||
|
SQL_SERVER = "SQL_SERVER",
|
||||||
|
S3 = "S3",
|
||||||
|
AIRTABLE = "AIRTABLE",
|
||||||
|
MYSQL = "MYSQL",
|
||||||
|
ARANGODB = "ARANGODB",
|
||||||
|
REST = "REST",
|
||||||
|
}
|
||||||
|
|
||||||
export interface QueryDefinition {
|
export interface QueryDefinition {
|
||||||
type: QueryTypes
|
type: QueryTypes
|
||||||
displayName?: string
|
displayName?: string
|
||||||
|
@ -47,7 +61,7 @@ export interface Integration {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchFilters {
|
export interface SearchFilters {
|
||||||
allOr: boolean
|
allOr?: boolean
|
||||||
string?: {
|
string?: {
|
||||||
[key: string]: string
|
[key: string]: string
|
||||||
}
|
}
|
||||||
|
@ -72,6 +86,26 @@ export interface SearchFilters {
|
||||||
notEmpty?: {
|
notEmpty?: {
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
}
|
}
|
||||||
|
oneOf?: {
|
||||||
|
[key: string]: any[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SortJson {
|
||||||
|
[key: string]: SortDirection
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationJson {
|
||||||
|
limit: number
|
||||||
|
page: string | number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelationshipsJson {
|
||||||
|
through?: string
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
tableName: string
|
||||||
|
column: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueryJson {
|
export interface QueryJson {
|
||||||
|
@ -84,17 +118,13 @@ export interface QueryJson {
|
||||||
fields: string[]
|
fields: string[]
|
||||||
}
|
}
|
||||||
filters?: SearchFilters
|
filters?: SearchFilters
|
||||||
sort?: {
|
sort?: SortJson
|
||||||
[key: string]: SortDirection
|
paginate?: PaginationJson
|
||||||
}
|
|
||||||
paginate?: {
|
|
||||||
limit: number
|
|
||||||
page: string | number
|
|
||||||
}
|
|
||||||
body?: object
|
body?: object
|
||||||
extra: {
|
extra?: {
|
||||||
idFilter?: SearchFilters
|
idFilter?: SearchFilters
|
||||||
}
|
}
|
||||||
|
relationships?: RelationshipsJson[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SqlQuery {
|
export interface SqlQuery {
|
|
@ -2,7 +2,7 @@ import {
|
||||||
Integration,
|
Integration,
|
||||||
DatasourceFieldTypes,
|
DatasourceFieldTypes,
|
||||||
QueryTypes,
|
QueryTypes,
|
||||||
} from "./base/definitions"
|
} from "../definitions/datasource"
|
||||||
|
|
||||||
module AirtableModule {
|
module AirtableModule {
|
||||||
const Airtable = require("airtable")
|
const Airtable = require("airtable")
|
||||||
|
|
|
@ -2,7 +2,7 @@ import {
|
||||||
Integration,
|
Integration,
|
||||||
DatasourceFieldTypes,
|
DatasourceFieldTypes,
|
||||||
QueryTypes,
|
QueryTypes,
|
||||||
} from "./base/definitions"
|
} from "../definitions/datasource"
|
||||||
|
|
||||||
module ArangoModule {
|
module ArangoModule {
|
||||||
const { Database, aql } = require("arangojs")
|
const { Database, aql } = require("arangojs")
|
||||||
|
|
|
@ -6,18 +6,23 @@ import {
|
||||||
QueryOptions,
|
QueryOptions,
|
||||||
SortDirection,
|
SortDirection,
|
||||||
Operation,
|
Operation,
|
||||||
} from "./definitions"
|
RelationshipsJson,
|
||||||
|
} from "../../definitions/datasource"
|
||||||
|
|
||||||
|
type KnexQuery = Knex.QueryBuilder | Knex
|
||||||
|
|
||||||
|
// right now we only do filters on the specific table being queried
|
||||||
function addFilters(
|
function addFilters(
|
||||||
query: any,
|
tableName: string,
|
||||||
|
query: KnexQuery,
|
||||||
filters: SearchFilters | undefined
|
filters: SearchFilters | undefined
|
||||||
): Knex.QueryBuilder {
|
): KnexQuery {
|
||||||
function iterate(
|
function iterate(
|
||||||
structure: { [key: string]: any },
|
structure: { [key: string]: any },
|
||||||
fn: (key: string, value: any) => void
|
fn: (key: string, value: any) => void
|
||||||
) {
|
) {
|
||||||
for (let [key, value] of Object.entries(structure)) {
|
for (let [key, value] of Object.entries(structure)) {
|
||||||
fn(key, value)
|
fn(`${tableName}.${key}`, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!filters) {
|
if (!filters) {
|
||||||
|
@ -25,6 +30,12 @@ function addFilters(
|
||||||
}
|
}
|
||||||
// if all or specified in filters, then everything is an or
|
// if all or specified in filters, then everything is an or
|
||||||
const allOr = filters.allOr
|
const allOr = filters.allOr
|
||||||
|
if (filters.oneOf) {
|
||||||
|
iterate(filters.oneOf, (key, array) => {
|
||||||
|
const fnc = allOr ? "orWhereIn" : "whereIn"
|
||||||
|
query = query[fnc](key, array)
|
||||||
|
})
|
||||||
|
}
|
||||||
if (filters.string) {
|
if (filters.string) {
|
||||||
iterate(filters.string, (key, value) => {
|
iterate(filters.string, (key, value) => {
|
||||||
const fnc = allOr ? "orWhere" : "where"
|
const fnc = allOr ? "orWhere" : "where"
|
||||||
|
@ -67,9 +78,47 @@ function addFilters(
|
||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCreate(knex: Knex, json: QueryJson, opts: QueryOptions) {
|
function addRelationships(
|
||||||
|
query: KnexQuery,
|
||||||
|
fromTable: string,
|
||||||
|
relationships: RelationshipsJson[] | undefined
|
||||||
|
): KnexQuery {
|
||||||
|
if (!relationships) {
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
for (let relationship of relationships) {
|
||||||
|
const from = relationship.from,
|
||||||
|
to = relationship.to,
|
||||||
|
toTable = relationship.tableName
|
||||||
|
if (!relationship.through) {
|
||||||
|
// @ts-ignore
|
||||||
|
query = query.leftJoin(
|
||||||
|
toTable,
|
||||||
|
`${fromTable}.${from}`,
|
||||||
|
`${relationship.tableName}.${to}`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const throughTable = relationship.through
|
||||||
|
query = query
|
||||||
|
// @ts-ignore
|
||||||
|
.leftJoin(
|
||||||
|
throughTable,
|
||||||
|
`${fromTable}.${from}`,
|
||||||
|
`${throughTable}.${from}`
|
||||||
|
)
|
||||||
|
.leftJoin(toTable, `${toTable}.${to}`, `${throughTable}.${to}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCreate(
|
||||||
|
knex: Knex,
|
||||||
|
json: QueryJson,
|
||||||
|
opts: QueryOptions
|
||||||
|
): KnexQuery {
|
||||||
const { endpoint, body } = json
|
const { endpoint, body } = json
|
||||||
let query = knex(endpoint.entityId)
|
let query: KnexQuery = knex(endpoint.entityId)
|
||||||
// mysql can't use returning
|
// mysql can't use returning
|
||||||
if (opts.disableReturning) {
|
if (opts.disableReturning) {
|
||||||
return query.insert(body)
|
return query.insert(body)
|
||||||
|
@ -78,9 +127,10 @@ function buildCreate(knex: Knex, json: QueryJson, opts: QueryOptions) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRead(knex: Knex, json: QueryJson, limit: number) {
|
function buildRead(knex: Knex, json: QueryJson, limit: number): KnexQuery {
|
||||||
let { endpoint, resource, filters, sort, paginate } = json
|
let { endpoint, resource, filters, sort, paginate, relationships } = json
|
||||||
let query: Knex.QueryBuilder = knex(endpoint.entityId)
|
const tableName = endpoint.entityId
|
||||||
|
let query: KnexQuery = knex(tableName)
|
||||||
// select all if not specified
|
// select all if not specified
|
||||||
if (!resource) {
|
if (!resource) {
|
||||||
resource = { fields: [] }
|
resource = { fields: [] }
|
||||||
|
@ -92,7 +142,9 @@ function buildRead(knex: Knex, json: QueryJson, limit: number) {
|
||||||
query = query.select("*")
|
query = query.select("*")
|
||||||
}
|
}
|
||||||
// handle where
|
// handle where
|
||||||
query = addFilters(query, filters)
|
query = addFilters(tableName, query, filters)
|
||||||
|
// handle join
|
||||||
|
query = addRelationships(query, tableName, relationships)
|
||||||
// handle sorting
|
// handle sorting
|
||||||
if (sort) {
|
if (sort) {
|
||||||
for (let [key, value] of Object.entries(sort)) {
|
for (let [key, value] of Object.entries(sort)) {
|
||||||
|
@ -114,10 +166,14 @@ function buildRead(knex: Knex, json: QueryJson, limit: number) {
|
||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUpdate(knex: Knex, json: QueryJson, opts: QueryOptions) {
|
function buildUpdate(
|
||||||
|
knex: Knex,
|
||||||
|
json: QueryJson,
|
||||||
|
opts: QueryOptions
|
||||||
|
): KnexQuery {
|
||||||
const { endpoint, body, filters } = json
|
const { endpoint, body, filters } = json
|
||||||
let query = knex(endpoint.entityId)
|
let query: KnexQuery = knex(endpoint.entityId)
|
||||||
query = addFilters(query, filters)
|
query = addFilters(endpoint.entityId, query, filters)
|
||||||
// mysql can't use returning
|
// mysql can't use returning
|
||||||
if (opts.disableReturning) {
|
if (opts.disableReturning) {
|
||||||
return query.update(body)
|
return query.update(body)
|
||||||
|
@ -126,10 +182,14 @@ function buildUpdate(knex: Knex, json: QueryJson, opts: QueryOptions) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDelete(knex: Knex, json: QueryJson, opts: QueryOptions) {
|
function buildDelete(
|
||||||
|
knex: Knex,
|
||||||
|
json: QueryJson,
|
||||||
|
opts: QueryOptions
|
||||||
|
): KnexQuery {
|
||||||
const { endpoint, filters } = json
|
const { endpoint, filters } = json
|
||||||
let query = knex(endpoint.entityId)
|
let query: KnexQuery = knex(endpoint.entityId)
|
||||||
query = addFilters(query, filters)
|
query = addFilters(endpoint.entityId, query, filters)
|
||||||
// mysql can't use returning
|
// mysql can't use returning
|
||||||
if (opts.disableReturning) {
|
if (opts.disableReturning) {
|
||||||
return query.delete()
|
return query.delete()
|
||||||
|
@ -180,6 +240,8 @@ class SqlQueryBuilder {
|
||||||
default:
|
default:
|
||||||
throw `Operation type is not supported by SQL query builder`
|
throw `Operation type is not supported by SQL query builder`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
return query.toSQL().toNative()
|
return query.toSQL().toNative()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@ import {
|
||||||
Integration,
|
Integration,
|
||||||
DatasourceFieldTypes,
|
DatasourceFieldTypes,
|
||||||
QueryTypes,
|
QueryTypes,
|
||||||
} from "./base/definitions"
|
} from "../definitions/datasource"
|
||||||
|
|
||||||
module CouchDBModule {
|
module CouchDBModule {
|
||||||
const PouchDB = require("pouchdb")
|
const PouchDB = require("pouchdb")
|
||||||
|
|
|
@ -2,7 +2,7 @@ import {
|
||||||
Integration,
|
Integration,
|
||||||
DatasourceFieldTypes,
|
DatasourceFieldTypes,
|
||||||
QueryTypes,
|
QueryTypes,
|
||||||
} from "./base/definitions"
|
} from "../definitions/datasource"
|
||||||
|
|
||||||
module DynamoModule {
|
module DynamoModule {
|
||||||
const AWS = require("aws-sdk")
|
const AWS = require("aws-sdk")
|
||||||
|
|
|
@ -2,7 +2,7 @@ import {
|
||||||
Integration,
|
Integration,
|
||||||
DatasourceFieldTypes,
|
DatasourceFieldTypes,
|
||||||
QueryTypes,
|
QueryTypes,
|
||||||
} from "./base/definitions"
|
} from "../definitions/datasource"
|
||||||
|
|
||||||
module ElasticsearchModule {
|
module ElasticsearchModule {
|
||||||
const { Client } = require("@elastic/elasticsearch")
|
const { Client } = require("@elastic/elasticsearch")
|
||||||
|
|
|
@ -9,33 +9,34 @@ const airtable = require("./airtable")
|
||||||
const mysql = require("./mysql")
|
const mysql = require("./mysql")
|
||||||
const arangodb = require("./arangodb")
|
const arangodb = require("./arangodb")
|
||||||
const rest = require("./rest")
|
const rest = require("./rest")
|
||||||
|
const { SourceNames } = require("../definitions/datasource")
|
||||||
|
|
||||||
const DEFINITIONS = {
|
const DEFINITIONS = {
|
||||||
POSTGRES: postgres.schema,
|
[SourceNames.POSTGRES]: postgres.schema,
|
||||||
DYNAMODB: dynamodb.schema,
|
[SourceNames.DYNAMODB]: dynamodb.schema,
|
||||||
MONGODB: mongodb.schema,
|
[SourceNames.MONGODB]: mongodb.schema,
|
||||||
ELASTICSEARCH: elasticsearch.schema,
|
[SourceNames.ELASTICSEARCH]: elasticsearch.schema,
|
||||||
COUCHDB: couchdb.schema,
|
[SourceNames.COUCHDB]: couchdb.schema,
|
||||||
SQL_SERVER: sqlServer.schema,
|
[SourceNames.SQL_SERVER]: sqlServer.schema,
|
||||||
S3: s3.schema,
|
[SourceNames.S3]: s3.schema,
|
||||||
AIRTABLE: airtable.schema,
|
[SourceNames.AIRTABLE]: airtable.schema,
|
||||||
MYSQL: mysql.schema,
|
[SourceNames.MYSQL]: mysql.schema,
|
||||||
ARANGODB: arangodb.schema,
|
[SourceNames.ARANGODB]: arangodb.schema,
|
||||||
REST: rest.schema,
|
[SourceNames.REST]: rest.schema,
|
||||||
}
|
}
|
||||||
|
|
||||||
const INTEGRATIONS = {
|
const INTEGRATIONS = {
|
||||||
POSTGRES: postgres.integration,
|
[SourceNames.POSTGRES]: postgres.integration,
|
||||||
DYNAMODB: dynamodb.integration,
|
[SourceNames.DYNAMODB]: dynamodb.integration,
|
||||||
MONGODB: mongodb.integration,
|
[SourceNames.MONGODB]: mongodb.integration,
|
||||||
ELASTICSEARCH: elasticsearch.integration,
|
[SourceNames.ELASTICSEARCH]: elasticsearch.integration,
|
||||||
COUCHDB: couchdb.integration,
|
[SourceNames.COUCHDB]: couchdb.integration,
|
||||||
S3: s3.integration,
|
[SourceNames.SQL_SERVER]: s3.integration,
|
||||||
SQL_SERVER: sqlServer.integration,
|
[SourceNames.S3]: sqlServer.integration,
|
||||||
AIRTABLE: airtable.integration,
|
[SourceNames.AIRTABLE]: airtable.integration,
|
||||||
MYSQL: mysql.integration,
|
[SourceNames.MYSQL]: mysql.integration,
|
||||||
ARANGODB: arangodb.integration,
|
[SourceNames.ARANGODB]: arangodb.integration,
|
||||||
REST: rest.integration,
|
[SourceNames.REST]: rest.integration,
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue