Share validation between app modals, add yup based validation framework, add url to app modals

This commit is contained in:
Rory Powell 2022-01-20 16:01:09 +00:00
parent 3d5a3e7902
commit bc67974996
10 changed files with 282 additions and 243 deletions

View File

@ -430,13 +430,14 @@ Cypress.Commands.add("addDatasourceConfig", (datasource, skipFetch) => {
// Click to fetch tables // Click to fetch tables
if (skipFetch) { if (skipFetch) {
cy.get(".spectrum-Dialog-grid").within(() => { 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 }) .click({ force: true })
}) })
} } else {
else {
cy.get(".spectrum-Dialog-grid").within(() => { 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 }) .click({ force: true })
cy.wait(1000) cy.wait(1000)
}) })

View File

@ -1,12 +1,13 @@
// eslint-disable-next-line no-undef
const breweries = data const breweries = data
const totals = {} const totals = {}
for (let brewery of breweries) for (let brewery of breweries) {
{const state = brewery.state const state = brewery.state
if (totals[state] == null) if (totals[state] == null) {
{totals[state] = 1 totals[state] = 1
} else } else {
{totals[state]++ totals[state]++
} }
} }
const entries = Object.entries(totals) const entries = Object.entries(totals)

View File

@ -1,15 +1,16 @@
// eslint-disable-next-line no-undef
const breweries = data const breweries = data
const totals = {} const totals = {}
for (let brewery of breweries) for (let brewery of breweries) {
{const state = brewery.state const state = brewery.state
if (totals[state] == null) if (totals[state] == null) {
{totals[state] = 1 totals[state] = 1
} else } else {
{totals[state]++ totals[state]++
} }
} }
const stateCodes = const stateCodes = {
{texas: "tx", texas: "tx",
colorado: "co", colorado: "co",
florida: "fl", florida: "fl",
iwoa: "ia", iwoa: "ia",
@ -24,7 +25,7 @@ const stateCodes =
ohio: "oh", ohio: "oh",
} }
const entries = Object.entries(totals) const entries = Object.entries(totals)
return entries.map(([state, count]) => return entries.map(([state, count]) => {
{stateCodes[state.toLowerCase()] stateCodes[state.toLowerCase()]
return { state, count, flag: "http://flags.ox3.in/svg/us/${stateCode}.svg" } return { state, count, flag: "http://flags.ox3.in/svg/us/${stateCode}.svg" }
}) })

View File

@ -4,98 +4,45 @@
import { notifications, Input, ModalContent, Dropzone } from "@budibase/bbui" import { notifications, Input, ModalContent, Dropzone } from "@budibase/bbui"
import { store, automationStore, hostingStore } from "builderStore" import { store, automationStore, hostingStore } from "builderStore"
import { admin, auth } from "stores/portal" import { admin, auth } from "stores/portal"
import { string, mixed, object } from "yup"
import api, { get, post } from "builderStore/api" import api, { get, post } from "builderStore/api"
import analytics, { Events } from "analytics" import analytics, { Events } from "analytics"
import { onMount } from "svelte" import { onMount } from "svelte"
import { capitalise } from "helpers"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { APP_NAME_REGEX } from "constants" import { createValidationStore } from "helpers/validation/yup"
import TemplateList from "./TemplateList.svelte" import * as appValidation from "helpers/validation/yup/app"
export let template export let template
export let inline
const values = writable({ name: null }) const values = writable({ name: "", url: null })
const errors = writable({}) const validation = createValidationStore()
const touched = writable({}) $: validation.check($values)
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
onMount(async () => { onMount(async () => {
await setupValidation()
})
const setupValidation = async () => {
await hostingStore.actions.fetchDeployedApps() await hostingStore.actions.fetchDeployedApps()
const existingAppNames = svelteGet(hostingStore).deployedAppNames const apps = svelteGet(hostingStore).deployedApps
validator.name = string() appValidation.name(validation, { apps })
.trim() appValidation.url(validation, { apps })
.required("Your application must have a name") appValidation.file(validation, { template })
.matches(APP_NAME_REGEX, "App name must be letters and numbers only") // init validation
.test( validation.check($values)
"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)
} }
async function createNewApp() { 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 { try {
// Create form data to create app // Create form data to create app
let data = new FormData() let data = new FormData()
data.append("name", $values.name.trim()) data.append("name", $values.name.trim())
data.append("useTemplate", templateToUse != null) if ($values.url) {
if (templateToUse) { data.append("url", $values.url.trim())
data.append("templateName", templateToUse.name) }
data.append("templateKey", templateToUse.key) data.append("useTemplate", template != null)
if (template) {
data.append("templateName", template.name)
data.append("templateKey", template.key)
data.append("templateFile", $values.file) data.append("templateFile", $values.file)
} }
@ -109,7 +56,7 @@
analytics.captureEvent(Events.APP.CREATED, { analytics.captureEvent(Events.APP.CREATED, {
name: $values.name, name: $values.name,
appId: appJson.instance._id, appId: appJson.instance._id,
templateToUse, templateToUse: template,
}) })
// Select Correct Application/DB in prep for creating user // Select Correct Application/DB in prep for creating user
@ -137,7 +84,6 @@
} catch (error) { } catch (error) {
console.error(error) console.error(error)
notifications.error(error) notifications.error(error)
submitting = false
} }
} }
@ -145,60 +91,50 @@
template = null template = null
await auth.setInitInfo({}) await auth.setInitInfo({})
} }
// auto add slash to url
$: {
if ($values.url && !$values.url.startsWith("/")) {
$values.url = `/${$values.url}`
}
}
</script> </script>
{#if showTemplateSelection}
<ModalContent <ModalContent
title={"Get started quickly"} title={"Create your app"}
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"}
confirmText={template?.fromFile ? "Import app" : "Create app"} confirmText={template?.fromFile ? "Import app" : "Create app"}
onConfirm={createNewApp} onConfirm={createNewApp}
onCancel={inline ? onCancel : null} {onCancel}
cancelText={inline ? "Back" : undefined} disabled={!$validation.valid}
showCloseIcon={!inline}
disabled={!valid}
> >
{#if template?.fromFile} {#if template?.fromFile}
<Dropzone <Dropzone
error={$touched.file && $errors.file} error={$validation.touched.file && $validation.errors.file}
gallery={false} gallery={false}
label="File to import" label="File to import"
value={[$values.file]} value={[$values.file]}
on:change={e => { on:change={e => {
$values.file = e.detail?.[0] $values.file = e.detail?.[0]
$touched.file = true $validation.touched.file = true
}} }}
/> />
{/if} {/if}
<Input <Input
bind:value={$values.name} bind:value={$values.name}
error={$touched.name && $errors.name} error={$validation.touched.name && $validation.errors.name}
on:blur={() => ($touched.name = true)} on:blur={() => ($validation.touched.name = true)}
label="Name" label="Name"
placeholder={$auth.user.firstName placeholder={$auth.user.firstName
? `${$auth.user.firstName}'s app` ? `${$auth.user.firstName}s app`
: "My 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> </ModalContent>
{/if}

View File

@ -1,120 +1,71 @@
<script> <script>
import { writable, get as svelteGet } from "svelte/store" import { writable, get as svelteGet } from "svelte/store"
import { import { notifications, Input, ModalContent, Body } from "@budibase/bbui"
notifications,
Input,
Modal,
ModalContent,
Body,
} from "@budibase/bbui"
import { hostingStore } from "builderStore" import { hostingStore } from "builderStore"
import { apps } from "stores/portal" import { apps } from "stores/portal"
import { string, object } from "yup"
import { onMount } from "svelte" import { onMount } from "svelte"
import { capitalise } from "helpers" import { createValidationStore } from "helpers/validation/yup"
import { APP_NAME_REGEX } from "constants" import * as appValidation from "helpers/validation/yup/app"
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"
),
}
export let app export let app
let modal const values = writable({ name: "", url: null })
let valid = false const validation = createValidationStore()
let dirty = false $: validation.check($values)
$: checkValidity($values, validator)
$: {
// prevent validation by setting name to undefined without an app
if (app) {
$values.name = app?.name
}
}
onMount(async () => { onMount(async () => {
await hostingStore.actions.fetchDeployedApps() $values.name = app.name
const existingAppNames = svelteGet(hostingStore).deployedAppNames $values.url = app.url
validator.name = string() setupValidation()
.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()
)
}
)
}) })
const checkValidity = async (values, validator) => { const setupValidation = async () => {
const obj = object().shape(validator) await hostingStore.actions.fetchDeployedApps()
Object.keys(validator).forEach(key => ($errors[key] = null)) const apps = svelteGet(hostingStore).deployedApps
try { appValidation.name(validation, { apps, currentApp: app })
await obj.validate(values, { abortEarly: false }) appValidation.url(validation, { apps, currentApp: app })
} catch (validationErrors) { // init validation
validationErrors.inner.forEach(error => { validation.check($values)
$errors[error.path] = capitalise(error.message)
})
}
valid = await obj.isValid(values)
} }
async function updateApp() { async function updateApp() {
try { try {
// Update App // Update App
await apps.update(app.instance._id, { name: $values.name.trim() }) await apps.update(app.instance._id, { name: $values.name.trim() })
hide()
} catch (error) { } catch (error) {
console.error(error) console.error(error)
notifications.error(error) notifications.error(error)
} }
} }
export const show = () => { // auto add slash to url
modal.show() $: {
if ($values.url && !$values.url.startsWith("/")) {
$values.url = `/${$values.url}`
} }
export const hide = () => {
modal.hide()
}
const onCancel = () => {
hide()
}
const onShow = () => {
dirty = false
} }
</script> </script>
<Modal bind:this={modal} on:hide={onCancel} on:show={onShow}>
<ModalContent <ModalContent
title={"Edit app"} title={"Edit app"}
confirmText={"Save"} confirmText={"Save"}
onConfirm={updateApp} onConfirm={updateApp}
disabled={!(valid && dirty)} disabled={!$validation.valid}
> >
<Body size="S">Update the name of your app.</Body> <Body size="S">Update the name of your app.</Body>
<Input <Input
bind:value={$values.name} bind:value={$values.name}
error={$touched.name && $errors.name} error={$validation.touched.name && $validation.errors.name}
on:blur={() => ($touched.name = true)} on:blur={() => ($validation.touched.name = true)}
on:change={() => (dirty = true)}
label="Name" 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> </ModalContent>
</Modal>

View File

@ -36,4 +36,7 @@ export const LAYOUT_NAMES = {
export const BUDIBASE_INTERNAL_DB = "bb_internal" export const BUDIBASE_INTERNAL_DB = "bb_internal"
// one or more word characters and whitespace
export const APP_NAME_REGEX = /^[\w\s]+$/ export const APP_NAME_REGEX = /^[\w\s]+$/
// zero or more non-whitespace characters
export const APP_URL_REGEX = /^\S*$/

View File

@ -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
)
}

View File

@ -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,
}
}

View File

@ -412,6 +412,11 @@
> >
<CreateAppModal {template} /> <CreateAppModal {template} />
</Modal> </Modal>
<Modal bind:this={updatingModal} padding={false} width="600px">
<UpdateAppModal app={selectedApp} />
</Modal>
<ConfirmDialog <ConfirmDialog
bind:this={deletionModal} bind:this={deletionModal}
title="Confirm deletion" title="Confirm deletion"
@ -438,7 +443,6 @@
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>? Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog> </ConfirmDialog>
<UpdateAppModal app={selectedApp} bind:this={updatingModal} />
<ChooseIconModal app={selectedApp} bind:this={iconModal} /> <ChooseIconModal app={selectedApp} bind:this={iconModal} />
<style> <style>

View File

@ -78,6 +78,11 @@ class QueryRunner {
return this.execute() return this.execute()
} }
// check for undefined response
if (!rows) {
rows = []
}
// needs to an array for next step // needs to an array for next step
if (!Array.isArray(rows)) { if (!Array.isArray(rows)) {
rows = [rows] rows = [rows]