Share validation between app modals, add yup based validation framework, add url to app modals
This commit is contained in:
parent
3d5a3e7902
commit
bc67974996
|
@ -430,13 +430,14 @@ Cypress.Commands.add("addDatasourceConfig", (datasource, skipFetch) => {
|
|||
// Click to fetch tables
|
||||
if (skipFetch) {
|
||||
cy.get(".spectrum-Dialog-grid").within(() => {
|
||||
cy.get(".spectrum-Button").contains("Skip table fetch")
|
||||
cy.get(".spectrum-Button")
|
||||
.contains("Skip table fetch")
|
||||
.click({ force: true })
|
||||
})
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
cy.get(".spectrum-Dialog-grid").within(() => {
|
||||
cy.get(".spectrum-Button").contains("Save and fetch tables")
|
||||
cy.get(".spectrum-Button")
|
||||
.contains("Save and fetch tables")
|
||||
.click({ force: true })
|
||||
cy.wait(1000)
|
||||
})
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
// eslint-disable-next-line no-undef
|
||||
const breweries = data
|
||||
const totals = {}
|
||||
|
||||
for (let brewery of breweries)
|
||||
{const state = brewery.state
|
||||
if (totals[state] == null)
|
||||
{totals[state] = 1
|
||||
} else
|
||||
{totals[state]++
|
||||
for (let brewery of breweries) {
|
||||
const state = brewery.state
|
||||
if (totals[state] == null) {
|
||||
totals[state] = 1
|
||||
} else {
|
||||
totals[state]++
|
||||
}
|
||||
}
|
||||
const entries = Object.entries(totals)
|
||||
|
|
|
@ -1,15 +1,16 @@
|
|||
// eslint-disable-next-line no-undef
|
||||
const breweries = data
|
||||
const totals = {}
|
||||
for (let brewery of breweries)
|
||||
{const state = brewery.state
|
||||
if (totals[state] == null)
|
||||
{totals[state] = 1
|
||||
} else
|
||||
{totals[state]++
|
||||
for (let brewery of breweries) {
|
||||
const state = brewery.state
|
||||
if (totals[state] == null) {
|
||||
totals[state] = 1
|
||||
} else {
|
||||
totals[state]++
|
||||
}
|
||||
}
|
||||
const stateCodes =
|
||||
{texas: "tx",
|
||||
const stateCodes = {
|
||||
texas: "tx",
|
||||
colorado: "co",
|
||||
florida: "fl",
|
||||
iwoa: "ia",
|
||||
|
@ -24,7 +25,7 @@ const stateCodes =
|
|||
ohio: "oh",
|
||||
}
|
||||
const entries = Object.entries(totals)
|
||||
return entries.map(([state, count]) =>
|
||||
{stateCodes[state.toLowerCase()]
|
||||
return entries.map(([state, count]) => {
|
||||
stateCodes[state.toLowerCase()]
|
||||
return { state, count, flag: "http://flags.ox3.in/svg/us/${stateCode}.svg" }
|
||||
})
|
||||
|
|
|
@ -4,98 +4,45 @@
|
|||
import { notifications, Input, ModalContent, Dropzone } from "@budibase/bbui"
|
||||
import { store, automationStore, hostingStore } from "builderStore"
|
||||
import { admin, auth } from "stores/portal"
|
||||
import { string, mixed, object } from "yup"
|
||||
import api, { get, post } from "builderStore/api"
|
||||
import analytics, { Events } from "analytics"
|
||||
import { onMount } from "svelte"
|
||||
import { capitalise } from "helpers"
|
||||
import { goto } from "@roxi/routify"
|
||||
import { APP_NAME_REGEX } from "constants"
|
||||
import TemplateList from "./TemplateList.svelte"
|
||||
import { createValidationStore } from "helpers/validation/yup"
|
||||
import * as appValidation from "helpers/validation/yup/app"
|
||||
|
||||
export let template
|
||||
export let inline
|
||||
|
||||
const values = writable({ name: null })
|
||||
const errors = writable({})
|
||||
const touched = writable({})
|
||||
const validator = {
|
||||
name: string()
|
||||
.trim()
|
||||
.required("Your application must have a name")
|
||||
.matches(
|
||||
APP_NAME_REGEX,
|
||||
"App name must be letters, numbers and spaces only"
|
||||
),
|
||||
file: template?.fromFile
|
||||
? mixed().required("Please choose a file to import")
|
||||
: null,
|
||||
}
|
||||
|
||||
let submitting = false
|
||||
let valid = false
|
||||
let initialTemplateInfo = template?.fromFile || template?.key
|
||||
|
||||
$: checkValidity($values, validator)
|
||||
$: showTemplateSelection = !template && !initialTemplateInfo
|
||||
const values = writable({ name: "", url: null })
|
||||
const validation = createValidationStore()
|
||||
$: validation.check($values)
|
||||
|
||||
onMount(async () => {
|
||||
await setupValidation()
|
||||
})
|
||||
|
||||
const setupValidation = async () => {
|
||||
await hostingStore.actions.fetchDeployedApps()
|
||||
const existingAppNames = svelteGet(hostingStore).deployedAppNames
|
||||
validator.name = string()
|
||||
.trim()
|
||||
.required("Your application must have a name")
|
||||
.matches(APP_NAME_REGEX, "App name must be letters and numbers only")
|
||||
.test(
|
||||
"non-existing-app-name",
|
||||
"Another app with the same name already exists",
|
||||
value => {
|
||||
return !existingAppNames.some(
|
||||
appName => appName.toLowerCase() === value.toLowerCase()
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const checkValidity = async (values, validator) => {
|
||||
const obj = object().shape(validator)
|
||||
Object.keys(validator).forEach(key => ($errors[key] = null))
|
||||
if (template?.fromFile && values.file == null) {
|
||||
valid = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await obj.validate(values, { abortEarly: false })
|
||||
} catch (validationErrors) {
|
||||
validationErrors.inner.forEach(error => {
|
||||
$errors[error.path] = capitalise(error.message)
|
||||
})
|
||||
}
|
||||
|
||||
valid = await obj.isValid(values)
|
||||
const apps = svelteGet(hostingStore).deployedApps
|
||||
appValidation.name(validation, { apps })
|
||||
appValidation.url(validation, { apps })
|
||||
appValidation.file(validation, { template })
|
||||
// init validation
|
||||
validation.check($values)
|
||||
}
|
||||
|
||||
async function createNewApp() {
|
||||
const templateToUse = Object.keys(template).length === 0 ? null : template
|
||||
submitting = true
|
||||
|
||||
// Check a template exists if we are important
|
||||
if (templateToUse?.fromFile && !$values.file) {
|
||||
$errors.file = "Please choose a file to import"
|
||||
valid = false
|
||||
submitting = false
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// Create form data to create app
|
||||
let data = new FormData()
|
||||
data.append("name", $values.name.trim())
|
||||
data.append("useTemplate", templateToUse != null)
|
||||
if (templateToUse) {
|
||||
data.append("templateName", templateToUse.name)
|
||||
data.append("templateKey", templateToUse.key)
|
||||
if ($values.url) {
|
||||
data.append("url", $values.url.trim())
|
||||
}
|
||||
data.append("useTemplate", template != null)
|
||||
if (template) {
|
||||
data.append("templateName", template.name)
|
||||
data.append("templateKey", template.key)
|
||||
data.append("templateFile", $values.file)
|
||||
}
|
||||
|
||||
|
@ -109,7 +56,7 @@
|
|||
analytics.captureEvent(Events.APP.CREATED, {
|
||||
name: $values.name,
|
||||
appId: appJson.instance._id,
|
||||
templateToUse,
|
||||
templateToUse: template,
|
||||
})
|
||||
|
||||
// Select Correct Application/DB in prep for creating user
|
||||
|
@ -137,7 +84,6 @@
|
|||
} catch (error) {
|
||||
console.error(error)
|
||||
notifications.error(error)
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -145,60 +91,50 @@
|
|||
template = null
|
||||
await auth.setInitInfo({})
|
||||
}
|
||||
|
||||
// auto add slash to url
|
||||
$: {
|
||||
if ($values.url && !$values.url.startsWith("/")) {
|
||||
$values.url = `/${$values.url}`
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showTemplateSelection}
|
||||
<ModalContent
|
||||
title={"Get started quickly"}
|
||||
showConfirmButton={false}
|
||||
size="L"
|
||||
onConfirm={() => {
|
||||
template = {}
|
||||
return false
|
||||
}}
|
||||
showCancelButton={!inline}
|
||||
showCloseIcon={!inline}
|
||||
>
|
||||
<TemplateList
|
||||
onSelect={(selected, { useImport } = {}) => {
|
||||
if (!selected) {
|
||||
template = useImport ? { fromFile: true } : {}
|
||||
return
|
||||
}
|
||||
template = selected
|
||||
}}
|
||||
/>
|
||||
</ModalContent>
|
||||
{:else}
|
||||
<ModalContent
|
||||
title={"Name your app"}
|
||||
title={"Create your app"}
|
||||
confirmText={template?.fromFile ? "Import app" : "Create app"}
|
||||
onConfirm={createNewApp}
|
||||
onCancel={inline ? onCancel : null}
|
||||
cancelText={inline ? "Back" : undefined}
|
||||
showCloseIcon={!inline}
|
||||
disabled={!valid}
|
||||
{onCancel}
|
||||
disabled={!$validation.valid}
|
||||
>
|
||||
{#if template?.fromFile}
|
||||
<Dropzone
|
||||
error={$touched.file && $errors.file}
|
||||
error={$validation.touched.file && $validation.errors.file}
|
||||
gallery={false}
|
||||
label="File to import"
|
||||
value={[$values.file]}
|
||||
on:change={e => {
|
||||
$values.file = e.detail?.[0]
|
||||
$touched.file = true
|
||||
$validation.touched.file = true
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<Input
|
||||
bind:value={$values.name}
|
||||
error={$touched.name && $errors.name}
|
||||
on:blur={() => ($touched.name = true)}
|
||||
error={$validation.touched.name && $validation.errors.name}
|
||||
on:blur={() => ($validation.touched.name = true)}
|
||||
label="Name"
|
||||
placeholder={$auth.user.firstName
|
||||
? `${$auth.user.firstName}'s app`
|
||||
? `${$auth.user.firstName}s app`
|
||||
: "My app"}
|
||||
/>
|
||||
<Input
|
||||
bind:value={$values.url}
|
||||
error={$validation.touched.url && $validation.errors.url}
|
||||
on:blur={() => ($validation.touched.url = true)}
|
||||
label="URL"
|
||||
placeholder={$values.name
|
||||
? "/" + encodeURIComponent($values.name).toLowerCase()
|
||||
: "/"}
|
||||
/>
|
||||
</ModalContent>
|
||||
{/if}
|
||||
|
|
|
@ -1,120 +1,71 @@
|
|||
<script>
|
||||
import { writable, get as svelteGet } from "svelte/store"
|
||||
import {
|
||||
notifications,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Body,
|
||||
} from "@budibase/bbui"
|
||||
import { notifications, Input, ModalContent, Body } from "@budibase/bbui"
|
||||
import { hostingStore } from "builderStore"
|
||||
import { apps } from "stores/portal"
|
||||
import { string, object } from "yup"
|
||||
import { onMount } from "svelte"
|
||||
import { capitalise } from "helpers"
|
||||
import { APP_NAME_REGEX } from "constants"
|
||||
|
||||
const values = writable({ name: null })
|
||||
const errors = writable({})
|
||||
const touched = writable({})
|
||||
const validator = {
|
||||
name: string()
|
||||
.trim()
|
||||
.required("Your application must have a name")
|
||||
.matches(
|
||||
APP_NAME_REGEX,
|
||||
"App name must be letters, numbers and spaces only"
|
||||
),
|
||||
}
|
||||
import { createValidationStore } from "helpers/validation/yup"
|
||||
import * as appValidation from "helpers/validation/yup/app"
|
||||
|
||||
export let app
|
||||
|
||||
let modal
|
||||
let valid = false
|
||||
let dirty = false
|
||||
$: checkValidity($values, validator)
|
||||
$: {
|
||||
// prevent validation by setting name to undefined without an app
|
||||
if (app) {
|
||||
$values.name = app?.name
|
||||
}
|
||||
}
|
||||
const values = writable({ name: "", url: null })
|
||||
const validation = createValidationStore()
|
||||
$: validation.check($values)
|
||||
|
||||
onMount(async () => {
|
||||
await hostingStore.actions.fetchDeployedApps()
|
||||
const existingAppNames = svelteGet(hostingStore).deployedAppNames
|
||||
validator.name = string()
|
||||
.trim()
|
||||
.required("Your application must have a name")
|
||||
.matches(
|
||||
APP_NAME_REGEX,
|
||||
"App name must be letters, numbers and spaces only"
|
||||
)
|
||||
.test(
|
||||
"non-existing-app-name",
|
||||
"Another app with the same name already exists",
|
||||
value => {
|
||||
return !existingAppNames.some(
|
||||
appName => dirty && appName.toLowerCase() === value.toLowerCase()
|
||||
)
|
||||
}
|
||||
)
|
||||
$values.name = app.name
|
||||
$values.url = app.url
|
||||
setupValidation()
|
||||
})
|
||||
|
||||
const checkValidity = async (values, validator) => {
|
||||
const obj = object().shape(validator)
|
||||
Object.keys(validator).forEach(key => ($errors[key] = null))
|
||||
try {
|
||||
await obj.validate(values, { abortEarly: false })
|
||||
} catch (validationErrors) {
|
||||
validationErrors.inner.forEach(error => {
|
||||
$errors[error.path] = capitalise(error.message)
|
||||
})
|
||||
}
|
||||
valid = await obj.isValid(values)
|
||||
const setupValidation = async () => {
|
||||
await hostingStore.actions.fetchDeployedApps()
|
||||
const apps = svelteGet(hostingStore).deployedApps
|
||||
appValidation.name(validation, { apps, currentApp: app })
|
||||
appValidation.url(validation, { apps, currentApp: app })
|
||||
// init validation
|
||||
validation.check($values)
|
||||
}
|
||||
|
||||
async function updateApp() {
|
||||
try {
|
||||
// Update App
|
||||
await apps.update(app.instance._id, { name: $values.name.trim() })
|
||||
hide()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
notifications.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
export const show = () => {
|
||||
modal.show()
|
||||
// auto add slash to url
|
||||
$: {
|
||||
if ($values.url && !$values.url.startsWith("/")) {
|
||||
$values.url = `/${$values.url}`
|
||||
}
|
||||
export const hide = () => {
|
||||
modal.hide()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
hide()
|
||||
}
|
||||
|
||||
const onShow = () => {
|
||||
dirty = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:this={modal} on:hide={onCancel} on:show={onShow}>
|
||||
<ModalContent
|
||||
title={"Edit app"}
|
||||
confirmText={"Save"}
|
||||
onConfirm={updateApp}
|
||||
disabled={!(valid && dirty)}
|
||||
disabled={!$validation.valid}
|
||||
>
|
||||
<Body size="S">Update the name of your app.</Body>
|
||||
<Input
|
||||
bind:value={$values.name}
|
||||
error={$touched.name && $errors.name}
|
||||
on:blur={() => ($touched.name = true)}
|
||||
on:change={() => (dirty = true)}
|
||||
error={$validation.touched.name && $validation.errors.name}
|
||||
on:blur={() => ($validation.touched.name = true)}
|
||||
label="Name"
|
||||
/>
|
||||
<Input
|
||||
bind:value={$values.url}
|
||||
error={$validation.touched.url && $validation.errors.url}
|
||||
on:blur={() => ($validation.touched.url = true)}
|
||||
label="URL"
|
||||
placeholder={$values.name
|
||||
? "/" + encodeURIComponent($values.name).toLowerCase()
|
||||
: "/"}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
|
|
@ -36,4 +36,7 @@ export const LAYOUT_NAMES = {
|
|||
|
||||
export const BUDIBASE_INTERNAL_DB = "bb_internal"
|
||||
|
||||
// one or more word characters and whitespace
|
||||
export const APP_NAME_REGEX = /^[\w\s]+$/
|
||||
// zero or more non-whitespace characters
|
||||
export const APP_URL_REGEX = /^\S*$/
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
import { string, mixed } from "yup"
|
||||
import { APP_NAME_REGEX, APP_URL_REGEX } from "constants"
|
||||
|
||||
export const name = (validation, { apps, currentApp } = { apps: [] }) => {
|
||||
let existingApps = Object.values(apps)
|
||||
validation.addValidator(
|
||||
"name",
|
||||
string()
|
||||
.required("Your application must have a name")
|
||||
.matches(APP_NAME_REGEX, "App name must be letters and numbers only")
|
||||
.test(
|
||||
"non-existing-app-name",
|
||||
"Another app with the same name already exists",
|
||||
value => {
|
||||
if (!value) {
|
||||
return true
|
||||
}
|
||||
if (currentApp) {
|
||||
// filter out the current app if present
|
||||
existingApps = existingApps
|
||||
// match the id format of the current app (remove 'app_')
|
||||
.map(app => ({ ...app, appId: app.appId.substring(4) }))
|
||||
.filter(app => app.appId !== currentApp.appId)
|
||||
}
|
||||
return !existingApps
|
||||
.map(app => app.name)
|
||||
.some(appName => appName.toLowerCase() === value.toLowerCase())
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export const url = (validation, { apps, currentApp } = { apps: {} }) => {
|
||||
let existingApps = Object.values(apps)
|
||||
validation.addValidator(
|
||||
"url",
|
||||
string()
|
||||
.nullable()
|
||||
.matches(APP_URL_REGEX, "App URL must not contain spaces")
|
||||
.test(
|
||||
"non-existing-app-url",
|
||||
"Another app with the same URL already exists",
|
||||
value => {
|
||||
// url is nullable
|
||||
if (!value) {
|
||||
return true
|
||||
}
|
||||
if (currentApp) {
|
||||
existingApps = existingApps
|
||||
// match the id format of the current app (remove 'app_')
|
||||
.map(app => ({ ...app, appId: app.appId.substring(4) }))
|
||||
// filter out the current app if present
|
||||
.filter(app => app.appId !== currentApp.appId)
|
||||
}
|
||||
return !existingApps
|
||||
.map(app => app.url)
|
||||
.some(appUrl => appUrl.toLowerCase() === value.toLowerCase())
|
||||
}
|
||||
)
|
||||
.test("start-with-slash", "Not a valid URL", value => {
|
||||
// url is nullable
|
||||
if (!value) {
|
||||
return true
|
||||
}
|
||||
return value.length > 1
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export const file = (validation, { template } = {}) => {
|
||||
const templateToUse =
|
||||
template && Object.keys(template).length === 0 ? null : template
|
||||
validation.addValidator(
|
||||
"file",
|
||||
templateToUse?.fromFile
|
||||
? mixed().required("Please choose a file to import")
|
||||
: null
|
||||
)
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
import { capitalise } from "helpers"
|
||||
import { object } from "yup"
|
||||
import { writable, get } from "svelte/store"
|
||||
|
||||
export const createValidationStore = () => {
|
||||
const DEFAULT = {
|
||||
errors: {},
|
||||
touched: {},
|
||||
valid: false,
|
||||
}
|
||||
|
||||
const validator = {}
|
||||
const validation = writable(DEFAULT)
|
||||
|
||||
const addValidator = (propertyName, propertyValidator) => {
|
||||
if (!propertyValidator || !propertyName) {
|
||||
return
|
||||
}
|
||||
validator[propertyName] = propertyValidator
|
||||
}
|
||||
|
||||
const check = async values => {
|
||||
const obj = object().shape(validator)
|
||||
// clear the previous errors
|
||||
const properties = Object.keys(validator)
|
||||
properties.forEach(property => (get(validation).errors[property] = null))
|
||||
try {
|
||||
await obj.validate(values, { abortEarly: false })
|
||||
} catch (error) {
|
||||
error.inner.forEach(err => {
|
||||
validation.update(store => {
|
||||
store.errors[err.path] = capitalise(err.message)
|
||||
return store
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
let valid
|
||||
if (properties.length) {
|
||||
valid = await obj.isValid(values)
|
||||
} else {
|
||||
// don't say valid until validators have been loaded
|
||||
valid = false
|
||||
}
|
||||
|
||||
validation.update(store => {
|
||||
store.valid = valid
|
||||
return store
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: validation.subscribe,
|
||||
set: validation.set,
|
||||
check,
|
||||
addValidator,
|
||||
}
|
||||
}
|
|
@ -412,6 +412,11 @@
|
|||
>
|
||||
<CreateAppModal {template} />
|
||||
</Modal>
|
||||
|
||||
<Modal bind:this={updatingModal} padding={false} width="600px">
|
||||
<UpdateAppModal app={selectedApp} />
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:this={deletionModal}
|
||||
title="Confirm deletion"
|
||||
|
@ -438,7 +443,6 @@
|
|||
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
|
||||
</ConfirmDialog>
|
||||
|
||||
<UpdateAppModal app={selectedApp} bind:this={updatingModal} />
|
||||
<ChooseIconModal app={selectedApp} bind:this={iconModal} />
|
||||
|
||||
<style>
|
||||
|
|
|
@ -78,6 +78,11 @@ class QueryRunner {
|
|||
return this.execute()
|
||||
}
|
||||
|
||||
// check for undefined response
|
||||
if (!rows) {
|
||||
rows = []
|
||||
}
|
||||
|
||||
// needs to an array for next step
|
||||
if (!Array.isArray(rows)) {
|
||||
rows = [rows]
|
||||
|
|
Loading…
Reference in New Issue