new schema and indexes ui

This commit is contained in:
Gani Georgiev
2023-03-16 19:21:16 +02:00
parent 254e691e92
commit 4d16d0e16e
87 changed files with 2807 additions and 1973 deletions
@@ -1,46 +1,44 @@
<script>
import { Collection, SchemaField } from "pocketbase";
import FieldAccordion from "@/components/collections/FieldAccordion.svelte";
import { setErrors } from "@/stores/errors";
import CommonHelper from "@/utils/CommonHelper";
import IndexesList from "@/components/collections/IndexesList.svelte";
import NewField from "@/components/collections/schema/NewField.svelte";
import SchemaFieldText from "@/components/collections/schema/SchemaFieldText.svelte";
import SchemaFieldNumber from "@/components/collections/schema/SchemaFieldNumber.svelte";
import SchemaFieldBool from "@/components/collections/schema/SchemaFieldBool.svelte";
import SchemaFieldEmail from "@/components/collections/schema/SchemaFieldEmail.svelte";
import SchemaFieldUrl from "@/components/collections/schema/SchemaFieldUrl.svelte";
import SchemaFieldEditor from "@/components/collections/schema/SchemaFieldEditor.svelte";
import SchemaFieldDate from "@/components/collections/schema/SchemaFieldDate.svelte";
import SchemaFieldSelect from "@/components/collections/schema/SchemaFieldSelect.svelte";
import SchemaFieldJson from "@/components/collections/schema/SchemaFieldJson.svelte";
import SchemaFieldFile from "@/components/collections/schema/SchemaFieldFile.svelte";
import SchemaFieldRelation from "@/components/collections/schema/SchemaFieldRelation.svelte";
export let collection = new Collection();
const baseReservedNames = [
"id",
"created",
"updated",
"collectionId",
"collectionName",
"expand",
"true",
"false",
"null",
];
let reservedNames = [];
$: if (collection.isAuth) {
reservedNames = baseReservedNames.concat([
"username",
"email",
"emailVisibility",
"verified",
"tokenKey",
"passwordHash",
"lastResetSentAt",
"lastVerificationSentAt",
"password",
"passwordConfirm",
"oldPassword",
]);
} else {
reservedNames = baseReservedNames.slice(0);
}
const fieldComponents = {
text: SchemaFieldText,
number: SchemaFieldNumber,
bool: SchemaFieldBool,
email: SchemaFieldEmail,
url: SchemaFieldUrl,
editor: SchemaFieldEditor,
date: SchemaFieldDate,
select: SchemaFieldSelect,
json: SchemaFieldJson,
file: SchemaFieldFile,
relation: SchemaFieldRelation,
};
$: if (typeof collection.schema === "undefined") {
collection = collection || new Collection();
collection.schema = [];
}
$: nonDeletedFields = collection.schema.filter((f) => !f.toDelete) || [];
function removeField(fieldIndex) {
if (collection.schema[fieldIndex]) {
collection.schema.splice(fieldIndex, 1);
@@ -48,9 +46,10 @@
}
}
function newField() {
function newField(fieldType = "text") {
const field = new SchemaField({
name: getUniqueFieldName(),
type: fieldType,
});
field.onMountExpand = true;
@@ -73,22 +72,19 @@
return !!collection.schema.find((field) => field.name === name);
}
function getSiblingsFieldNames(currentField) {
let result = [];
function getSchemaFieldIndex(field) {
return nonDeletedFields.findIndex((f) => f === field);
}
if (currentField.toDelete) {
return result;
function replaceIndexesColumn(oldName, newName) {
if (!collection?.schema?.length || oldName === newName || !newName) {
return;
}
for (let field of collection.schema) {
if (field === currentField || field.toDelete) {
continue; // skip current and deleted fields
}
result.push(field.name);
}
return result;
// update indexes on renamed fields
collection.indexes = collection.indexes.map((idx) =>
CommonHelper.replaceIndexColumn(idx, oldName, newName)
);
}
// ---------------------------------------------------------------
@@ -124,6 +120,9 @@
}
collection.schema = newSchema;
// reset errors since the schema keys index has changed
setErrors({});
}
</script>
@@ -144,26 +143,22 @@
</p>
</div>
<div class="accordions">
<div class="accordions schema-fields">
{#each collection.schema as field, i (field)}
<FieldAccordion
<svelte:component
this={fieldComponents[field.type]}
key={getSchemaFieldIndex(field)}
bind:field
key={i}
excludeNames={reservedNames.concat(getSiblingsFieldNames(field))}
on:remove={() => removeField(i)}
on:dragstart={(e) => onFieldDrag(e?.detail, i)}
on:drop={(e) => onFieldDrop(e?.detail, i)}
on:rename={(e) => replaceIndexesColumn(e.detail.oldName, e.detail.newName)}
on:dragstart={(e) => onFieldDrag(e.detail, i)}
on:drop={(e) => onFieldDrop(e.detail, i)}
/>
{/each}
</div>
<div class="clearfix m-t-xs" />
<NewField class="btn btn-block btn-outline" on:select={(e) => newField(e.detail)} />
<button
type="button"
class="btn btn-block {collection.schema.length ? 'btn-transparent' : 'btn-secondary'}"
on:click={newField}
>
<i class="ri-add-line" />
<span class="txt">New field</span>
</button>
<div class="clearfix m-b-base" />
<IndexesList bind:collection />
@@ -72,7 +72,7 @@
this={codeEditorComponent}
id={uniqueId}
placeholder="eg. SELECT id, name from posts"
language="sql"
language="sql-select"
minHeight="150"
on:change={() => {
if (schemaErrors.length) {
@@ -11,7 +11,7 @@
let showFiltersInfo = false;
</script>
<div class="block m-b-base handle" class:fade={!showFiltersInfo}>
<div class="block m-b-sm handle" class:fade={!showFiltersInfo}>
<div class="flex txt-sm txt-hint m-b-5">
<p>
All rules follow the
@@ -77,22 +77,17 @@
<RuleField label="List/Search rule" formKey="listRule" {collection} bind:rule={collection.listRule} />
<hr />
<RuleField label="View rule" formKey="viewRule" {collection} bind:rule={collection.viewRule} />
{#if !collection?.isView}
<hr />
<RuleField label="Create rule" formKey="createRule" {collection} bind:rule={collection.createRule} />
<hr />
<RuleField label="Update rule" formKey="updateRule" {collection} bind:rule={collection.updateRule} />
<hr />
<RuleField label="Delete rule" formKey="deleteRule" {collection} bind:rule={collection.deleteRule} />
{/if}
{#if collection?.isAuth}
<hr />
<RuleField
label="Manage rule"
formKey="options.manageRule"
@@ -69,6 +69,13 @@
collection.deleteRule = null;
}
// update indexes on collection rename
$: if (collection?.name && original?.name != collection?.name) {
collection.indexes = collection.indexes.map((idx) =>
CommonHelper.replaceIndexTableName(idx, collection.name)
);
}
export function changeTab(newTab) {
activeTab = newTab;
}
@@ -228,6 +235,15 @@
field.id = "";
}
}
// update indexes with the new table name
if (!CommonHelper.isEmpty(clone.indexes)) {
for (let i = 0; i < clone.indexes.length; i++) {
const parsed = CommonHelper.parseIndex(clone.indexes[i]);
parsed.tableName = clone.name;
clone.indexes[i] = CommonHelper.buildIndex(parsed);
}
}
}
show(clone);
@@ -241,6 +257,7 @@
<OverlayPanel
bind:this={collectionPanel}
class="overlay-panel-lg colored-header collection-panel"
escClose={false}
beforeHide={() => {
if (hasChanges && confirmClose) {
confirm("You have unsaved changes. Do you really want to close the panel?", () => {
@@ -1,353 +0,0 @@
<script>
import { createEventDispatcher, onMount } from "svelte";
import { scale, fly } from "svelte/transition";
import { SchemaField } from "pocketbase";
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import { errors } from "@/stores/errors";
import Toggler from "@/components/base/Toggler.svelte";
import Accordion from "@/components/base/Accordion.svelte";
import Field from "@/components/base/Field.svelte";
import FieldTypeSelect from "@/components/collections/schema/FieldTypeSelect.svelte";
import TextOptions from "@/components/collections/schema/TextOptions.svelte";
import NumberOptions from "@/components/collections/schema/NumberOptions.svelte";
import BoolOptions from "@/components/collections/schema/BoolOptions.svelte";
import EmailOptions from "@/components/collections/schema/EmailOptions.svelte";
import UrlOptions from "@/components/collections/schema/UrlOptions.svelte";
import EditorOptions from "@/components/collections/schema/EditorOptions.svelte";
import DateOptions from "@/components/collections/schema/DateOptions.svelte";
import SelectOptions from "@/components/collections/schema/SelectOptions.svelte";
import JsonOptions from "@/components/collections/schema/JsonOptions.svelte";
import FileOptions from "@/components/collections/schema/FileOptions.svelte";
import RelationOptions from "@/components/collections/schema/RelationOptions.svelte";
const dispatch = createEventDispatcher();
export let key = "0";
export let field = new SchemaField();
export let disabled = false;
export let excludeNames = [];
let accordion;
let initialType = field.type;
$: if (initialType != field.type) {
initialType = field.type;
// reset common options
field.options = {};
field.unique = false;
}
$: canBeStored = !CommonHelper.isEmpty(field.name) && field.type;
$: if (!canBeStored) {
accordion && expand();
}
$: if (field.toDelete) {
accordion && collapse();
// reset the name if it was previously deleted
if (field.originalName && field.name !== field.originalName) {
field.name = field.originalName;
}
}
$: if (!field.originalName && field.name) {
field.originalName = field.name;
}
$: if (typeof field.toDelete === "undefined") {
field.toDelete = false; // normalize
}
$: if (field.required) {
field.nullable = false;
}
$: interactive = !disabled && !field.system && !field.toDelete && canBeStored;
$: hasValidName = validateFieldName(field.name);
$: hasErrors =
!hasValidName || !CommonHelper.isEmpty(CommonHelper.getNestedVal($errors, `schema.${key}`));
export function expand() {
accordion?.expand();
}
export function collapse() {
accordion?.collapse();
}
function handleDelete() {
if (!field.id) {
collapse();
dispatch("remove");
} else {
field.toDelete = true;
}
}
function validateFieldName(name) {
name = ("" + name).toLowerCase();
if (!name) {
return false;
}
for (const excluded of excludeNames) {
if (excluded.toLowerCase() === name) {
return false;
}
}
return true;
}
function normalizeFieldName(name) {
return CommonHelper.slugify(name);
}
function requiredLabel(field) {
switch (field?.type) {
case "bool":
return "Nonfalsey";
case "number":
return "Nonzero";
default:
return "Nonempty";
}
}
onMount(() => {
if (field?.onMountExpand) {
field.onMountExpand = false; // auto expand only the first time
expand();
}
});
</script>
<Accordion
bind:this={accordion}
on:expand
on:collapse
on:toggle
on:dragenter
on:dragleave
on:dragstart
on:drop
draggable
single
{interactive}
class={disabled || field.toDelete || field.system ? "field-accordion disabled" : "field-accordion"}
{...$$restProps}
>
<svelte:fragment slot="header">
<div class="inline-flex">
<span class="icon field-type">
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
</span>
<strong class="title field-name" class:txt-strikethrough={field.toDelete} title={field.name}>
{field.name || "-"}
</strong>
</div>
{#if !field.toDelete}
<div class="inline-flex">
{#if field.system}
<span class="label label-danger">System</span>
{/if}
{#if !field.id}
<span class="label" class:label-warning={interactive && !field.toDelete}>New</span>
{/if}
{#if field.required}
<span class="label label-success">{requiredLabel(field)}</span>
{/if}
{#if field.unique}
<span class="label label-success">Unique</span>
{/if}
</div>
{/if}
<div class="flex-fill" />
{#if hasErrors && !field.system}
<i
class="ri-error-warning-fill txt-danger"
transition:scale={{ duration: 150, start: 0.7 }}
use:tooltip={{ text: "Has errors", position: "left" }}
/>
{/if}
{#if field.toDelete}
<button
type="button"
class="btn btn-sm btn-danger btn-transparent"
on:click|stopPropagation={() => {
field.toDelete = false;
}}
>
<span class="txt">Restore</span>
</button>
{/if}
</svelte:fragment>
<form
class="field-form"
on:dragstart={(e) => {
e.stopPropagation();
e.preventDefault();
e.stopImmediatePropagation();
}}
on:submit|preventDefault={() => {
canBeStored && collapse();
}}
>
<div class="grid">
<div class="col-sm-6">
<Field
class="form-field required {field.id ? 'readonly' : ''}"
name="schema.{key}.type"
let:uniqueId
>
<label for={uniqueId}>Type</label>
<FieldTypeSelect id={uniqueId} disabled={field.id} bind:value={field.type} />
</Field>
</div>
<div class="col-sm-6">
<Field
class="
form-field
required
{!hasValidName ? 'invalid' : ''}
{field.id && field.system ? 'disabled' : ''}
"
name="schema.{key}.name"
let:uniqueId
>
<label for={uniqueId}>
<span class="txt">Name</span>
{#if !hasValidName}
<span class="txt invalid-name-note" transition:fly={{ duration: 150, x: 5 }}>
Duplicated or invalid name
</span>
{/if}
</label>
<!-- svelte-ignore a11y-autofocus -->
<input
type="text"
id={uniqueId}
required
disabled={field.id && field.system}
spellcheck="false"
autofocus={!field.id}
value={field.name}
on:input={(e) => {
field.name = normalizeFieldName(e.target.value);
e.target.value = field.name;
}}
/>
</Field>
</div>
<div class="col-sm-12 hidden-empty">
{#if field.type === "text"}
<TextOptions {key} bind:options={field.options} />
{:else if field.type === "number"}
<NumberOptions {key} bind:options={field.options} />
{:else if field.type === "bool"}
<BoolOptions {key} bind:options={field.options} />
{:else if field.type === "email"}
<EmailOptions {key} bind:options={field.options} />
{:else if field.type === "url"}
<UrlOptions {key} bind:options={field.options} />
{:else if field.type === "editor"}
<EditorOptions {key} bind:options={field.options} />
{:else if field.type === "date"}
<DateOptions {key} bind:options={field.options} />
{:else if field.type === "select"}
<SelectOptions {key} bind:options={field.options} />
{:else if field.type === "json"}
<JsonOptions {key} bind:options={field.options} />
{:else if field.type === "file"}
<FileOptions {key} bind:options={field.options} />
{:else if field.type === "relation"}
<RelationOptions {key} bind:options={field.options} />
{/if}
</div>
<div class="col-sm-4 flex">
<Field class="form-field form-field-toggle m-0" name="requried" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={field.required} />
<label for={uniqueId}>
<span class="txt">{requiredLabel(field)}</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: `Requires the field value to be ${requiredLabel(
field
)}\n(aka. not ${CommonHelper.zeroDefaultStr(field)}).`,
position: "right",
}}
/>
</label>
</Field>
</div>
<div class="col-sm-4 flex">
{#if field.type !== "file"}
<Field class="form-field form-field-toggle m-0" name="unique" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={field.unique} />
<label for={uniqueId}>Unique</label>
</Field>
{/if}
</div>
{#if !field.toDelete}
<div class="col-sm-4 txt-right">
<div class="flex-fill" />
<div class="inline-flex flex-gap-sm flex-nowrap">
<button type="button" aria-label="More" class="btn btn-circle btn-sm btn-transparent">
<i class="ri-more-line" />
<Toggler
class="dropdown dropdown-sm dropdown-upside dropdown-right dropdown-nowrap no-min-width"
>
<button type="button" class="dropdown-item txt-right" on:click={handleDelete}>
<span class="txt">Remove</span>
</button>
</Toggler>
</button>
{#if interactive}
<button
type="button"
class="btn btn-sm btn-outline btn-expanded-sm"
on:click|stopPropagation={collapse}
>
<span class="txt">Done</span>
</button>
{/if}
</div>
</div>
{/if}
</div>
<input type="submit" class="hidden" tabindex="-1" />
</form>
</Accordion>
<style>
.invalid-name-note {
position: absolute;
right: 10px;
top: 10px;
text-transform: none;
}
.title.field-name {
max-width: 130px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
</style>
@@ -0,0 +1,162 @@
<script>
import { onMount, createEventDispatcher } from "svelte";
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import Field from "@/components/base/Field.svelte";
const dispatch = createEventDispatcher();
export let collection;
let panel;
let original = "";
let index = "";
let key = "";
let codeEditorComponent;
let isCodeEditorComponentLoading = false;
$: presetColumns = collection?.schema?.map((f) => f.name) || [];
$: indexParts = CommonHelper.parseIndex(index);
$: indexColumns = indexParts.columns?.map((c) => c.column) || [];
export function show(showIndex, showKey) {
key = !CommonHelper.isEmpty(showKey) ? showKey : "";
original = showIndex || blankIndex();
index = original;
return panel?.show();
}
export function hide() {
return panel?.hide();
}
function blankIndex() {
const parsed = CommonHelper.parseIndex("");
parsed.tableName = collection?.name || "";
return CommonHelper.buildIndex(parsed);
}
function remove() {
dispatch("remove", original);
hide();
}
function submit() {
if (!indexColumns.length) {
return;
}
dispatch("submit", {
old: original,
new: index,
});
hide();
}
function toggleColumn(column) {
const clone = CommonHelper.clone(indexParts);
const col = clone.columns.find((c) => c.column == column);
if (col) {
CommonHelper.removeByValue(clone.columns, col);
} else {
CommonHelper.pushUnique(clone.columns, { column });
}
index = CommonHelper.buildIndex(clone);
}
onMount(async () => {
isCodeEditorComponentLoading = true;
try {
codeEditorComponent = (await import("@/components/base/CodeEditor.svelte")).default;
} catch (err) {
console.warn(err);
}
isCodeEditorComponentLoading = false;
});
</script>
<OverlayPanel bind:this={panel} popup on:hide on:show {...$$restProps}>
<svelte:fragment slot="header">
<h4>{original ? "Update" : "Create"} index</h4>
</svelte:fragment>
<Field class="form-field form-field-toggle m-b-sm" let:uniqueId>
<input
type="checkbox"
id={uniqueId}
checked={indexParts.unique}
on:change={(e) => {
indexParts.unique = e.target.checked;
indexParts.tableName = indexParts.tableName || collection?.name;
index = CommonHelper.buildIndex(indexParts);
}}
/>
<label for={uniqueId}>Unique</label>
</Field>
<Field class="form-field required m-b-sm" name={`indexes.${key || ""}`} let:uniqueId>
{#if isCodeEditorComponentLoading}
<textarea disabled rows="7" placeholder="Loading..." />
{:else}
<svelte:component
this={codeEditorComponent}
id={uniqueId}
placeholder={`eg. CREATE INDEX idx_test on ${collection?.name} (created)`}
language="sql-create-index"
minHeight="85"
bind:value={index}
/>
{/if}
</Field>
{#if presetColumns.length > 0}
<div class="inline-flex gap-10">
<span class="txt txt-hint">Presets</span>
{#each presetColumns as column}
<button
type="button"
class="label link-primary"
class:label-info={indexColumns.includes(column)}
on:click={() => toggleColumn(column)}
>
{column}
</button>
{/each}
</div>
{/if}
<svelte:fragment slot="footer">
{#if original != ""}
<button
type="button"
class="btn btn-sm btn-circle btn-hint btn-transparent m-r-auto"
use:tooltip={{ text: "Delete", position: "top" }}
on:click={() => remove()}
>
<i class="ri-delete-bin-7-line" />
</button>
{/if}
<button type="button" class="btn btn-transparent" on:click={() => hide()}>
<span class="txt">Cancel</span>
</button>
<button
type="button"
class="btn"
class:btn-disabled={indexColumns.length <= 0}
on:click={() => submit()}
>
<span class="txt">Set index</span>
</button>
</svelte:fragment>
</OverlayPanel>
@@ -0,0 +1,82 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import { errors, removeError } from "@/stores/errors";
import tooltip from "@/actions/tooltip";
import IndexUpsertPanel from "@/components/collections/IndexUpsertPanel.svelte";
export let collection;
let upsertPanel;
function pushOrReplace(oldIndex, newIndex) {
for (let i = 0; i < collection.indexes.length; i++) {
// replace
if (collection.indexes[i] == oldIndex) {
collection.indexes[i] = newIndex;
removeError("indexes." + i);
return;
}
}
// push missing
collection.indexes.push(newIndex);
collection.indexes = collection.indexes;
}
</script>
<div class="section-title">
Unique constraints and indexes ({collection?.indexes?.length || 0})
</div>
<div class="indexes-list">
{#each collection?.indexes || [] as rawIndex, i}
{@const parsed = CommonHelper.parseIndex(rawIndex)}
<button
type="button"
class="label link-primary {$errors.indexes?.[i]?.message ? 'label-danger' : 'label-info'}"
use:tooltip={$errors.indexes?.[i]?.message || ""}
on:click={() => upsertPanel?.show(rawIndex, i)}
>
{#if parsed.unique}
<strong>Unique:</strong>
{/if}
<span class="txt">
{parsed.columns?.map((c) => c.column).join(", ")}
</span>
</button>
{/each}
<button type="button" class="label label-primary link-fade" on:click={() => upsertPanel?.show()}>
<span class="txt">+</span>
<span class="txt">New index</span>
</button>
</div>
<IndexUpsertPanel
bind:this={upsertPanel}
bind:collection
on:remove={(e) => {
for (let i = 0; i < collection.indexes.length; i++) {
if (collection.indexes[i] == e.detail) {
collection.indexes.splice(i, 1);
removeError("indexes." + i);
break;
}
}
collection.indexes = collection.indexes;
}}
on:submit={(e) => {
pushOrReplace(e.detail.old, e.detail.new);
}}
/>
<style lang="scss">
.indexes-list {
display: flex;
flex-wrap: wrap;
width: 100%;
gap: 10px;
}
.label {
overflow: hidden;
min-width: 50px;
}
</style>
@@ -1,6 +0,0 @@
<script>
// svelte-ignore unused-export-let
export let key = "";
// svelte-ignore unused-export-let
export let options = {};
</script>
@@ -1,34 +0,0 @@
<script>
import Flatpickr from "svelte-flatpickr";
import CommonHelper from "@/utils/CommonHelper";
import Field from "@/components/base/Field.svelte";
export let key = "";
export let options = {};
</script>
<div class="grid">
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.min" let:uniqueId>
<label for={uniqueId}>Min date (UTC)</label>
<Flatpickr
id={uniqueId}
options={CommonHelper.defaultFlatpickrOptions()}
value={options.min}
bind:formattedValue={options.min}
/>
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.max" let:uniqueId>
<label for={uniqueId}>Max date (UTC)</label>
<Flatpickr
id={uniqueId}
options={CommonHelper.defaultFlatpickrOptions()}
value={options.max}
bind:formattedValue={options.max}
/>
</Field>
</div>
</div>
@@ -1,6 +0,0 @@
<script>
// svelte-ignore unused-export-let
export let key = "";
// svelte-ignore unused-export-let
export let options = {};
</script>
@@ -1,53 +0,0 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import MultipleValueInput from "@/components/base/MultipleValueInput.svelte";
export let key = "";
export let options = {};
</script>
<div class="grid">
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.exceptDomains" let:uniqueId>
<label for={uniqueId}>
<span class="txt">Except domains</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: 'List of domains that are NOT allowed. \n This field is disabled if "Only domains" is set.',
position: "top",
}}
/>
</label>
<MultipleValueInput
id={uniqueId}
disabled={!CommonHelper.isEmpty(options.onlyDomains)}
bind:value={options.exceptDomains}
/>
<div class="help-block">Use comma as separator.</div>
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.onlyDomains" let:uniqueId>
<label for="{uniqueId}.options.onlyDomains">
<span class="txt">Only domains</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: 'List of domains that are ONLY allowed. \n This field is disabled if "Except domains" is set.',
position: "top",
}}
/>
</label>
<MultipleValueInput
id="{uniqueId}.options.onlyDomains"
disabled={!CommonHelper.isEmpty(options.exceptDomains)}
bind:value={options.onlyDomains}
/>
<div class="help-block">Use comma as separator.</div>
</Field>
</div>
</div>
@@ -1,208 +0,0 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import Toggler from "@/components/base/Toggler.svelte";
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
import MimeTypeSelectOption from "@/components/base/MimeTypeSelectOption.svelte";
import MultipleValueInput from "@/components/base/MultipleValueInput.svelte";
import baseMimeTypesList from "@/mimes.js";
export let key = "";
export let options = {};
let mimeTypesList = baseMimeTypesList.slice();
$: if (CommonHelper.isEmpty(options)) {
// load defaults
options = {
maxSelect: 1,
maxSize: 5242880,
thumbs: [],
mimeTypes: [],
};
} else {
appendMissingMimeTypes();
}
// append any previously set custom mime types to the predefined
// list for backward compatibility
function appendMissingMimeTypes() {
if (CommonHelper.isEmpty(options.mimeTypes)) {
return;
}
const missing = [];
for (const v of options.mimeTypes) {
if (!!mimeTypesList.find((item) => item.mimeType === v)) {
continue; // exist
}
missing.push({ mimeType: v });
}
if (missing.length) {
mimeTypesList = mimeTypesList.concat(missing);
}
}
</script>
<div class="grid">
<div class="col-sm-6">
<Field class="form-field required" name="schema.{key}.options.maxSize" let:uniqueId>
<label for={uniqueId}>Max file size (bytes)</label>
<input type="number" id={uniqueId} step="1" min="0" bind:value={options.maxSize} />
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field required" name="schema.{key}.options.maxSelect" let:uniqueId>
<label for={uniqueId}>Max files</label>
<input type="number" id={uniqueId} step="1" min="" required bind:value={options.maxSelect} />
</Field>
</div>
<div class="col-sm-12">
<Field class="form-field" name="schema.{key}.options.mimeTypes" let:uniqueId>
<label for={uniqueId}>
<span class="txt">Allowed mime types</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: "Allow files ONLY with the listed mime types. \n Leave empty for no restriction.",
position: "top",
}}
/>
</label>
<ObjectSelect
id={uniqueId}
multiple
searchable
closable={false}
selectionKey="mimeType"
selectPlaceholder="No restriction"
items={mimeTypesList}
labelComponent={MimeTypeSelectOption}
optionComponent={MimeTypeSelectOption}
bind:keyOfSelected={options.mimeTypes}
/>
<div class="help-block">
<button type="button" class="inline-flex flex-gap-0">
<span class="txt link-primary">Choose presets</span>
<i class="ri-arrow-drop-down-fill" />
<Toggler class="dropdown dropdown-sm dropdown-nowrap dropdown-left">
<button
type="button"
class="dropdown-item closable"
on:click={() => {
options.mimeTypes = [
"image/jpeg",
"image/png",
"image/svg+xml",
"image/gif",
"image/webp",
];
}}
>
<span class="txt">Images (jpg, png, svg, gif, webp)</span>
</button>
<button
type="button"
class="dropdown-item closable"
on:click={() => {
options.mimeTypes = [
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
];
}}
>
<span class="txt">Documents (pdf, doc/docx, xls/xlsx)</span>
</button>
<button
type="button"
class="dropdown-item closable"
on:click={() => {
options.mimeTypes = [
"video/mp4",
"video/x-ms-wmv",
"video/quicktime",
"video/3gpp",
];
}}
>
<span class="txt">Videos (mp4, avi, mov, 3gp)</span>
</button>
<button
type="button"
class="dropdown-item closable"
on:click={() => {
options.mimeTypes = [
"application/zip",
"application/x-7z-compressed",
"application/x-rar-compressed",
];
}}
>
<span class="txt">Archives (zip, 7zip, rar)</span>
</button>
</Toggler>
</button>
</div>
</Field>
</div>
<div class="col-sm-12">
<Field class="form-field" name="schema.{key}.options.thumbs" let:uniqueId>
<label for={uniqueId}>
<span class="txt">Thumb sizes</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: "List of additional thumb sizes for image files, along with the default thumb size of 100x100. The thumbs are generated lazily on first access.",
position: "top",
}}
/>
</label>
<MultipleValueInput id={uniqueId} placeholder="eg. 50x50, 480x720" bind:value={options.thumbs} />
<div class="help-block">
<span class="txt">Use comma as separator.</span>
<button type="button" class="inline-flex flex-gap-0">
<span class="txt link-primary">Supported formats</span>
<i class="ri-arrow-drop-down-fill" />
<Toggler class="dropdown dropdown-sm dropdown-center dropdown-nowrap p-r-10">
<ul class="m-0">
<li>
<strong>WxH</strong>
(eg. 100x50) - crop to WxH viewbox (from center)
</li>
<li>
<strong>WxHt</strong>
(eg. 100x50t) - crop to WxH viewbox (from top)
</li>
<li>
<strong>WxHb</strong>
(eg. 100x50b) - crop to WxH viewbox (from bottom)
</li>
<li>
<strong>WxHf</strong>
(eg. 100x50f) - fit inside a WxH viewbox (without cropping)
</li>
<li>
<strong>0xH</strong>
(eg. 0x50) - resize to H height preserving the aspect ratio
</li>
<li>
<strong>Wx0</strong>
(eg. 100x0) - resize to W width preserving the aspect ratio
</li>
</ul>
</Toggler>
</button>
</div>
</Field>
</div>
</div>
@@ -1,50 +0,0 @@
<script>
import { slide } from "svelte/transition";
// svelte-ignore unused-export-let
export const key = "";
// svelte-ignore unused-export-let
export const options = {};
let showInfo = false;
</script>
<button
type="button"
class="inline-flex txt-sm flex-gap-5 link-hint"
on:click={() => {
showInfo = !showInfo;
}}
>
<strong class="txt">String value normalizations</strong>
{#if showInfo}
<i class="ri-arrow-up-s-line txt-sm" />
{:else}
<i class="ri-arrow-down-s-line txt-sm" />
{/if}
</button>
{#if showInfo}
<div class="block" transition:slide|local={{ duration: 150 }}>
<div class="alert alert-warning m-b-0 m-t-10">
<div class="content">
In order to support seamlessly both <code>application/json</code> and
<code>multipart/form-data</code>
requests, the following normalization rules are applied if the <code>json</code> field is a
<strong>plain string</strong>:
<ul>
<li>"true" is converted to the json <code>true</code></li>
<li>"false" is converted to the json <code>false</code></li>
<li>"null" is converted to the json <code>null</code></li>
<li>"[1,2,3]" is converted to the json <code>[1,2,3]</code></li>
<li>{'"{"a":1,"b":2}"'} is converted to the json <code>{'{"a":1,"b":2}'}</code></li>
<li>numeric strings are converted to json number</li>
<li>double quoted strings are left as they are (aka. without normalizations)</li>
<li>any other string (empty string too) is double quoted</li>
</ul>
Alternatively, if you want to avoid the string value normalizations, you can wrap your data inside
an object, eg.<code>{'{"data": anything}'}</code>
</div>
</div>
</div>
{/if}
@@ -1,12 +1,13 @@
<script>
import { createEventDispatcher } from "svelte";
import CommonHelper from "@/utils/CommonHelper";
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
export let value = "text";
import Toggler from "@/components/base/Toggler.svelte";
let classes = "";
export { classes as class }; // export reserved keyword
const dispatch = createEventDispatcher();
const types = [
{
label: "Plain text",
@@ -64,6 +65,50 @@
icon: CommonHelper.getFieldTypeIcon("json"),
},
];
function select(fieldType) {
dispatch("select", fieldType);
}
</script>
<ObjectSelect class="field-type-select {classes}" items={types} bind:keyOfSelected={value} {...$$restProps} />
<button type="button" class={classes} on:click={dispatch}>
<i class="ri-add-line" />
<div class="txt">New field</div>
<Toggler class="dropdown field-types-dropdown">
{#each types as item}
<div
tabindex="0"
class="dropdown-item closable"
on:click|stopPropagation={() => {
select(item.value);
}}
on:keydown|stopPropagation={(e) => {
if (e.code === "Enter" || e.code === "Space") {
select(item.value);
}
}}
>
<i class="icon {item.icon}" />
<span class="txt">{item.label}</span>
</div>
{/each}
</Toggler>
</button>
<style lang="scss">
:global(.field-types-dropdown) {
display: flex;
flex-wrap: wrap;
width: 100%;
max-width: none;
padding: 10px;
margin: 0;
border: 0;
box-shadow: 0px 0px 0px 2px var(--primaryColor);
border-top-left-radius: 0;
border-top-right-radius: 0;
.dropdown-item {
width: 25%;
}
}
</style>
@@ -1,22 +0,0 @@
<script>
import Field from "@/components/base/Field.svelte";
export let key = "";
export let options = {};
</script>
<div class="grid">
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.min" let:uniqueId>
<label for={uniqueId}>Min</label>
<input type="number" id={uniqueId} bind:value={options.min} />
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.max" let:uniqueId>
<label for={uniqueId}>Max</label>
<input type="number" id={uniqueId} min={options.min} bind:value={options.max} />
</Field>
</div>
</div>
@@ -1,187 +0,0 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import Select from "@/components/base/Select.svelte";
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
import CollectionUpsertPanel from "@/components/collections/CollectionUpsertPanel.svelte";
import { collections } from "@/stores/collections";
export let key = "";
export let options = {};
const isSingleOptions = [
{ label: "Single", value: true },
{ label: "Multiple", value: false },
];
const defaultOptions = [
{ label: "False", value: false },
{ label: "True", value: true },
];
const baseFields = ["id", "created", "updated"];
const authFields = ["username", "email", "emailVisibility", "verified"];
let upsertPanel = null;
let displayFieldsList = [];
let oldCollectionId = null;
let isSingle = options?.maxSelect == 1;
let oldIsSingle = isSingle;
// load defaults
$: if (CommonHelper.isEmpty(options)) {
options = {
maxSelect: 1,
collectionId: null,
cascadeDelete: false,
displayFields: [],
};
isSingle = true;
oldIsSingle = isSingle;
}
$: if (oldIsSingle != isSingle) {
oldIsSingle = isSingle;
if (isSingle) {
options.minSelect = null;
options.maxSelect = 1;
} else {
options.maxSelect = null;
}
}
$: selectedColection = $collections.find((c) => c.id == options.collectionId) || null;
$: if (oldCollectionId != options.collectionId) {
oldCollectionId = options.collectionId;
refreshDisplayFieldsList();
}
function refreshDisplayFieldsList() {
displayFieldsList = baseFields.slice(0);
if (!selectedColection) {
return;
}
if (selectedColection.isAuth) {
displayFieldsList = displayFieldsList.concat(authFields);
}
for (const field of selectedColection.schema) {
displayFieldsList.push(field.name);
}
// deselect any missing display field
if (options?.displayFields?.length > 0) {
for (let i = options.displayFields.length - 1; i >= 0; i--) {
if (!displayFieldsList.includes(options.displayFields[i])) {
options.displayFields.splice(i, 1);
}
}
}
}
</script>
<div class="grid">
<div class="col-sm-6">
<Field class="form-field required" name="schema.{key}.options.collectionId" let:uniqueId>
<label for={uniqueId}>Collection</label>
<ObjectSelect
id={uniqueId}
searchable={$collections.length > 5}
selectPlaceholder={"Select collection"}
noOptionsText="No collections found"
selectionKey="id"
items={$collections}
bind:keyOfSelected={options.collectionId}
>
<svelte:fragment slot="afterOptions">
<hr />
<button
type="button"
class="btn btn-transparent btn-block btn-sm"
on:click={() => upsertPanel?.show()}
>
<i class="ri-add-line" />
<span class="txt">New collection</span>
</button>
</svelte:fragment>
</ObjectSelect>
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field" let:uniqueId>
<label for={uniqueId}>Relation type</label>
<ObjectSelect id={uniqueId} items={isSingleOptions} bind:keyOfSelected={isSingle} />
</Field>
</div>
{#if !isSingle}
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.minSelect" let:uniqueId>
<label for={uniqueId}>Min select</label>
<input
type="number"
id={uniqueId}
step="1"
min="1"
placeholder="No min limit"
bind:value={options.minSelect}
/>
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.maxSelect" let:uniqueId>
<label for={uniqueId}>Max select</label>
<input
type="number"
id={uniqueId}
step="1"
placeholder="No max limit"
min={options.minSelect || 2}
bind:value={options.maxSelect}
/>
</Field>
</div>
{/if}
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.displayFields" let:uniqueId>
<label for={uniqueId}>
<span class="txt">Display fields</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: "Optionally select the field(s) that will be used in the listings UI. Leave empty for auto.",
position: "top",
}}
/>
</label>
<Select
multiple
searchable
id={uniqueId}
selectPlaceholder="Auto"
items={displayFieldsList}
bind:selected={options.displayFields}
/>
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.cascadeDelete" let:uniqueId>
<label for={uniqueId}>Delete main record on relation delete</label>
<ObjectSelect id={uniqueId} items={defaultOptions} bind:keyOfSelected={options.cascadeDelete} />
</Field>
</div>
</div>
<CollectionUpsertPanel
bind:this={upsertPanel}
on:save={(e) => {
if (e?.detail?.collection?.id) {
options.collectionId = e.detail.collection.id;
}
}}
/>
@@ -0,0 +1,217 @@
<script>
import { createEventDispatcher } from "svelte";
import { SchemaField } from "pocketbase";
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import { errors, setErrors } from "@/stores/errors";
import Toggler from "@/components/base/Toggler.svelte";
import Field from "@/components/base/Field.svelte";
const dispatch = createEventDispatcher();
export let key = "";
export let field = new SchemaField();
let optionsTrigger;
let isDragOver = false;
$: if (field.toDelete) {
// reset the name if it was previously deleted
if (field.originalName && field.name !== field.originalName) {
field.name = field.originalName;
}
}
$: if (!field.originalName && field.name) {
field.originalName = field.name;
}
$: if (typeof field.toDelete === "undefined") {
field.toDelete = false; // normalize
}
$: if (field.required) {
field.nullable = false;
}
$: interactive = !field.toDelete && !(field.id && field.system);
$: hasErrors = !CommonHelper.isEmpty(CommonHelper.getNestedVal($errors, `schema.${key}`));
function remove() {
if (!field.id) {
dispatch("remove");
} else {
field.toDelete = true;
}
}
function restore() {
field.toDelete = false;
// reset all errors since the error index key would have been changed
setErrors({});
}
function normalizeFieldName(name) {
return CommonHelper.slugify(name);
}
function requiredLabel(field) {
switch (field?.type) {
case "bool":
return "Nonfalsey";
case "number":
return "Nonzero";
default:
return "Nonempty";
}
}
</script>
<div
draggable={true}
class="schema-field"
class:drag-over={isDragOver}
on:dragstart={(e) => {
if (!e.target.classList.contains("drag-handle-wrapper")) {
e.preventDefault();
return;
}
const blank = document.createElement("div");
e.dataTransfer.setDragImage(blank, 0, 0);
interactive && dispatch("dragstart", e);
}}
on:dragenter={(e) => {
if (interactive) {
isDragOver = true;
dispatch("dragenter", e);
}
}}
on:drop|preventDefault={(e) => {
if (interactive) {
isDragOver = false;
dispatch("drop", e);
}
}}
on:dragleave={(e) => {
if (interactive) {
isDragOver = false;
dispatch("dragleave", e);
}
}}
on:dragover|preventDefault
>
<div class="schema-field-header">
{#if interactive}
<div class="drag-handle-wrapper" draggable="true" aria-label="Sort">
<span class="drag-handle" />
</div>
{/if}
<Field
class="form-field required m-0 {!interactive ? 'disabled' : ''}"
name="schema.{key}.name"
inlineError
>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="form-field-addon prefix no-pointer-events" class:txt-disabled={!interactive}>
<i class={CommonHelper.getFieldTypeIcon(field.type)} />
</div>
<!-- svelte-ignore a11y-autofocus -->
<input
type="text"
required
disabled={!interactive}
readonly={field.id && field.system}
spellcheck="false"
autofocus={!field.id}
placeholder="Field name"
value={field.name}
on:input={(e) => {
const oldName = field.name;
field.name = normalizeFieldName(e.target.value);
e.target.value = field.name;
dispatch("rename", { oldName: oldName, newName: field.name });
}}
/>
</Field>
<slot {interactive} {hasErrors} />
{#if field.toDelete}
<button
type="button"
class="btn btn-sm btn-circle btn-warning btn-transparent options-trigger"
aria-label="Restore"
use:tooltip={"Restore"}
on:click={restore}
>
<i class="ri-restart-line" />
</button>
{:else if interactive}
<button
bind:this={optionsTrigger}
type="button"
aria-label="Field options"
class="btn btn-sm btn-circle btn-transparent options-trigger {hasErrors
? 'btn-danger'
: 'btn-hint'}"
>
<i class="ri-settings-3-line" />
</button>
{/if}
</div>
{#if interactive}
<Toggler class="dropdown dropdown-block schema-field-dropdown" trigger={optionsTrigger}>
<div class="grid grid-sm">
<div class="col-sm-12 hidden-empty">
<slot name="options" {interactive} {hasErrors} />
</div>
<div class="col-sm-4 flex">
<Field class="form-field form-field-toggle m-0" name="requried" let:uniqueId>
<input type="checkbox" id={uniqueId} bind:checked={field.required} />
<label for={uniqueId}>
<span class="txt">{requiredLabel(field)}</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: `Requires the field value NOT to be ${CommonHelper.zeroDefaultStr(
field
)}.`,
position: "right",
}}
/>
</label>
</Field>
</div>
{#if !field.toDelete}
<div class="col-sm-4 m-l-auto txt-right">
<div class="flex-fill" />
<div class="inline-flex flex-gap-sm flex-nowrap">
<button
type="button"
aria-label="More"
class="btn btn-circle btn-sm btn-transparent"
>
<i class="ri-more-line" />
<Toggler
class="dropdown dropdown-sm dropdown-upside dropdown-right dropdown-nowrap no-min-width"
>
<button type="button" class="dropdown-item txt-right" on:click={remove}>
<span class="txt">Remove</span>
</button>
</Toggler>
</button>
</div>
</div>
{/if}
</div>
</Toggler>
{/if}
</div>
@@ -0,0 +1,18 @@
<script>
import SchemaField from "@/components/collections/schema/SchemaField.svelte";
export let field;
export let key = "";
</script>
<SchemaField
bind:field
{key}
on:rename
on:remove
on:drop
on:dragstart
on:dragenter
on:dragleave
{...$$restProps}
/>
@@ -0,0 +1,50 @@
<script>
import Flatpickr from "svelte-flatpickr";
import CommonHelper from "@/utils/CommonHelper";
import Field from "@/components/base/Field.svelte";
import SchemaField from "@/components/collections/schema/SchemaField.svelte";
export let field;
export let key = "";
</script>
<SchemaField
bind:field
{key}
on:rename
on:remove
on:drop
on:dragstart
on:dragenter
on:dragleave
{...$$restProps}
>
<svelte:fragment slot="options">
<div class="grid grid-sm">
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.min" let:uniqueId>
<label for={uniqueId}>Min date (UTC)</label>
<Flatpickr
id={uniqueId}
options={CommonHelper.defaultFlatpickrOptions()}
value={field.options.min}
bind:formattedValue={field.options.min}
/>
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.max" let:uniqueId>
<label for={uniqueId}>Max date (UTC)</label>
<Flatpickr
id={uniqueId}
options={CommonHelper.defaultFlatpickrOptions()}
value={field.options.max}
bind:formattedValue={field.options.max}
/>
</Field>
</div>
</div>
</svelte:fragment>
</SchemaField>
@@ -0,0 +1,18 @@
<script>
import SchemaField from "@/components/collections/schema/SchemaField.svelte";
export let field;
export let key = "";
</script>
<SchemaField
bind:field
{key}
on:rename
on:remove
on:drop
on:dragstart
on:dragenter
on:dragleave
{...$$restProps}
/>
@@ -0,0 +1,68 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import MultipleValueInput from "@/components/base/MultipleValueInput.svelte";
import SchemaField from "@/components/collections/schema/SchemaField.svelte";
export let field;
export let key = "";
</script>
<SchemaField
bind:field
{key}
on:rename
on:remove
on:drop
on:dragstart
on:dragenter
on:dragleave
{...$$restProps}
>
<svelte:fragment slot="options">
<div class="grid grid-sm">
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.exceptDomains" let:uniqueId>
<label for={uniqueId}>
<span class="txt">Except domains</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: 'List of domains that are NOT allowed. \n This field is disabled if "Only domains" is set.',
position: "top",
}}
/>
</label>
<MultipleValueInput
id={uniqueId}
disabled={!CommonHelper.isEmpty(field.options.onlyDomains)}
bind:value={field.options.exceptDomains}
/>
<div class="help-block">Use comma as separator.</div>
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.onlyDomains" let:uniqueId>
<label for="{uniqueId}.options.onlyDomains">
<span class="txt">Only domains</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: 'List of domains that are ONLY allowed. \n This field is disabled if "Except domains" is set.',
position: "top",
}}
/>
</label>
<MultipleValueInput
id="{uniqueId}.options.onlyDomains"
disabled={!CommonHelper.isEmpty(field.options.exceptDomains)}
bind:value={field.options.onlyDomains}
/>
<div class="help-block">Use comma as separator.</div>
</Field>
</div>
</div>
</svelte:fragment>
</SchemaField>
@@ -0,0 +1,280 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import Toggler from "@/components/base/Toggler.svelte";
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
import MimeTypeSelectOption from "@/components/base/MimeTypeSelectOption.svelte";
import MultipleValueInput from "@/components/base/MultipleValueInput.svelte";
import SchemaField from "@/components/collections/schema/SchemaField.svelte";
import baseMimeTypesList from "@/mimes.js";
export let field;
export let key = "";
const isSingleOptions = [
{ label: "Single", value: true },
{ label: "Multiple", value: false },
];
let mimeTypesList = baseMimeTypesList.slice();
let isSingle = field.options?.maxSelect <= 1;
let oldIsSingle = isSingle;
$: if (CommonHelper.isEmpty(field.options)) {
loadDefaults();
} else {
appendMissingMimeTypes();
}
$: if (oldIsSingle != isSingle) {
oldIsSingle = isSingle;
if (isSingle) {
field.options.maxSelect = 1;
} else {
field.options.maxSelect = field.options?.values?.length || 99;
}
}
function loadDefaults() {
field.options = {
maxSelect: 1,
maxSize: 5242880,
thumbs: [],
mimeTypes: [],
};
isSingle = true;
oldIsSingle = isSingle;
}
// append any previously set custom mime types to the predefined
// list for backward compatibility
function appendMissingMimeTypes() {
if (CommonHelper.isEmpty(field.options.mimeTypes)) {
return;
}
const missing = [];
for (const v of field.options.mimeTypes) {
if (!!mimeTypesList.find((item) => item.mimeType === v)) {
continue; // exist
}
missing.push({ mimeType: v });
}
if (missing.length) {
mimeTypesList = mimeTypesList.concat(missing);
}
}
</script>
<SchemaField
bind:field
{key}
on:rename
on:remove
on:drop
on:dragstart
on:dragenter
on:dragleave
{...$$restProps}
>
<svelte:fragment let:interactive>
<Field
class="form-field form-field-single-multiple-select {!interactive ? 'disabled' : ''}"
inlineError
let:uniqueId
>
<ObjectSelect
id={uniqueId}
items={isSingleOptions}
disabled={!interactive}
bind:keyOfSelected={isSingle}
/>
</Field>
</svelte:fragment>
<svelte:fragment slot="options">
<div class="grid grid-sm">
<div class="col-sm-12">
<Field class="form-field" name="schema.{key}.options.mimeTypes" let:uniqueId>
<label for={uniqueId}>
<span class="txt">Allowed mime types</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: "Allow files ONLY with the listed mime types. \n Leave empty for no restriction.",
position: "top",
}}
/>
</label>
<ObjectSelect
id={uniqueId}
multiple
searchable
closable={false}
selectionKey="mimeType"
selectPlaceholder="No restriction"
items={mimeTypesList}
labelComponent={MimeTypeSelectOption}
optionComponent={MimeTypeSelectOption}
bind:keyOfSelected={field.options.mimeTypes}
/>
<div class="help-block">
<button type="button" class="inline-flex flex-gap-0">
<span class="txt link-primary">Choose presets</span>
<i class="ri-arrow-drop-down-fill" />
<Toggler class="dropdown dropdown-sm dropdown-nowrap dropdown-left">
<button
type="button"
class="dropdown-item closable"
on:click={() => {
field.options.mimeTypes = [
"image/jpeg",
"image/png",
"image/svg+xml",
"image/gif",
"image/webp",
];
}}
>
<span class="txt">Images (jpg, png, svg, gif, webp)</span>
</button>
<button
type="button"
class="dropdown-item closable"
on:click={() => {
field.options.mimeTypes = [
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
];
}}
>
<span class="txt">Documents (pdf, doc/docx, xls/xlsx)</span>
</button>
<button
type="button"
class="dropdown-item closable"
on:click={() => {
field.options.mimeTypes = [
"video/mp4",
"video/x-ms-wmv",
"video/quicktime",
"video/3gpp",
];
}}
>
<span class="txt">Videos (mp4, avi, mov, 3gp)</span>
</button>
<button
type="button"
class="dropdown-item closable"
on:click={() => {
field.options.mimeTypes = [
"application/zip",
"application/x-7z-compressed",
"application/x-rar-compressed",
];
}}
>
<span class="txt">Archives (zip, 7zip, rar)</span>
</button>
</Toggler>
</button>
</div>
</Field>
</div>
<div class={!isSingle ? "col-sm-6" : "col-sm-8"}>
<Field class="form-field" name="schema.{key}.options.thumbs" let:uniqueId>
<label for={uniqueId}>
<span class="txt">Thumb sizes</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: "List of additional thumb sizes for image files, along with the default thumb size of 100x100. The thumbs are generated lazily on first access.",
position: "top",
}}
/>
</label>
<MultipleValueInput
id={uniqueId}
placeholder="eg. 50x50, 480x720"
bind:value={field.options.thumbs}
/>
<div class="help-block">
<span class="txt">Use comma as separator.</span>
<button type="button" class="inline-flex flex-gap-0">
<span class="txt link-primary">Supported formats</span>
<i class="ri-arrow-drop-down-fill" />
<Toggler class="dropdown dropdown-sm dropdown-center dropdown-nowrap p-r-10">
<ul class="m-0">
<li>
<strong>WxH</strong>
(eg. 100x50) - crop to WxH viewbox (from center)
</li>
<li>
<strong>WxHt</strong>
(eg. 100x50t) - crop to WxH viewbox (from top)
</li>
<li>
<strong>WxHb</strong>
(eg. 100x50b) - crop to WxH viewbox (from bottom)
</li>
<li>
<strong>WxHf</strong>
(eg. 100x50f) - fit inside a WxH viewbox (without cropping)
</li>
<li>
<strong>0xH</strong>
(eg. 0x50) - resize to H height preserving the aspect ratio
</li>
<li>
<strong>Wx0</strong>
(eg. 100x0) - resize to W width preserving the aspect ratio
</li>
</ul>
</Toggler>
</button>
</div>
</Field>
</div>
<div class={!isSingle ? "col-sm-3" : "col-sm-4"}>
<Field class="form-field required" name="schema.{key}.options.maxSize" let:uniqueId>
<label for={uniqueId}>Max file size</label>
<input type="number" id={uniqueId} step="1" min="0" bind:value={field.options.maxSize} />
<div class="help-block">Must be in bytes.</div>
</Field>
</div>
{#if !isSingle}
<div class="col-sm-3">
<Field class="form-field required" name="schema.{key}.options.maxSelect" let:uniqueId>
<label for={uniqueId}>Max select</label>
<input
id={uniqueId}
type="number"
step="1"
min="2"
required
bind:value={field.options.maxSelect}
/>
</Field>
</div>
{/if}
</div>
</svelte:fragment>
</SchemaField>
<style>
:global(.form-field-file-max-select) {
width: 100px;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,66 @@
<script>
import { slide } from "svelte/transition";
import SchemaField from "@/components/collections/schema/SchemaField.svelte";
export let field;
export let key = "";
let showInfo = false;
</script>
<SchemaField
bind:field
{key}
on:rename
on:remove
on:drop
on:dragstart
on:dragenter
on:dragleave
{...$$restProps}
>
<svelte:fragment slot="options">
<button
type="button"
class="inline-flex txt-sm flex-gap-5 link-hint"
on:click={() => {
showInfo = !showInfo;
}}
>
<strong class="txt">String value normalizations</strong>
{#if showInfo}
<i class="ri-arrow-up-s-line txt-sm" />
{:else}
<i class="ri-arrow-down-s-line txt-sm" />
{/if}
</button>
{#if showInfo}
<div class="block" transition:slide|local={{ duration: 150 }}>
<div class="alert alert-warning m-b-0 m-t-10">
<div class="content">
In order to support seamlessly both <code>application/json</code> and
<code>multipart/form-data</code>
requests, the following normalization rules are applied if the <code>json</code> field
is a
<strong>plain string</strong>:
<ul>
<li>"true" is converted to the json <code>true</code></li>
<li>"false" is converted to the json <code>false</code></li>
<li>"null" is converted to the json <code>null</code></li>
<li>"[1,2,3]" is converted to the json <code>[1,2,3]</code></li>
<li>
{'"{"a":1,"b":2}"'} is converted to the json <code>{'{"a":1,"b":2}'}</code>
</li>
<li>numeric strings are converted to json number</li>
<li>double quoted strings are left as they are (aka. without normalizations)</li>
<li>any other string (empty string too) is double quoted</li>
</ul>
Alternatively, if you want to avoid the string value normalizations, you can wrap your
data inside an object, eg.<code>{'{"data": anything}'}</code>
</div>
</div>
</div>
{/if}
</svelte:fragment>
</SchemaField>
@@ -0,0 +1,42 @@
<script>
import Field from "@/components/base/Field.svelte";
import SchemaField from "@/components/collections/schema/SchemaField.svelte";
export let field;
export let key = "";
</script>
<SchemaField
bind:field
{key}
on:rename
on:remove
on:drop
on:dragstart
on:dragenter
on:dragleave
{...$$restProps}
>
<svelte:fragment slot="options">
<div class="grid grid-sm">
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.min" let:uniqueId>
<label for={uniqueId}>Min</label>
<input type="number" id={uniqueId} bind:value={field.options.min} />
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.max" let:uniqueId>
<label for={uniqueId}>Max</label>
<input
type="number"
id={uniqueId}
min={field.options.min}
bind:value={field.options.max}
/>
</Field>
</div>
</div>
</svelte:fragment>
</SchemaField>
@@ -0,0 +1,222 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import Select from "@/components/base/Select.svelte";
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
import CollectionUpsertPanel from "@/components/collections/CollectionUpsertPanel.svelte";
import SchemaField from "@/components/collections/schema/SchemaField.svelte";
import { collections } from "@/stores/collections";
export let field;
export let key = "";
const isSingleOptions = [
{ label: "Single", value: true },
{ label: "Multiple", value: false },
];
const defaultOptions = [
{ label: "False", value: false },
{ label: "True", value: true },
];
const baseFields = ["id", "created", "updated"];
const authFields = ["username", "email", "emailVisibility", "verified"];
let upsertPanel = null;
let displayFieldsList = [];
let oldCollectionId = null;
let isSingle = field.options?.maxSelect == 1;
let oldIsSingle = isSingle;
// load defaults
$: if (CommonHelper.isEmpty(field.options)) {
loadDefaults();
}
$: if (oldIsSingle != isSingle) {
oldIsSingle = isSingle;
if (isSingle) {
field.options.minSelect = null;
field.options.maxSelect = 1;
} else {
field.options.maxSelect = null;
}
}
$: selectedColection = $collections.find((c) => c.id == field.options.collectionId) || null;
$: if (oldCollectionId != field.options.collectionId) {
oldCollectionId = field.options.collectionId;
refreshDisplayFieldsList();
}
function loadDefaults() {
field.options = {
maxSelect: 1,
collectionId: null,
cascadeDelete: false,
displayFields: [],
};
isSingle = true;
oldIsSingle = isSingle;
}
function refreshDisplayFieldsList() {
displayFieldsList = baseFields.slice(0);
if (!selectedColection) {
return;
}
if (selectedColection.isAuth) {
displayFieldsList = displayFieldsList.concat(authFields);
}
for (const f of selectedColection.schema) {
displayFieldsList.push(f.name);
}
// deselect any missing display field
if (field.options?.displayFields?.length > 0) {
for (let i = field.options.displayFields.length - 1; i >= 0; i--) {
if (!displayFieldsList.includes(field.options.displayFields[i])) {
field.options.displayFields.splice(i, 1);
}
}
}
}
</script>
<SchemaField
bind:field
{key}
on:rename
on:remove
on:drop
on:dragstart
on:dragenter
on:dragleave
{...$$restProps}
>
<svelte:fragment let:interactive>
<Field
class="form-field required {!interactive ? 'disabled' : ''}"
inlineError
name="schema.{key}.options.collectionId"
let:uniqueId
>
<ObjectSelect
id={uniqueId}
searchable={$collections.length > 5}
selectPlaceholder={"Select collection *"}
noOptionsText="No collections found"
selectionKey="id"
items={$collections}
disabled={!interactive}
bind:keyOfSelected={field.options.collectionId}
>
<svelte:fragment slot="afterOptions">
<hr />
<button
type="button"
class="btn btn-transparent btn-block btn-sm"
on:click={() => upsertPanel?.show()}
>
<i class="ri-add-line" />
<span class="txt">New collection</span>
</button>
</svelte:fragment>
</ObjectSelect>
</Field>
<Field
class="form-field form-field-single-multiple-select {!interactive ? 'disabled' : ''}"
inlineError
let:uniqueId
>
<ObjectSelect
id={uniqueId}
items={isSingleOptions}
disabled={!interactive}
bind:keyOfSelected={isSingle}
/>
</Field>
</svelte:fragment>
<svelte:fragment slot="options">
<div class="grid grid-sm">
{#if !isSingle}
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.minSelect" let:uniqueId>
<label for={uniqueId}>Min select</label>
<input
type="number"
id={uniqueId}
step="1"
min="1"
placeholder="No min limit"
bind:value={field.options.minSelect}
/>
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.maxSelect" let:uniqueId>
<label for={uniqueId}>Max select</label>
<input
type="number"
id={uniqueId}
step="1"
placeholder="No max limit"
min={field.options.minSelect || 2}
bind:value={field.options.maxSelect}
/>
</Field>
</div>
{/if}
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.displayFields" let:uniqueId>
<label for={uniqueId}>
<span class="txt">Display fields</span>
<i
class="ri-information-line link-hint"
use:tooltip={{
text: "Optionally select the field(s) that will be used in the listings UI. Leave empty for auto.",
position: "top",
}}
/>
</label>
<Select
multiple
searchable
id={uniqueId}
selectPlaceholder="Auto"
items={displayFieldsList}
bind:selected={field.options.displayFields}
/>
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.cascadeDelete" let:uniqueId>
<label for={uniqueId}>Delete main record on relation delete</label>
<ObjectSelect
id={uniqueId}
items={defaultOptions}
bind:keyOfSelected={field.options.cascadeDelete}
/>
</Field>
</div>
</div>
</svelte:fragment>
</SchemaField>
<CollectionUpsertPanel
bind:this={upsertPanel}
on:save={(e) => {
if (e?.detail?.collection?.id) {
field.options.collectionId = e.detail.collection.id;
}
}}
/>
@@ -0,0 +1,100 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import tooltip from "@/actions/tooltip";
import Field from "@/components/base/Field.svelte";
import ObjectSelect from "@/components/base/ObjectSelect.svelte";
import SchemaField from "@/components/collections/schema/SchemaField.svelte";
import MultipleValueInput from "@/components/base/MultipleValueInput.svelte";
export let field;
export let key = "";
const isSingleOptions = [
{ label: "Single", value: true },
{ label: "Multiple", value: false },
];
let isSingle = field.options?.maxSelect <= 1;
let oldIsSingle = isSingle;
$: if (CommonHelper.isEmpty(field.options)) {
loadDefaults();
}
$: if (oldIsSingle != isSingle) {
oldIsSingle = isSingle;
if (isSingle) {
field.options.maxSelect = 1;
} else {
field.options.maxSelect = field.options?.values?.length || 2;
}
}
function loadDefaults() {
field.options = {
maxSelect: 1,
values: [],
};
isSingle = true;
oldIsSingle = isSingle;
}
</script>
<SchemaField
bind:field
{key}
on:rename
on:remove
on:drop
on:dragstart
on:dragenter
on:dragleave
{...$$restProps}
>
<svelte:fragment let:interactive>
<Field
class="form-field required {!interactive ? 'disabled' : ''}"
inlineError
name="schema.{key}.options.values"
let:uniqueId
>
<div use:tooltip={{ text: "Choices (comma separated)", position: "top-left", delay: 700 }}>
<MultipleValueInput
id={uniqueId}
placeholder="Choices: eg. optionA, optionB"
required
disabled={!interactive}
bind:value={field.options.values}
/>
</div>
</Field>
<Field
class="form-field form-field-single-multiple-select {!interactive ? 'disabled' : ''}"
inlineError
let:uniqueId
>
<ObjectSelect
id={uniqueId}
items={isSingleOptions}
disabled={!interactive}
bind:keyOfSelected={isSingle}
/>
</Field>
</svelte:fragment>
<svelte:fragment slot="options">
{#if !isSingle}
<Field class="form-field required" name="schema.{key}.options.maxSelect" let:uniqueId>
<label for={uniqueId}>Max select</label>
<input
id={uniqueId}
type="number"
step="1"
min="2"
required
bind:value={field.options.maxSelect}
/>
</Field>
{/if}
</svelte:fragment>
</SchemaField>
@@ -0,0 +1,55 @@
<script>
import SchemaField from "@/components/collections/schema/SchemaField.svelte";
import Field from "@/components/base/Field.svelte";
export let field;
export let key = "";
</script>
<SchemaField
bind:field
{key}
on:rename
on:remove
on:drop
on:dragstart
on:dragenter
on:dragleave
{...$$restProps}
>
<svelte:fragment slot="options">
<div class="grid grid-sm">
<div class="col-sm-3">
<Field class="form-field" name="schema.{key}.options.min" let:uniqueId>
<label for={uniqueId}>Min length</label>
<input type="number" id={uniqueId} step="1" min="0" bind:value={field.options.min} />
</Field>
</div>
<div class="col-sm-3">
<Field class="form-field" name="schema.{key}.options.max" let:uniqueId>
<label for={uniqueId}>Max length</label>
<input
type="number"
id={uniqueId}
step="1"
min={field.options.min || 0}
bind:value={field.options.max}
/>
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.pattern" let:uniqueId>
<label for={uniqueId}>Regex pattern</label>
<input
type="text"
id={uniqueId}
placeholder={"Valid Go regular expression, eg. ^w+$"}
bind:value={field.options.pattern}
/>
</Field>
</div>
</div>
</svelte:fragment>
</SchemaField>
@@ -0,0 +1,19 @@
<script>
import SchemaFieldEmail from "./SchemaFieldEmail.svelte";
export let field;
export let key = "";
</script>
<!-- shares the same options with the email field -->
<SchemaFieldEmail
bind:field
{key}
on:rename
on:remove
on:drop
on:dragstart
on:dragenter
on:dragleave
{...$$restProps}
/>
@@ -1,43 +0,0 @@
<script>
import CommonHelper from "@/utils/CommonHelper";
import Field from "@/components/base/Field.svelte";
import MultipleValueInput from "@/components/base/MultipleValueInput.svelte";
export let key = "";
export let options = {};
$: if (CommonHelper.isEmpty(options)) {
// load defaults
options = {
maxSelect: 1,
values: [],
};
}
// note: leave the validation to the api
// $: if (!CommonHelper.isEmpty(options.values) && options.maxSelect > options.values.length) {
// options.maxSelect = options.values.length;
// }
</script>
<div class="grid">
<div class="col-sm-9">
<Field class="form-field required" name="schema.{key}.options.values" let:uniqueId>
<label for={uniqueId}>Choices</label>
<MultipleValueInput
id={uniqueId}
placeholder="eg. optionA, optionB"
required
bind:value={options.values}
/>
<div class="help-block">Use comma as separator.</div>
</Field>
</div>
<div class="col-sm-3">
<Field class="form-field required" name="schema.{key}.options.maxSelect" let:uniqueId>
<label for={uniqueId}>Max select</label>
<input type="number" id={uniqueId} step="1" min="1" required bind:value={options.maxSelect} />
</Field>
</div>
</div>
@@ -1,30 +0,0 @@
<script>
import Field from "@/components/base/Field.svelte";
export let key = "";
export let options = {};
</script>
<div class="grid">
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.min" let:uniqueId>
<label for={uniqueId}>Min length</label>
<input type="number" id={uniqueId} step="1" min="0" bind:value={options.min} />
</Field>
</div>
<div class="col-sm-6">
<Field class="form-field" name="schema.{key}.options.max" let:uniqueId>
<label for={uniqueId}>Max length</label>
<input type="number" id={uniqueId} step="1" min={options.min || 0} bind:value={options.max} />
</Field>
</div>
<div class="col-sm-12">
<Field class="form-field" name="schema.{key}.options.pattern" let:uniqueId>
<label for={uniqueId}>Regex pattern</label>
<input type="text" id={uniqueId} bind:value={options.pattern} />
<div class="help-block">Valid Go regular expression, eg. <code>^\w+$</code>.</div>
</Field>
</div>
</div>
@@ -1,9 +0,0 @@
<script>
import EmailOptions from "./EmailOptions.svelte";
export let key = "";
export let options = {};
</script>
<!-- shares the same options with the email field -->
<EmailOptions bind:key bind:options />