@budibase/frontend-core 3.5.2 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@budibase/frontend-core",
3
- "version": "3.5.2",
3
+ "version": "3.6.0",
4
4
  "description": "Budibase frontend core libraries used in builder and client",
5
5
  "author": "Budibase",
6
6
  "license": "MPL-2.0",
@@ -17,5 +17,5 @@
17
17
  "shortid": "2.2.15",
18
18
  "socket.io-client": "^4.7.5"
19
19
  },
20
- "gitHead": "2d3a130fceeb689d3013606dde57bab8db41c586"
20
+ "gitHead": "fcc8291c1f2968ee7fd17bd47ee6ca19181a14a7"
21
21
  }
package/src/api/ai.ts CHANGED
@@ -1,7 +1,9 @@
1
+ import { GenerateJsRequest, GenerateJsResponse } from "@budibase/types"
1
2
  import { BaseAPIClient } from "./types"
2
3
 
3
4
  export interface AIEndpoints {
4
5
  generateCronExpression: (prompt: string) => Promise<{ message: string }>
6
+ generateJs: (req: GenerateJsRequest) => Promise<GenerateJsResponse>
5
7
  }
6
8
 
7
9
  export const buildAIEndpoints = (API: BaseAPIClient): AIEndpoints => ({
@@ -14,4 +16,11 @@ export const buildAIEndpoints = (API: BaseAPIClient): AIEndpoints => ({
14
16
  body: { prompt },
15
17
  })
16
18
  },
19
+
20
+ generateJs: async req => {
21
+ return await API.post({
22
+ url: "/api/ai/js",
23
+ body: req,
24
+ })
25
+ },
17
26
  })
package/src/api/index.ts CHANGED
@@ -45,6 +45,7 @@ import { buildAuditLogEndpoints } from "./auditLogs"
45
45
  import { buildLogsEndpoints } from "./logs"
46
46
  import { buildMigrationEndpoints } from "./migrations"
47
47
  import { buildRowActionEndpoints } from "./rowActions"
48
+ import { buildOAuth2Endpoints } from "./oauth2"
48
49
 
49
50
  export type { APIClient } from "./types"
50
51
 
@@ -290,5 +291,6 @@ export const createAPIClient = (config: APIClientConfig = {}): APIClient => {
290
291
  ...buildMigrationEndpoints(API),
291
292
  viewV2: buildViewV2Endpoints(API),
292
293
  rowActions: buildRowActionEndpoints(API),
294
+ oauth2: buildOAuth2Endpoints(API),
293
295
  }
294
296
  }
@@ -0,0 +1,86 @@
1
+ import {
2
+ FetchOAuth2ConfigsResponse,
3
+ InsertOAuth2ConfigRequest,
4
+ InsertOAuth2ConfigResponse,
5
+ OAuth2ConfigResponse,
6
+ UpdateOAuth2ConfigRequest,
7
+ UpdateOAuth2ConfigResponse,
8
+ ValidateConfigRequest,
9
+ ValidateConfigResponse,
10
+ } from "@budibase/types"
11
+ import { BaseAPIClient } from "./types"
12
+
13
+ export interface OAuth2Endpoints {
14
+ fetch: () => Promise<OAuth2ConfigResponse[]>
15
+ create: (
16
+ config: InsertOAuth2ConfigRequest
17
+ ) => Promise<InsertOAuth2ConfigResponse>
18
+ update: (
19
+ config: UpdateOAuth2ConfigRequest
20
+ ) => Promise<UpdateOAuth2ConfigResponse>
21
+ delete: (id: string, rev: string) => Promise<void>
22
+ validate: (config: ValidateConfigRequest) => Promise<ValidateConfigResponse>
23
+ }
24
+
25
+ export const buildOAuth2Endpoints = (API: BaseAPIClient): OAuth2Endpoints => ({
26
+ /**
27
+ * Gets all OAuth2 configurations for the app.
28
+ */
29
+ fetch: async () => {
30
+ return (
31
+ await API.get<FetchOAuth2ConfigsResponse>({
32
+ url: `/api/oauth2`,
33
+ })
34
+ ).configs
35
+ },
36
+
37
+ /**
38
+ * Creates a OAuth2 configuration.
39
+ */
40
+ create: async config => {
41
+ return await API.post<
42
+ InsertOAuth2ConfigRequest,
43
+ InsertOAuth2ConfigResponse
44
+ >({
45
+ url: `/api/oauth2`,
46
+ body: {
47
+ ...config,
48
+ },
49
+ })
50
+ },
51
+
52
+ /**
53
+ * Updates an existing OAuth2 configuration.
54
+ */
55
+ update: async config => {
56
+ return await API.put<UpdateOAuth2ConfigRequest, UpdateOAuth2ConfigResponse>(
57
+ {
58
+ url: `/api/oauth2/${config._id}`,
59
+ body: {
60
+ ...config,
61
+ },
62
+ }
63
+ )
64
+ },
65
+
66
+ /**
67
+ * Deletes an OAuth2 configuration by its id.
68
+ * @param id the ID of the OAuth2 config
69
+ * @param rev the rev of the OAuth2 config
70
+ */
71
+ delete: async (id, rev) => {
72
+ return await API.delete<void, void>({
73
+ url: `/api/oauth2/${id}/${rev}`,
74
+ })
75
+ },
76
+ validate: async function (
77
+ config: ValidateConfigRequest
78
+ ): Promise<ValidateConfigResponse> {
79
+ return await API.post<ValidateConfigRequest, ValidateConfigResponse>({
80
+ url: `/api/oauth2/validate`,
81
+ body: {
82
+ ...config,
83
+ },
84
+ })
85
+ },
86
+ })
package/src/api/types.ts CHANGED
@@ -16,6 +16,7 @@ import { LayoutEndpoints } from "./layouts"
16
16
  import { LicensingEndpoints } from "./licensing"
17
17
  import { LogEndpoints } from "./logs"
18
18
  import { MigrationEndpoints } from "./migrations"
19
+ import { OAuth2Endpoints } from "./oauth2"
19
20
  import { OtherEndpoints } from "./other"
20
21
  import { PermissionEndpoints } from "./permissions"
21
22
  import { PluginEndpoins } from "./plugins"
@@ -132,4 +133,8 @@ export type APIClient = BaseAPIClient &
132
133
  TableEndpoints &
133
134
  TemplateEndpoints &
134
135
  UserEndpoints &
135
- ViewEndpoints & { rowActions: RowActionEndpoints; viewV2: ViewV2Endpoints }
136
+ ViewEndpoints & {
137
+ rowActions: RowActionEndpoints
138
+ viewV2: ViewV2Endpoints
139
+ oauth2: OAuth2Endpoints
140
+ }
@@ -0,0 +1,43 @@
1
+ <script lang="ts">
2
+ import { ModalContent, Body, notifications } from "@budibase/bbui"
3
+ import PasswordRepeatInput from "./PasswordRepeatInput.svelte"
4
+ import type { APIClient } from "@budibase/frontend-core"
5
+ import { createEventDispatcher } from "svelte"
6
+
7
+ export let API: APIClient
8
+ export let passwordMinLength: string | undefined = undefined
9
+ export let notifySuccess = notifications.success
10
+ export let notifyError = notifications.error
11
+
12
+ const dispatch = createEventDispatcher()
13
+
14
+ let password: string = ""
15
+ let error: string = ""
16
+
17
+ const updatePassword = async () => {
18
+ try {
19
+ await API.updateSelf({ password })
20
+ notifySuccess("Password changed successfully")
21
+ dispatch("save")
22
+ } catch (error) {
23
+ notifyError("Failed to update password")
24
+ }
25
+ }
26
+
27
+ const handleKeydown = (evt: KeyboardEvent) => {
28
+ if (evt.key === "Enter" && !error && password) {
29
+ updatePassword()
30
+ }
31
+ }
32
+ </script>
33
+
34
+ <svelte:window on:keydown={handleKeydown} />
35
+ <ModalContent
36
+ title="Update password"
37
+ confirmText="Update password"
38
+ onConfirm={updatePassword}
39
+ disabled={!!error || !password}
40
+ >
41
+ <Body size="S">Enter your new password below.</Body>
42
+ <PasswordRepeatInput bind:password bind:error minLength={passwordMinLength} />
43
+ </ModalContent>
@@ -0,0 +1,54 @@
1
+ <script lang="ts">
2
+ import { FancyForm, FancyInput } from "@budibase/bbui"
3
+ import { createValidationStore, requiredValidator } from "../utils/validation"
4
+
5
+ export let passwordForm: FancyForm | undefined = undefined
6
+ export let password: string
7
+ export let error: string
8
+ export let minLength = "12"
9
+
10
+ const validatePassword = (value: string | undefined) => {
11
+ if (!value || value.length < parseInt(minLength)) {
12
+ return `Please enter at least ${minLength} characters. We recommend using machine generated or random passwords.`
13
+ }
14
+ return null
15
+ }
16
+
17
+ const [firstPassword, passwordError, firstTouched] = createValidationStore(
18
+ "",
19
+ requiredValidator
20
+ )
21
+ const [repeatPassword, _, repeatTouched] = createValidationStore(
22
+ "",
23
+ requiredValidator,
24
+ validatePassword
25
+ )
26
+
27
+ $: password = $firstPassword
28
+ $: firstPasswordError =
29
+ ($firstTouched && $passwordError) ||
30
+ ($repeatTouched && validatePassword(password))
31
+ $: error =
32
+ !$firstPassword ||
33
+ !$firstTouched ||
34
+ !$repeatTouched ||
35
+ $firstPassword !== $repeatPassword ||
36
+ firstPasswordError
37
+ </script>
38
+
39
+ <FancyForm bind:this={passwordForm}>
40
+ <FancyInput
41
+ label="Password"
42
+ type="password"
43
+ error={firstPasswordError}
44
+ bind:value={$firstPassword}
45
+ />
46
+ <FancyInput
47
+ label="Repeat password"
48
+ type="password"
49
+ error={$repeatTouched &&
50
+ $firstPassword !== $repeatPassword &&
51
+ "Passwords must match"}
52
+ bind:value={$repeatPassword}
53
+ />
54
+ </FancyForm>
@@ -0,0 +1,39 @@
1
+ <script lang="ts">
2
+ import { writable } from "svelte/store"
3
+ import { ModalContent, Body, Input, notifications } from "@budibase/bbui"
4
+ import type { User, ContextUser } from "@budibase/types"
5
+ import type { APIClient } from "@budibase/frontend-core"
6
+ import { createEventDispatcher } from "svelte"
7
+
8
+ export let user: User | ContextUser | undefined = undefined
9
+ export let API: APIClient
10
+ export let notifySuccess = notifications.success
11
+ export let notifyError = notifications.error
12
+
13
+ const dispatch = createEventDispatcher()
14
+
15
+ const values = writable({
16
+ firstName: user?.firstName,
17
+ lastName: user?.lastName,
18
+ })
19
+
20
+ const updateInfo = async () => {
21
+ try {
22
+ await API.updateSelf($values)
23
+ notifySuccess("Information updated successfully")
24
+ dispatch("save")
25
+ } catch (error) {
26
+ console.error(error)
27
+ notifyError("Failed to update information")
28
+ }
29
+ }
30
+ </script>
31
+
32
+ <ModalContent title="My profile" confirmText="Save" onConfirm={updateInfo}>
33
+ <Body size="S">
34
+ Personalise the platform by adding your first name and last name.
35
+ </Body>
36
+ <Input disabled value={user?.email || ""} label="Email" />
37
+ <Input bind:value={$values.firstName} label="First name" />
38
+ <Input bind:value={$values.lastName} label="Last name" />
39
+ </ModalContent>
@@ -56,7 +56,7 @@
56
56
  rowIdx={row?.__idx}
57
57
  metadata={row?.__metadata?.row}
58
58
  >
59
- <div class="gutter">
59
+ <div class="gutter" class:selectable={$config.canSelectRows}>
60
60
  {#if $$slots.default}
61
61
  <slot />
62
62
  {:else}
@@ -116,12 +116,9 @@
116
116
  margin: 3px 0 0 0;
117
117
  }
118
118
  .number {
119
- color: val(--cell-font-color, var(--spectrum-global-color-gray-500));
120
- }
121
- .checkbox.visible,
122
- .number.visible {
123
- display: flex;
119
+ color: var(--spectrum-global-color-gray-500);
124
120
  }
121
+
125
122
  .delete,
126
123
  .expand {
127
124
  margin-right: 4px;
@@ -137,4 +134,11 @@
137
134
  .delete:hover :global(.spectrum-Icon) {
138
135
  color: var(--spectrum-global-color-red-600) !important;
139
136
  }
137
+
138
+ /* Visibility of checkbox and number */
139
+ .gutter.selectable .checkbox.visible,
140
+ .number.visible,
141
+ .gutter:not(.selectable) .number {
142
+ display: flex;
143
+ }
140
144
  </style>
@@ -303,9 +303,11 @@
303
303
  />
304
304
  {/if}
305
305
 
306
- <div class="column-icon">
307
- <Icon size="S" name={getColumnIcon(column)} />
308
- </div>
306
+ {#if !$config.quiet}
307
+ <div class="column-icon">
308
+ <Icon size="S" name={getColumnIcon(column)} />
309
+ </div>
310
+ {/if}
309
311
  <div class="search-icon" on:click={startSearching}>
310
312
  <Icon hoverable size="S" name="Search" />
311
313
  </div>
@@ -431,7 +433,7 @@
431
433
  .header-cell :global(.cell) {
432
434
  padding: 0 var(--cell-padding);
433
435
  gap: calc(2 * var(--cell-spacing));
434
- background: var(--grid-background-alt);
436
+ background: var(--header-cell-background);
435
437
  }
436
438
 
437
439
  /* Icon colors */
@@ -463,6 +465,7 @@
463
465
  white-space: nowrap;
464
466
  text-overflow: ellipsis;
465
467
  overflow: hidden;
468
+ font-weight: 600;
466
469
  }
467
470
  .header-cell.searching .name {
468
471
  opacity: 0;
@@ -217,6 +217,10 @@
217
217
  --accent-color: var(--primaryColor, var(--spectrum-global-color-blue-400));
218
218
  --grid-background: var(--spectrum-global-color-gray-50);
219
219
  --grid-background-alt: var(--spectrum-global-color-gray-100);
220
+ --header-cell-background: var(
221
+ --custom-header-cell-background,
222
+ var(--spectrum-global-color-gray-100)
223
+ );
220
224
  --cell-background: var(--grid-background);
221
225
  --cell-background-hover: var(--grid-background-alt);
222
226
  --cell-background-alt: var(--cell-background);
@@ -246,7 +250,10 @@
246
250
  cursor: grabbing !important;
247
251
  }
248
252
  .grid.stripe {
249
- --cell-background-alt: var(--spectrum-global-color-gray-75);
253
+ --cell-background-alt: var(
254
+ --custom-stripe-cell-background,
255
+ var(--spectrum-global-color-gray-75)
256
+ );
250
257
  }
251
258
 
252
259
  /* Data layers */
@@ -352,11 +359,18 @@
352
359
 
353
360
  /* Overrides for quiet */
354
361
  .grid.quiet :global(.grid-data-content .row > .cell:not(:last-child)),
355
- .grid.quiet :global(.sticky-column .row > .cell),
356
- .grid.quiet :global(.new-row .row > .cell:not(:last-child)) {
362
+ .grid.quiet :global(.sticky-column .row .cell),
363
+ .grid.quiet :global(.new-row .row > .cell:not(:last-child)),
364
+ .grid.quiet :global(.header-cell:not(:last-child) .cell) {
357
365
  border-right: none;
358
366
  }
359
367
  .grid.quiet :global(.sticky-column:before) {
360
368
  display: none;
361
369
  }
370
+ .grid.quiet:not(.stripe) {
371
+ --header-cell-background: var(
372
+ --custom-header-cell-background,
373
+ var(--grid-background)
374
+ );
375
+ }
362
376
  </style>
@@ -36,7 +36,7 @@
36
36
 
37
37
  <style>
38
38
  .header {
39
- background: var(--grid-background-alt);
39
+ background: var(--header-cell-background);
40
40
  border-bottom: var(--cell-border);
41
41
  position: relative;
42
42
  height: var(--default-row-height);
@@ -4,7 +4,7 @@
4
4
  import GridScrollWrapper from "./GridScrollWrapper.svelte"
5
5
  import DataCell from "../cells/DataCell.svelte"
6
6
  import { fade } from "svelte/transition"
7
- import { GutterWidth, NewRowID } from "../lib/constants"
7
+ import { DefaultRowHeight, GutterWidth, NewRowID } from "../lib/constants"
8
8
  import GutterCell from "../cells/GutterCell.svelte"
9
9
  import KeyboardShortcut from "./KeyboardShortcut.svelte"
10
10
  import { getCellID } from "../lib/utils"
@@ -33,6 +33,7 @@
33
33
  columnRenderMap,
34
34
  visibleColumns,
35
35
  scrollTop,
36
+ height,
36
37
  } = getContext("grid")
37
38
 
38
39
  let visible = false
@@ -47,6 +48,8 @@
47
48
  $: hasNoRows = !$rows.length
48
49
  $: renderedRowCount = $renderedRows.length
49
50
  $: offset = getOffset($hasNextPage, renderedRowCount, $rowHeight, $scrollTop)
51
+ $: spaceBelow = $height - offset - $rowHeight
52
+ $: flipButtons = spaceBelow < 36 + DefaultRowHeight
50
53
 
51
54
  const getOffset = (hasNextPage, rowCount, rowHeight, scrollTop) => {
52
55
  // If we have a next page of data then we aren't truly at the bottom, so we
@@ -244,7 +247,11 @@
244
247
  </div>
245
248
  </GridScrollWrapper>
246
249
  </div>
247
- <div class="buttons" transition:fade|local={{ duration: 130 }}>
250
+ <div
251
+ class="buttons"
252
+ class:flip={flipButtons}
253
+ transition:fade|local={{ duration: 130 }}
254
+ >
248
255
  <Button size="M" cta on:click={addRow} disabled={isAdding}>
249
256
  <div class="button-with-keys">
250
257
  Save
@@ -337,6 +344,9 @@
337
344
  .button-with-keys :global(> div) {
338
345
  padding-top: 2px;
339
346
  }
347
+ .buttons.flip {
348
+ top: calc(var(--offset) - 36px - var(--default-row-height) / 2);
349
+ }
340
350
 
341
351
  /* Sticky column styles */
342
352
  .sticky-column {
@@ -169,7 +169,7 @@
169
169
  z-index: 1;
170
170
  }
171
171
  .header :global(.cell) {
172
- background: var(--grid-background-alt);
172
+ background: var(--header-cell-background);
173
173
  }
174
174
  .header :global(.cell::before) {
175
175
  display: none;
@@ -2,8 +2,8 @@ export const SmallRowHeight = 36
2
2
  export const MediumRowHeight = 64
3
3
  export const LargeRowHeight = 92
4
4
  export const DefaultRowHeight = SmallRowHeight
5
- export const VPadding = SmallRowHeight * 2
6
- export const HPadding = 40
5
+ export const VPadding = 0
6
+ export const HPadding = 80
7
7
  export const ScrollBarSize = 8
8
8
  export const GutterWidth = 72
9
9
  export const DefaultColumnWidth = 200
@@ -140,13 +140,13 @@
140
140
  div {
141
141
  position: absolute;
142
142
  background: var(--spectrum-global-color-gray-500);
143
- opacity: 0.5;
143
+ opacity: 0.35;
144
144
  border-radius: 4px;
145
145
  transition: opacity 130ms ease-out;
146
146
  }
147
147
  div:hover,
148
148
  div.dragging {
149
- opacity: 1;
149
+ opacity: 0.8;
150
150
  }
151
151
  .v-scrollbar {
152
152
  width: var(--scroll-bar-size);
@@ -7,8 +7,12 @@ type ConfigStore = {
7
7
  [key in keyof BaseStoreProps]: Readable<BaseStoreProps[key]>
8
8
  }
9
9
 
10
+ interface ConfigState extends BaseStoreProps {
11
+ canSelectRows: boolean
12
+ }
13
+
10
14
  interface ConfigDerivedStore {
11
- config: Readable<BaseStoreProps>
15
+ config: Readable<ConfigState>
12
16
  }
13
17
 
14
18
  export type Store = ConfigStore & ConfigDerivedStore
@@ -47,7 +51,7 @@ export const deriveStores = (context: StoreContext): ConfigDerivedStore => {
47
51
  const config = derived(
48
52
  [props, definition, hasNonAutoColumn],
49
53
  ([$props, $definition, $hasNonAutoColumn]) => {
50
- let config = { ...$props }
54
+ let config: ConfigState = { ...$props, canSelectRows: false }
51
55
  const type = $props.datasource?.type
52
56
 
53
57
  // Disable some features if we're editing a view
@@ -78,6 +82,9 @@ export const deriveStores = (context: StoreContext): ConfigDerivedStore => {
78
82
  config.canEditColumns = false
79
83
  }
80
84
 
85
+ // Determine if we can select rows
86
+ config.canSelectRows = !!config.canDeleteRows || !!config.canAddRows
87
+
81
88
  return config
82
89
  }
83
90
  )
@@ -36,6 +36,7 @@ const DependencyOrderedStores = [
36
36
  NonPlus,
37
37
  Datasource,
38
38
  Columns,
39
+ Config as any,
39
40
  Scroll,
40
41
  Validation,
41
42
  Rows,
@@ -47,7 +48,6 @@ const DependencyOrderedStores = [
47
48
  Users,
48
49
  Menu,
49
50
  Pagination,
50
- Config as any,
51
51
  Clipboard,
52
52
  Notifications,
53
53
  Cache,
@@ -58,6 +58,7 @@ export const deriveStores = (context: StoreContext) => {
58
58
  width,
59
59
  height,
60
60
  buttonColumnWidth,
61
+ config,
61
62
  } = context
62
63
 
63
64
  // Memoize store primitives
@@ -97,11 +98,14 @@ export const deriveStores = (context: StoreContext) => {
97
98
 
98
99
  // Derive vertical limits
99
100
  const contentHeight = derived(
100
- [rows, rowHeight, showHScrollbar],
101
- ([$rows, $rowHeight, $showHScrollbar]) => {
102
- let height = ($rows.length + 1) * $rowHeight + VPadding
101
+ [rows, rowHeight, showHScrollbar, config],
102
+ ([$rows, $rowHeight, $showHScrollbar, $config]) => {
103
+ let height = $rows.length * $rowHeight + VPadding
103
104
  if ($showHScrollbar) {
104
- height += ScrollBarSize * 2
105
+ height += ScrollBarSize * 3
106
+ }
107
+ if ($config.canAddRows) {
108
+ height += $rowHeight
105
109
  }
106
110
  return height
107
111
  }
@@ -9,3 +9,6 @@ export { Grid } from "./grid"
9
9
  export { default as ClientAppSkeleton } from "./ClientAppSkeleton.svelte"
10
10
  export { default as CoreFilterBuilder } from "./CoreFilterBuilder.svelte"
11
11
  export { default as FilterUsers } from "./FilterUsers.svelte"
12
+ export { default as ChangePasswordModal } from "./ChangePasswordModal.svelte"
13
+ export { default as ProfileModal } from "./ProfileModal.svelte"
14
+ export { default as PasswordRepeatInput } from "./PasswordRepeatInput.svelte"
package/src/constants.ts CHANGED
@@ -166,3 +166,9 @@ export const FieldPermissions = {
166
166
  READONLY: "readonly",
167
167
  HIDDEN: "hidden",
168
168
  }
169
+
170
+ // one or more word characters and whitespace
171
+ export const APP_NAME_REGEX = /^[\w\s]+$/
172
+
173
+ // zero or more non-whitespace characters
174
+ export const APP_URL_REGEX = /^[0-9a-zA-Z-_]+$/
@@ -8,6 +8,7 @@ import {
8
8
  Row,
9
9
  SearchFilters,
10
10
  SortOrder,
11
+ SortType,
11
12
  TableSchema,
12
13
  } from "@budibase/types"
13
14
  import { APIClient } from "../api/types"
@@ -71,6 +72,8 @@ export default abstract class BaseDataFetch<
71
72
  options: DataFetchOptions<TQuery> & {
72
73
  datasource: TDatasource
73
74
 
75
+ sortType: SortType | null
76
+
74
77
  // Client side feature customisation
75
78
  clientSideSearching: boolean
76
79
  clientSideSorting: boolean
@@ -103,6 +106,7 @@ export default abstract class BaseDataFetch<
103
106
  // Sorting config
104
107
  sortColumn: null,
105
108
  sortOrder: SortOrder.ASCENDING,
109
+ sortType: null,
106
110
 
107
111
  // Pagination config
108
112
  paginate: true,
@@ -223,12 +227,31 @@ export default abstract class BaseDataFetch<
223
227
  this.options.sortColumn = this.getDefaultSortColumn(definition, schema)
224
228
  }
225
229
 
226
- // If no sort order, default to ascending
227
- if (!this.options.sortOrder) {
230
+ // If we don't have a sort column specified then just ensure we don't set
231
+ // any sorting params
232
+ if (!this.options.sortColumn) {
228
233
  this.options.sortOrder = SortOrder.ASCENDING
234
+ this.options.sortType = null
229
235
  } else {
230
- // Ensure sortOrder matches the enum
231
- this.options.sortOrder = this.options.sortOrder.toLowerCase() as SortOrder
236
+ // Otherwise determine what sort type to use base on sort column
237
+ this.options.sortType = SortType.STRING
238
+ const fieldSchema = schema?.[this.options.sortColumn]
239
+ if (
240
+ fieldSchema?.type === FieldType.NUMBER ||
241
+ fieldSchema?.type === FieldType.BIGINT ||
242
+ ("calculationType" in fieldSchema && fieldSchema?.calculationType)
243
+ ) {
244
+ this.options.sortType = SortType.NUMBER
245
+ }
246
+
247
+ // If no sort order, default to ascending
248
+ if (!this.options.sortOrder) {
249
+ this.options.sortOrder = SortOrder.ASCENDING
250
+ } else {
251
+ // Ensure sortOrder matches the enum
252
+ this.options.sortOrder =
253
+ this.options.sortOrder.toLowerCase() as SortOrder
254
+ }
232
255
  }
233
256
 
234
257
  // Build the query
@@ -271,6 +294,7 @@ export default abstract class BaseDataFetch<
271
294
  const {
272
295
  sortColumn,
273
296
  sortOrder,
297
+ sortType,
274
298
  limit,
275
299
  clientSideSearching,
276
300
  clientSideSorting,
@@ -287,8 +311,8 @@ export default abstract class BaseDataFetch<
287
311
  }
288
312
 
289
313
  // If we don't support sorting, do a client-side sort
290
- if (!this.features.supportsSort && clientSideSorting && sortColumn) {
291
- rows = sort(rows, sortColumn, sortOrder)
314
+ if (!this.features.supportsSort && clientSideSorting && sortType) {
315
+ rows = sort(rows, sortColumn as any, sortOrder, sortType)
292
316
  }
293
317
 
294
318
  // If we don't support pagination, do a client-side limit
@@ -29,7 +29,8 @@ export default class TableFetch extends BaseDataFetch<TableDatasource, Table> {
29
29
  }
30
30
 
31
31
  async getData() {
32
- const { datasource, limit, sortColumn, sortOrder, paginate } = this.options
32
+ const { datasource, limit, sortColumn, sortOrder, sortType, paginate } =
33
+ this.options
33
34
  const { tableId } = datasource
34
35
  const { cursor, query } = get(this.store)
35
36
 
@@ -40,6 +41,7 @@ export default class TableFetch extends BaseDataFetch<TableDatasource, Table> {
40
41
  limit,
41
42
  sort: sortColumn,
42
43
  sortOrder: sortOrder ?? SortOrder.ASCENDING,
44
+ sortType,
43
45
  paginate,
44
46
  bookmark: cursor,
45
47
  })
@@ -1,5 +1,4 @@
1
1
  import {
2
- SearchViewRowRequest,
3
2
  SortOrder,
4
3
  ViewDatasource,
5
4
  ViewV2Enriched,
@@ -41,7 +40,8 @@ export default class ViewV2Fetch extends BaseDataFetch<
41
40
  }
42
41
 
43
42
  async getData() {
44
- const { datasource, limit, sortColumn, sortOrder, paginate } = this.options
43
+ const { datasource, limit, sortColumn, sortOrder, sortType, paginate } =
44
+ this.options
45
45
  const { cursor, query, definition } = get(this.store)
46
46
 
47
47
  // If this is a calculation view and we have no calculations, return nothing
@@ -68,13 +68,14 @@ export default class ViewV2Fetch extends BaseDataFetch<
68
68
  }
69
69
 
70
70
  try {
71
- const request: SearchViewRowRequest = {
71
+ const request = {
72
72
  query,
73
73
  paginate,
74
74
  limit,
75
75
  bookmark: cursor,
76
76
  sort: sortColumn,
77
77
  sortOrder: sortOrder,
78
+ sortType,
78
79
  }
79
80
  if (paginate) {
80
81
  const res = await this.API.viewV2.fetch(datasource.id, {
@@ -14,3 +14,4 @@ export * from "./settings"
14
14
  export * from "./relatedColumns"
15
15
  export * from "./table"
16
16
  export * from "./components"
17
+ export * from "./validation"
@@ -2,7 +2,9 @@ import { helpers } from "@budibase/shared-core"
2
2
  import { TypeIconMap } from "../constants"
3
3
 
4
4
  export const getColumnIcon = column => {
5
- if (column.schema.icon) {
5
+ // For some reason we have remix icons saved under this property sometimes,
6
+ // so we must ignore those as they are invalid spectrum icons
7
+ if (column.schema.icon && !column.schema.icon.startsWith("ri-")) {
6
8
  return column.schema.icon
7
9
  }
8
10
  if (column.calculationType) {
@@ -397,14 +397,19 @@ export function parseFilter(filter: UISearchFilter) {
397
397
 
398
398
  const update = cloneDeep(filter)
399
399
 
400
- update.groups = update.groups
401
- ?.map(group => {
402
- group.filters = group.filters?.filter((filter: any) => {
403
- return filter.field && filter.operator
400
+ if (update.groups) {
401
+ update.groups = update.groups
402
+ .map(group => {
403
+ if (group.filters) {
404
+ group.filters = group.filters.filter((filter: any) => {
405
+ return filter.field && filter.operator
406
+ })
407
+ return group.filters?.length ? group : null
408
+ }
409
+ return group
404
410
  })
405
- return group.filters?.length ? group : null
406
- })
407
- .filter((group): group is SearchFilterGroup => !!group)
411
+ .filter((group): group is SearchFilterGroup => !!group)
412
+ }
408
413
 
409
414
  return update
410
415
  }
@@ -0,0 +1,2 @@
1
+ export { emailValidator, requiredValidator } from "./validators"
2
+ export { createValidationStore } from "./validation"
@@ -0,0 +1,28 @@
1
+ import { writable, derived } from "svelte/store"
2
+
3
+ // DEPRECATED - Use the yup based validators for future validation
4
+
5
+ export function createValidationStore(initialValue, ...validators) {
6
+ let touched = false
7
+
8
+ const value = writable(initialValue || "")
9
+ const touchedStore = derived(value, () => {
10
+ if (!touched) {
11
+ touched = true
12
+ return false
13
+ }
14
+ return touched
15
+ })
16
+ const error = derived(
17
+ [value, touchedStore],
18
+ ([$v, $t]) => $t && validate($v, validators)
19
+ )
20
+
21
+ return [value, error, touchedStore]
22
+ }
23
+
24
+ function validate(value, validators) {
25
+ const failing = validators.find(v => v(value) !== true)
26
+
27
+ return failing && failing(value)
28
+ }
@@ -0,0 +1,18 @@
1
+ // TODO: Convert to yup based validators
2
+
3
+ export function emailValidator(value) {
4
+ return (
5
+ (value &&
6
+ !!value.match(
7
+ /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
8
+ )) ||
9
+ "Please enter a valid email"
10
+ )
11
+ }
12
+
13
+ export function requiredValidator(value) {
14
+ return (
15
+ (value !== undefined && value !== null && value !== "") ||
16
+ "This field is required"
17
+ )
18
+ }
@@ -0,0 +1,87 @@
1
+ import { string, mixed } from "yup"
2
+ import { APP_NAME_REGEX, APP_URL_REGEX } from "../../../constants"
3
+
4
+ export const name = (validation, { apps, currentApp } = { apps: [] }) => {
5
+ validation.addValidator(
6
+ "name",
7
+ string()
8
+ .trim()
9
+ .required("Your application must have a name")
10
+ .matches(
11
+ APP_NAME_REGEX,
12
+ "App name must be letters, numbers and spaces only"
13
+ )
14
+ .test(
15
+ "non-existing-app-name",
16
+ "Another app with the same name already exists",
17
+ value => {
18
+ if (!value) {
19
+ // exit early, above validator will fail
20
+ return true
21
+ }
22
+ return !apps
23
+ .filter(app => {
24
+ return app.appId !== currentApp?.appId
25
+ })
26
+ .map(app => app.name)
27
+ .some(appName => appName.toLowerCase() === value.toLowerCase())
28
+ }
29
+ )
30
+ )
31
+ }
32
+
33
+ export const url = (validation, { apps, currentApp } = { apps: [] }) => {
34
+ validation.addValidator(
35
+ "url",
36
+ string()
37
+ .trim()
38
+ .nullable()
39
+ .required("Your application must have a url")
40
+ .matches(APP_URL_REGEX, "Please enter a valid url")
41
+ .test(
42
+ "non-existing-app-url",
43
+ "Another app with the same URL already exists",
44
+ value => {
45
+ if (!value) {
46
+ return true
47
+ }
48
+ if (currentApp) {
49
+ // filter out the current app if present
50
+ apps = apps.filter(app => app.appId !== currentApp.appId)
51
+ }
52
+ return !apps
53
+ .map(app => app.url)
54
+ .some(appUrl => {
55
+ const url =
56
+ appUrl?.[0] === "/"
57
+ ? appUrl.substring(1, appUrl.length)
58
+ : appUrl
59
+ return url?.toLowerCase() === value.toLowerCase()
60
+ })
61
+ }
62
+ )
63
+ .test("valid-url", "Not a valid URL", value => {
64
+ // url is nullable
65
+ if (!value) {
66
+ return true
67
+ }
68
+ // make it clear that this is a url path and cannot be a full url
69
+ return (
70
+ !value.includes("http") &&
71
+ !value.includes("www") &&
72
+ !value.includes(".")
73
+ )
74
+ })
75
+ )
76
+ }
77
+
78
+ export const file = (validation, { template } = {}) => {
79
+ const templateToUse =
80
+ template && Object.keys(template).length === 0 ? null : template
81
+ validation.addValidator(
82
+ "file",
83
+ templateToUse?.fromFile
84
+ ? mixed().required("Please choose a file to import")
85
+ : null
86
+ )
87
+ }
@@ -0,0 +1,151 @@
1
+ import { object, string, number } from "yup"
2
+ import { writable, get } from "svelte/store"
3
+ import { Helpers, notifications } from "@budibase/bbui"
4
+
5
+ export const createValidationStore = () => {
6
+ const DEFAULT = {
7
+ values: {},
8
+ errors: {},
9
+ touched: {},
10
+ valid: false,
11
+ }
12
+
13
+ const validator = {}
14
+ const validation = writable(DEFAULT)
15
+
16
+ const addValidator = (propertyName, propertyValidator) => {
17
+ if (!propertyValidator || !propertyName) {
18
+ return
19
+ }
20
+ validator[propertyName] = propertyValidator
21
+ }
22
+
23
+ const addValidatorType = (propertyName, type, required, options) => {
24
+ if (!type || !propertyName) {
25
+ return
26
+ }
27
+
28
+ let propertyValidator
29
+ switch (type) {
30
+ case "number":
31
+ propertyValidator = number().nullable()
32
+ break
33
+ case "email":
34
+ propertyValidator = string().email().nullable()
35
+ break
36
+ case "password":
37
+ propertyValidator = string().nullable()
38
+ break
39
+ default:
40
+ propertyValidator = string().nullable()
41
+ }
42
+
43
+ if (required) {
44
+ propertyValidator = propertyValidator.required()
45
+ }
46
+
47
+ if (options?.minLength) {
48
+ propertyValidator = propertyValidator.min(options.minLength)
49
+ }
50
+
51
+ validator[propertyName] = propertyValidator
52
+ }
53
+
54
+ const observe = async (propertyName, value) => {
55
+ const values = get(validation).values
56
+ let fieldIsValid
57
+ if (!Object.prototype.hasOwnProperty.call(values, propertyName)) {
58
+ // Initial setup
59
+ values[propertyName] = value
60
+ return
61
+ }
62
+
63
+ if (value === values[propertyName]) {
64
+ return
65
+ }
66
+
67
+ const obj = object().shape(validator)
68
+ try {
69
+ validation.update(store => {
70
+ store.errors[propertyName] = null
71
+ return store
72
+ })
73
+ await obj.validateAt(propertyName, { [propertyName]: value })
74
+ fieldIsValid = true
75
+ } catch (error) {
76
+ const [fieldError] = error.errors
77
+ if (fieldError) {
78
+ validation.update(store => {
79
+ store.errors[propertyName] = Helpers.capitalise(fieldError)
80
+ store.valid = false
81
+ return store
82
+ })
83
+ }
84
+ }
85
+
86
+ if (fieldIsValid) {
87
+ // Validate the rest of the fields
88
+ try {
89
+ await obj.validate(
90
+ { ...values, [propertyName]: value },
91
+ { abortEarly: false }
92
+ )
93
+ validation.update(store => {
94
+ store.valid = true
95
+ return store
96
+ })
97
+ } catch {
98
+ validation.update(store => {
99
+ store.valid = false
100
+ return store
101
+ })
102
+ }
103
+ }
104
+ }
105
+
106
+ const check = async values => {
107
+ const obj = object().shape(validator)
108
+ // clear the previous errors
109
+ const properties = Object.keys(validator)
110
+ properties.forEach(property => (get(validation).errors[property] = null))
111
+
112
+ let validationError = false
113
+ try {
114
+ await obj.validate(values, { abortEarly: false })
115
+ } catch (error) {
116
+ if (!error.inner) {
117
+ notifications.error("Unexpected validation error", error)
118
+ validationError = true
119
+ } else {
120
+ error.inner.forEach(err => {
121
+ validation.update(store => {
122
+ store.errors[err.path] = Helpers.capitalise(err.message)
123
+ return store
124
+ })
125
+ })
126
+ }
127
+ }
128
+
129
+ let valid
130
+ if (properties.length && !validationError) {
131
+ valid = await obj.isValid(values)
132
+ } else {
133
+ // don't say valid until validators have been loaded
134
+ valid = false
135
+ }
136
+
137
+ validation.update(store => {
138
+ store.valid = valid
139
+ return store
140
+ })
141
+ }
142
+
143
+ return {
144
+ subscribe: validation.subscribe,
145
+ set: validation.set,
146
+ check,
147
+ addValidator,
148
+ addValidatorType,
149
+ observe,
150
+ }
151
+ }