Merge branch 'master' of github.com:Budibase/budibase into grid-ux-improvements

This commit is contained in:
Andrew Kingston 2024-05-23 11:51:23 +01:00
commit 8378afb3c2
13 changed files with 153 additions and 140 deletions

View File

@ -177,7 +177,7 @@ export const stringifyDate = (
const year = value.year() const year = value.year()
const month = `${value.month() + 1}`.padStart(2, "0") const month = `${value.month() + 1}`.padStart(2, "0")
const day = `${value.date()}`.padStart(2, "0") const day = `${value.date()}`.padStart(2, "0")
return `${year}-${month}-${day}T00:00:00.000` return `${year}-${month}-${day}`
} }
// Otherwise use a normal ISO string with time and timezone // Otherwise use a normal ISO string with time and timezone

View File

@ -237,7 +237,12 @@
const onChangeJSValue = e => { const onChangeJSValue = e => {
jsValue = encodeJSBinding(e.detail) jsValue = encodeJSBinding(e.detail)
updateValue(jsValue) if (!e.detail?.trim()) {
// Don't bother saving empty values as JS
updateValue(null)
} else {
updateValue(jsValue)
}
} }
onMount(() => { onMount(() => {

View File

@ -4,7 +4,6 @@
readableToRuntimeBinding, readableToRuntimeBinding,
runtimeToReadableBinding, runtimeToReadableBinding,
} from "dataBinding" } from "dataBinding"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte" import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { createEventDispatcher, setContext } from "svelte" import { createEventDispatcher, setContext } from "svelte"
import { isJSBinding } from "@budibase/string-templates" import { isJSBinding } from "@budibase/string-templates"

View File

@ -1,5 +1,5 @@
<script> <script>
import { screenStore, componentStore } from "stores/builder" import { screenStore, componentStore, navigationStore } from "stores/builder"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { import {
ActionMenu, ActionMenu,
@ -12,6 +12,7 @@
import ScreenDetailsModal from "components/design/ScreenDetailsModal.svelte" import ScreenDetailsModal from "components/design/ScreenDetailsModal.svelte"
import sanitizeUrl from "helpers/sanitizeUrl" import sanitizeUrl from "helpers/sanitizeUrl"
import { makeComponentUnique } from "helpers/components" import { makeComponentUnique } from "helpers/components"
import { capitalise } from "helpers"
export let screenId export let screenId
@ -48,6 +49,13 @@
try { try {
// Create the screen // Create the screen
await screenStore.save(duplicateScreen) await screenStore.save(duplicateScreen)
// Add new screen to navigation
await navigationStore.saveLink(
duplicateScreen.routing.route,
capitalise(duplicateScreen.routing.route.split("/")[1]),
duplicateScreen.routing.roleId
)
} catch (error) { } catch (error) {
notifications.error("Error duplicating screen") notifications.error("Error duplicating screen")
} }

View File

@ -1,5 +1,5 @@
<script> <script>
import { createEventDispatcher, getContext } from "svelte" import { createEventDispatcher } from "svelte"
import active from "svelte-spa-router/active" import active from "svelte-spa-router/active"
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
@ -13,8 +13,6 @@
export let navStateStore export let navStateStore
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const sdk = getContext("sdk")
const { linkable } = sdk
let renderKey let renderKey
@ -46,10 +44,9 @@
styled styled
--> -->
<a <a
href={url} href="#{url}"
on:click={onClickLink} on:click={onClickLink}
use:active={url} use:active={url}
use:linkable
class:active={false} class:active={false}
> >
{text} {text}
@ -73,10 +70,9 @@
{#each subLinks || [] as subLink} {#each subLinks || [] as subLink}
{#if subLink.internalLink} {#if subLink.internalLink}
<a <a
href={subLink.url} href="#{subLink.url}"
on:click={onClickLink} on:click={onClickLink}
use:active={subLink.url} use:active={subLink.url}
use:linkable
> >
{subLink.text} {subLink.text}
</a> </a>

View File

@ -238,7 +238,13 @@ const triggerAutomationHandler = async action => {
} }
} }
const navigationHandler = action => { const navigationHandler = action => {
const { url, peek, externalNewTab } = action.parameters let { url, peek, externalNewTab, type } = action.parameters
// Ensure in-app navigation starts with a slash
if (type === "screen" && url && !url.startsWith("/")) {
url = `/${url}`
}
routeStore.actions.navigate(url, peek, externalNewTab) routeStore.actions.navigate(url, peek, externalNewTab)
closeSidePanelHandler() closeSidePanelHandler()
} }

View File

@ -485,6 +485,25 @@ describe.each([
) )
expect(response.message).toBe("Cannot create new user entry.") expect(response.message).toBe("Cannot create new user entry.")
}) })
it("should not mis-parse date string out of JSON", async () => {
const table = await config.api.table.save(
saveTableRequest({
schema: {
name: {
type: FieldType.STRING,
name: "name",
},
},
})
)
const row = await config.api.row.save(table._id!, {
name: `{ "foo": "2023-01-26T11:48:57.000Z" }`,
})
expect(row.name).toEqual(`{ "foo": "2023-01-26T11:48:57.000Z" }`)
})
}) })
describe("get", () => { describe("get", () => {

View File

@ -17,6 +17,7 @@ import {
TableSchema, TableSchema,
User, User,
Row, Row,
RelationshipType,
} from "@budibase/types" } from "@budibase/types"
import _ from "lodash" import _ from "lodash"
import tk from "timekeeper" import tk from "timekeeper"
@ -73,7 +74,7 @@ describe.each([
}) })
async function createTable(schema: TableSchema) { async function createTable(schema: TableSchema) {
table = await config.api.table.save( return await config.api.table.save(
tableForDatasource(datasource, { schema }) tableForDatasource(datasource, { schema })
) )
} }
@ -190,7 +191,7 @@ describe.each([
describe("boolean", () => { describe("boolean", () => {
beforeAll(async () => { beforeAll(async () => {
await createTable({ table = await createTable({
isTrue: { name: "isTrue", type: FieldType.BOOLEAN }, isTrue: { name: "isTrue", type: FieldType.BOOLEAN },
}) })
await createRows([{ isTrue: true }, { isTrue: false }]) await createRows([{ isTrue: true }, { isTrue: false }])
@ -320,7 +321,7 @@ describe.each([
}) })
) )
await createTable({ table = await createTable({
name: { name: "name", type: FieldType.STRING }, name: { name: "name", type: FieldType.STRING },
appointment: { name: "appointment", type: FieldType.DATETIME }, appointment: { name: "appointment", type: FieldType.DATETIME },
single_user: { single_user: {
@ -596,7 +597,7 @@ describe.each([
describe.each([FieldType.STRING, FieldType.LONGFORM])("%s", () => { describe.each([FieldType.STRING, FieldType.LONGFORM])("%s", () => {
beforeAll(async () => { beforeAll(async () => {
await createTable({ table = await createTable({
name: { name: "name", type: FieldType.STRING }, name: { name: "name", type: FieldType.STRING },
}) })
await createRows([{ name: "foo" }, { name: "bar" }]) await createRows([{ name: "foo" }, { name: "bar" }])
@ -716,6 +717,20 @@ describe.each([
expectQuery({ expectQuery({
range: { name: { low: "g", high: "h" } }, range: { name: { low: "g", high: "h" } },
}).toFindNothing()) }).toFindNothing())
!isLucene &&
it("ignores low if it's an empty object", () =>
expectQuery({
// @ts-ignore
range: { name: { low: {}, high: "z" } },
}).toContainExactly([{ name: "foo" }, { name: "bar" }]))
!isLucene &&
it("ignores high if it's an empty object", () =>
expectQuery({
// @ts-ignore
range: { name: { low: "a", high: {} } },
}).toContainExactly([{ name: "foo" }, { name: "bar" }]))
}) })
describe("empty", () => { describe("empty", () => {
@ -780,7 +795,7 @@ describe.each([
describe("numbers", () => { describe("numbers", () => {
beforeAll(async () => { beforeAll(async () => {
await createTable({ table = await createTable({
age: { name: "age", type: FieldType.NUMBER }, age: { name: "age", type: FieldType.NUMBER },
}) })
await createRows([{ age: 1 }, { age: 10 }]) await createRows([{ age: 1 }, { age: 10 }])
@ -889,7 +904,7 @@ describe.each([
const JAN_10TH = "2020-01-10T00:00:00.000Z" const JAN_10TH = "2020-01-10T00:00:00.000Z"
beforeAll(async () => { beforeAll(async () => {
await createTable({ table = await createTable({
dob: { name: "dob", type: FieldType.DATETIME }, dob: { name: "dob", type: FieldType.DATETIME },
}) })
@ -1012,7 +1027,7 @@ describe.each([
const NULL_TIME__ID = `null_time__id` const NULL_TIME__ID = `null_time__id`
beforeAll(async () => { beforeAll(async () => {
await createTable({ table = await createTable({
timeid: { name: "timeid", type: FieldType.STRING }, timeid: { name: "timeid", type: FieldType.STRING },
time: { name: "time", type: FieldType.DATETIME, timeOnly: true }, time: { name: "time", type: FieldType.DATETIME, timeOnly: true },
}) })
@ -1154,7 +1169,7 @@ describe.each([
describe.each([FieldType.ARRAY, FieldType.OPTIONS])("%s", () => { describe.each([FieldType.ARRAY, FieldType.OPTIONS])("%s", () => {
beforeAll(async () => { beforeAll(async () => {
await createTable({ table = await createTable({
numbers: { numbers: {
name: "numbers", name: "numbers",
type: FieldType.ARRAY, type: FieldType.ARRAY,
@ -1234,7 +1249,7 @@ describe.each([
const BIG = "9223372036854775807" const BIG = "9223372036854775807"
beforeAll(async () => { beforeAll(async () => {
await createTable({ table = await createTable({
num: { name: "num", type: FieldType.BIGINT }, num: { name: "num", type: FieldType.BIGINT },
}) })
await createRows([{ num: SMALL }, { num: MEDIUM }, { num: BIG }]) await createRows([{ num: SMALL }, { num: MEDIUM }, { num: BIG }])
@ -1325,7 +1340,7 @@ describe.each([
isInternal && isInternal &&
describe("auto", () => { describe("auto", () => {
beforeAll(async () => { beforeAll(async () => {
await createTable({ table = await createTable({
auto: { auto: {
name: "auto", name: "auto",
type: FieldType.AUTO, type: FieldType.AUTO,
@ -1452,6 +1467,25 @@ describe.each([
{ auto: 2 }, { auto: 2 },
{ auto: 1 }, { auto: 1 },
])) ]))
// This is important for pagination. The order of results must always
// be stable or pagination will break. We don't want the user to need
// to specify an order for pagination to work.
it("is stable without a sort specified", async () => {
let { rows } = await config.api.row.search(table._id!, {
tableId: table._id!,
query: {},
})
for (let i = 0; i < 10; i++) {
const response = await config.api.row.search(table._id!, {
tableId: table._id!,
limit: 1,
query: {},
})
expect(response.rows).toEqual(rows)
}
})
}) })
// TODO(samwho): fix for SQS // TODO(samwho): fix for SQS
@ -1490,7 +1524,7 @@ describe.each([
describe("field name 1:name", () => { describe("field name 1:name", () => {
beforeAll(async () => { beforeAll(async () => {
await createTable({ table = await createTable({
"1:name": { name: "1:name", type: FieldType.STRING }, "1:name": { name: "1:name", type: FieldType.STRING },
}) })
await createRows([{ "1:name": "bar" }, { "1:name": "foo" }]) await createRows([{ "1:name": "bar" }, { "1:name": "foo" }])
@ -1506,4 +1540,51 @@ describe.each([
expectQuery({ equal: { "1:1:name": "none" } }).toFindNothing()) expectQuery({ equal: { "1:1:name": "none" } }).toFindNothing())
}) })
}) })
// This will never work for Lucene.
!isLucene &&
describe("relations", () => {
let otherTable: Table
let rows: Row[]
beforeAll(async () => {
otherTable = await createTable({
one: { name: "one", type: FieldType.STRING },
})
table = await createTable({
two: { name: "two", type: FieldType.STRING },
other: {
type: FieldType.LINK,
relationshipType: RelationshipType.ONE_TO_MANY,
name: "other",
fieldName: "other",
tableId: otherTable._id!,
constraints: {
type: "array",
},
},
})
rows = await Promise.all([
config.api.row.save(otherTable._id!, { one: "foo" }),
config.api.row.save(otherTable._id!, { one: "bar" }),
])
await Promise.all([
config.api.row.save(table._id!, {
two: "foo",
other: [rows[0]._id],
}),
config.api.row.save(table._id!, {
two: "bar",
other: [rows[1]._id],
}),
])
})
it("can search through relations", () =>
expectQuery({
equal: { [`${otherTable.name}.one`]: "foo" },
}).toContainExactly([{ two: "foo", other: [{ _id: rows[0]._id }] }]))
})
}) })

View File

@ -56,16 +56,6 @@ function generateReadJson({
} }
} }
function generateCreateJson(table = TABLE_NAME, body = {}): QueryJson {
return {
endpoint: endpoint(table, "CREATE"),
meta: {
table: TABLE,
},
body,
}
}
function generateRelationshipJson(config: { schema?: string } = {}): QueryJson { function generateRelationshipJson(config: { schema?: string } = {}): QueryJson {
return { return {
endpoint: { endpoint: {
@ -146,24 +136,6 @@ describe("SQL query builder", () => {
sql = new Sql(client, limit) sql = new Sql(client, limit)
}) })
it("should allow filtering on a related field", () => {
const query = sql._query(
generateReadJson({
filters: {
equal: {
age: 10,
"task.name": "task 1",
},
},
})
)
// order of bindings changes because relationship filters occur outside inner query
expect(query).toEqual({
bindings: [10, limit, "task 1"],
sql: `select * from (select * from "${TABLE_NAME}" where "${TABLE_NAME}"."age" = $1 limit $2) as "${TABLE_NAME}" where "task"."name" = $3`,
})
})
it("should add the schema to the LEFT JOIN", () => { it("should add the schema to the LEFT JOIN", () => {
const query = sql._query(generateRelationshipJson({ schema: "production" })) const query = sql._query(generateRelationshipJson({ schema: "production" }))
expect(query).toEqual({ expect(query).toEqual({
@ -190,44 +162,6 @@ describe("SQL query builder", () => {
}) })
}) })
it("should ignore high range value if it is an empty object", () => {
const query = sql._query(
generateReadJson({
filters: {
range: {
dob: {
low: "2000-01-01 00:00:00",
high: {},
},
},
},
})
)
expect(query).toEqual({
bindings: ["2000-01-01 00:00:00", 500],
sql: `select * from (select * from "${TABLE_NAME}" where "${TABLE_NAME}"."dob" >= $1 limit $2) as "${TABLE_NAME}"`,
})
})
it("should ignore low range value if it is an empty object", () => {
const query = sql._query(
generateReadJson({
filters: {
range: {
dob: {
low: {},
high: "2010-01-01 00:00:00",
},
},
},
})
)
expect(query).toEqual({
bindings: ["2010-01-01 00:00:00", 500],
sql: `select * from (select * from "${TABLE_NAME}" where "${TABLE_NAME}"."dob" <= $1 limit $2) as "${TABLE_NAME}"`,
})
})
it("should lowercase the values for Oracle LIKE statements", () => { it("should lowercase the values for Oracle LIKE statements", () => {
let query = new Sql(SqlClient.ORACLE, limit)._query( let query = new Sql(SqlClient.ORACLE, limit)._query(
generateReadJson({ generateReadJson({
@ -272,44 +206,4 @@ describe("SQL query builder", () => {
sql: `select * from (select * from (select * from "test" where LOWER("test"."name") LIKE :1) where rownum <= :2) "test"`, sql: `select * from (select * from (select * from "test" where LOWER("test"."name") LIKE :1) where rownum <= :2) "test"`,
}) })
}) })
it("should sort SQL Server tables by the primary key if no sort data is provided", () => {
let query = new Sql(SqlClient.MS_SQL, limit)._query(
generateReadJson({
sort: {},
paginate: {
limit: 10,
},
})
)
expect(query).toEqual({
bindings: [10],
sql: `select * from (select top (@p0) * from [test] order by [test].[id] asc) as [test]`,
})
})
it("should not parse JSON string as Date", () => {
let query = new Sql(SqlClient.POSTGRES, limit)._query(
generateCreateJson(TABLE_NAME, {
name: '{ "created_at":"2023-09-09T03:21:06.024Z" }',
})
)
expect(query).toEqual({
bindings: ['{ "created_at":"2023-09-09T03:21:06.024Z" }'],
sql: `insert into "test" ("name") values ($1) returning *`,
})
})
it("should parse and trim valid string as Date", () => {
const dateObj = new Date("2023-09-09T03:21:06.024Z")
let query = new Sql(SqlClient.POSTGRES, limit)._query(
generateCreateJson(TABLE_NAME, {
name: " 2023-09-09T03:21:06.024Z ",
})
)
expect(query).toEqual({
bindings: [dateObj],
sql: `insert into "test" ("name") values ($1) returning *`,
})
})
}) })

View File

@ -55,8 +55,8 @@ function buildInternalFieldList(
return fieldList return fieldList
} }
function tableInFilter(name: string) { function tableNameInFieldRegex(tableName: string) {
return `:${name}.` return new RegExp(`^${tableName}.|:${tableName}.`, "g")
} }
function cleanupFilters(filters: SearchFilters, tables: Table[]) { function cleanupFilters(filters: SearchFilters, tables: Table[]) {
@ -72,15 +72,13 @@ function cleanupFilters(filters: SearchFilters, tables: Table[]) {
// relationship, switch to table ID // relationship, switch to table ID
const tableRelated = tables.find( const tableRelated = tables.find(
table => table =>
table.originalName && key.includes(tableInFilter(table.originalName)) table.originalName &&
key.match(tableNameInFieldRegex(table.originalName))
) )
if (tableRelated && tableRelated.originalName) { if (tableRelated && tableRelated.originalName) {
filter[ // only replace the first, not replaceAll
key.replace( filter[key.replace(tableRelated.originalName, tableRelated._id!)] =
tableInFilter(tableRelated.originalName), filter[key]
tableInFilter(tableRelated._id!)
)
] = filter[key]
delete filter[key] delete filter[key]
} }
} }

View File

@ -37,7 +37,7 @@ export function tableForDatasource(
): Table { ): Table {
return merge( return merge(
{ {
name: generator.guid(), name: generator.guid().substring(0, 10),
type: "table", type: "table",
sourceType: datasource sourceType: datasource
? TableSourceType.EXTERNAL ? TableSourceType.EXTERNAL

View File

@ -105,6 +105,9 @@ export function processDates<T extends Row | Row[]>(
if (schema.type !== FieldType.DATETIME) { if (schema.type !== FieldType.DATETIME) {
continue continue
} }
if (schema.dateOnly) {
continue
}
if (!schema.timeOnly && !schema.ignoreTimezones) { if (!schema.timeOnly && !schema.ignoreTimezones) {
datesWithTZ.push(column) datesWithTZ.push(column)
} }

View File

@ -134,7 +134,11 @@ export function parse(rows: Rows, schema: TableSchema): Rows {
if (columnType === FieldType.NUMBER) { if (columnType === FieldType.NUMBER) {
// If provided must be a valid number // If provided must be a valid number
parsedRow[columnName] = columnData ? Number(columnData) : columnData parsedRow[columnName] = columnData ? Number(columnData) : columnData
} else if (columnType === FieldType.DATETIME && !columnSchema.timeOnly) { } else if (
columnType === FieldType.DATETIME &&
!columnSchema.timeOnly &&
!columnSchema.dateOnly
) {
// If provided must be a valid date // If provided must be a valid date
parsedRow[columnName] = columnData parsedRow[columnName] = columnData
? new Date(columnData).toISOString() ? new Date(columnData).toISOString()