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,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 />