diff --git a/cmd/migrate.go b/cmd/migrate.go
index 1d095266..c7e4f259 100644
--- a/cmd/migrate.go
+++ b/cmd/migrate.go
@@ -27,7 +27,7 @@ Supported arguments are:
- up - runs all available migrations.
- down [number] - reverts the last [number] applied migrations.
- create name [folder] - creates new migration template file.
-- collections [folder] - creates new migration file with the current collections configuration.
+- collections [folder] - (Experimental) creates new migration file with the most recent local collections configuration.
`
var databaseFlag string
diff --git a/daos/collection.go b/daos/collection.go
index d50625bd..8ca72840 100644
--- a/daos/collection.go
+++ b/daos/collection.go
@@ -190,7 +190,7 @@ func (dao *Dao) ImportCollections(
mappedImported := make(map[string]*models.Collection, len(importedCollections))
for _, imported := range importedCollections {
- // normalize
+ // normalize ids
if !imported.HasId() {
// generate id if not set
imported.MarkAsNew()
@@ -199,6 +199,15 @@ func (dao *Dao) ImportCollections(
imported.MarkAsNew()
}
+ // extend existing schema
+ if existing, ok := mappedExisting[imported.GetId()]; ok && !deleteMissing {
+ schema, _ := existing.Schema.Clone()
+ for _, f := range imported.Schema.Fields() {
+ schema.AddField(f) // add or replace
+ }
+ imported.Schema = *schema
+ }
+
mappedImported[imported.GetId()] = imported
}
diff --git a/forms/collection_upsert.go b/forms/collection_upsert.go
index 54098f71..cf9f8df1 100644
--- a/forms/collection_upsert.go
+++ b/forms/collection_upsert.go
@@ -1,6 +1,7 @@
package forms
import (
+ "fmt"
"regexp"
"strings"
@@ -115,6 +116,7 @@ func (form *CollectionUpsert) Validate() error {
&form.Schema,
validation.By(form.ensureNoSystemFieldsChange),
validation.By(form.ensureNoFieldsTypeChange),
+ validation.By(form.ensureExistingRelationCollectionId),
),
validation.Field(&form.ListRule, validation.By(form.checkRule)),
validation.Field(&form.ViewRule, validation.By(form.checkRule)),
@@ -161,11 +163,38 @@ func (form *CollectionUpsert) ensureNoSystemFlagChange(value any) error {
func (form *CollectionUpsert) ensureNoFieldsTypeChange(value any) error {
v, _ := value.(schema.Schema)
- for _, field := range v.Fields() {
+ for i, field := range v.Fields() {
oldField := form.collection.Schema.GetFieldById(field.Id)
if oldField != nil && oldField.Type != field.Type {
- return validation.NewError("validation_field_type_change", "Field type cannot be changed.")
+ return validation.Errors{fmt.Sprint(i): validation.NewError(
+ "validation_field_type_change",
+ "Field type cannot be changed.",
+ )}
+ }
+ }
+
+ return nil
+}
+
+func (form *CollectionUpsert) ensureExistingRelationCollectionId(value any) error {
+ v, _ := value.(schema.Schema)
+
+ for i, field := range v.Fields() {
+ if field.Type != schema.FieldTypeRelation {
+ continue
+ }
+
+ options, _ := field.Options.(*schema.RelationOptions)
+ if options == nil {
+ continue
+ }
+
+ if _, err := form.config.TxDao.FindCollectionByNameOrId(options.CollectionId); err != nil {
+ return validation.Errors{fmt.Sprint(i): validation.NewError(
+ "validation_field_invalid_relation",
+ "The relation collection doesn't exist.",
+ )}
}
}
diff --git a/forms/collections_import.go b/forms/collections_import.go
index 3c31b8c5..70314c73 100644
--- a/forms/collections_import.go
+++ b/forms/collections_import.go
@@ -139,11 +139,11 @@ func (form *CollectionsImport) beforeRecordsSync(txDao *daos.Dao, mappedNew, map
if err := upsertForm.Validate(); err != nil {
// serialize the validation error(s)
- serializedErr, _ := json.Marshal(err)
+ serializedErr, _ := json.MarshalIndent(err, "", " ")
return validation.Errors{"collections": validation.NewError(
"collections_import_validate_failure",
- fmt.Sprintf("Data validations failed for collection %q (%s): %s", collection.Name, collection.Id, serializedErr),
+ fmt.Sprintf("Data validations failed for collection %q (%s):\n%s", collection.Name, collection.Id, serializedErr),
)}
}
}
diff --git a/ui/src/components/base/Field.svelte b/ui/src/components/base/Field.svelte
index 5bd06e12..22a701d0 100644
--- a/ui/src/components/base/Field.svelte
+++ b/ui/src/components/base/Field.svelte
@@ -39,7 +39,7 @@
{#each fieldErrors as error}
{#if typeof error === "object"}
- {error?.message || error?.code || defaultError}
+
{error?.message || error?.code || defaultError}
{:else}
{error || defaultError}
{/if}
diff --git a/ui/src/components/base/Toasts.svelte b/ui/src/components/base/Toasts.svelte
index baf4f3c3..d6be1db6 100644
--- a/ui/src/components/base/Toasts.svelte
+++ b/ui/src/components/base/Toasts.svelte
@@ -20,6 +20,8 @@
{:else if toast.type === "success"}
+ {:else if toast.type === "warning"}
+
{:else}
{/if}
diff --git a/ui/src/components/collections/CollectionsDiffTable.svelte b/ui/src/components/collections/CollectionsDiffTable.svelte
new file mode 100644
index 00000000..43cec496
--- /dev/null
+++ b/ui/src/components/collections/CollectionsDiffTable.svelte
@@ -0,0 +1,264 @@
+
+
+
+ {#if !collectionA?.id}
+
{collectionB?.name}
+
Added
+ {:else if !collectionB?.id}
+
{collectionA?.name}
+
Removed
+ {:else}
+
+ {#if collectionA.name !== collectionB.name}
+ {collectionA.name}
+
+ {/if}
+ {collectionB.name}
+ {#if hasAnyChange}
+ Changed
+ {/if}
+
+ {/if}
+
+
+
+
+
+ Props |
+ Old |
+ New |
+
+
+
+
+ {#each mainModelProps as prop}
+
+
+ {prop}
+ |
+
+ {displayValue(collectionA?.[prop])}
+ |
+
+ {displayValue(collectionB?.[prop])}
+ |
+
+ {/each}
+
+ {#if deleteMissing || isDeleteDiff}
+ {#each removedFields as field}
+
+
+ schema.{field.name}
+
+ Removed -
+ All stored data related to {field.name} will be deleted!
+
+
+ |
+
+
+ {#each Object.entries(field) as [key, value]}
+
+ {key} |
+
+ {displayValue(value)}
+ |
+ |
+
+ {/each}
+ {/each}
+ {/if}
+
+ {#each sharedFields as field}
+
+
+ schema.{field.name}
+ {#if hasChanges(getFieldById(schemaA, field.id), getFieldById(schemaB, field.id))}
+ Changed
+ {/if}
+ |
+
+
+ {#each Object.entries(field) as [key, newValue]}
+
+ {key} |
+
+ {displayValue(getFieldById(schemaA, field.id)?.[key])}
+ |
+
+ {displayValue(newValue)}
+ |
+
+ {/each}
+ {/each}
+
+ {#each addedFields as field}
+
+
+ schema.{field.name}
+ Added
+ |
+
+
+ {#each Object.entries(field) as [key, value]}
+
+ {key} |
+ |
+
+ {displayValue(value)}
+ |
+
+ {/each}
+ {/each}
+
+
+
+
diff --git a/ui/src/components/settings/ImportPopup.svelte b/ui/src/components/settings/ImportPopup.svelte
index 74fff8e4..0c5e0c1f 100644
--- a/ui/src/components/settings/ImportPopup.svelte
+++ b/ui/src/components/settings/ImportPopup.svelte
@@ -2,29 +2,28 @@
import { createEventDispatcher } from "svelte";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
- import OverlayPanel from "@/components/base/OverlayPanel.svelte";
import { addSuccessToast } from "@/stores/toasts";
import { confirm } from "@/stores/confirmation";
+ import OverlayPanel from "@/components/base/OverlayPanel.svelte";
+ import CollectionsDiffTable from "@/components/collections/CollectionsDiffTable.svelte";
const dispatch = createEventDispatcher();
let panel;
let oldCollections = [];
let newCollections = [];
- let changes = [];
+ let pairs = [];
+ let deleteMissing = false;
let isImporting = false;
$: if (Array.isArray(oldCollections) && Array.isArray(newCollections)) {
- loadChanges();
+ loadPairs();
}
- $: deletedCollections = oldCollections.filter((old) => {
- return !CommonHelper.findByKey(newCollections, "id", old.id)?.id;
- });
-
- export function show(a, b) {
- oldCollections = a;
- newCollections = b;
+ export function show(oldCollectionsArg, newCollectionsArg, deleteMissingArg = false) {
+ oldCollections = oldCollectionsArg;
+ newCollections = newCollectionsArg;
+ deleteMissing = deleteMissingArg;
panel?.show();
}
@@ -33,25 +32,23 @@
return panel?.hide();
}
- function loadChanges() {
- changes = [];
+ function loadPairs() {
+ pairs = [];
// add deleted and modified collections
for (const oldCollection of oldCollections) {
const newCollection = CommonHelper.findByKey(newCollections, "id", oldCollection.id) || null;
- if (!newCollection?.id || JSON.stringify(oldCollection) != JSON.stringify(newCollection)) {
- changes.push({
- old: oldCollection,
- new: newCollection,
- });
- }
+ pairs.push({
+ old: oldCollection,
+ new: newCollection,
+ });
}
// add only new collections
for (const newCollection of newCollections) {
const oldCollection = CommonHelper.findByKey(oldCollections, "id", newCollection.id) || null;
if (!oldCollection?.id) {
- changes.push({
+ pairs.push({
old: oldCollection,
new: newCollection,
});
@@ -59,63 +56,32 @@
}
}
- function diffsToHtml(diffs, ops = [window.DIFF_INSERT, window.DIFF_DELETE, window.DIFF_EQUAL]) {
- const html = [];
- const pattern_amp = /&/g;
- const pattern_lt = //g;
- const pattern_para = /\n/g;
-
- for (let i = 0; i < diffs.length; i++) {
- const op = diffs[i][0]; // operation (insert, delete, equal)
-
- if (!ops.includes(op)) {
- continue;
- }
-
- const text = diffs[i][1]
- .replace(pattern_amp, "&")
- .replace(pattern_lt, "<")
- .replace(pattern_gt, ">")
- .replace(pattern_para, "
");
-
- switch (op) {
- case DIFF_INSERT:
- html[i] = '
' + text + "";
- break;
- case DIFF_DELETE:
- html[i] = '
' + text + "";
- break;
- case DIFF_EQUAL:
- html[i] = text;
- break;
+ function submitWithConfirm() {
+ // find deleted fields
+ const deletedFieldNames = [];
+ if (deleteMissing) {
+ for (const old of oldCollections) {
+ const imported = !CommonHelper.findByKey(newCollections, "id", old.id);
+ if (!imported) {
+ // add all fields
+ deletedFieldNames.push(old.name + ".*");
+ } else {
+ // add only deleted fields
+ const schema = Array.isArray(old.schema) ? old.schema : [];
+ for (const field of schema) {
+ if (!CommonHelper.findByKey(imported.schema, "id", field.id)) {
+ deletedFieldNames.push(old.name + "." + field.name);
+ }
+ }
+ }
}
}
- return html.join("");
- }
-
- function diff(obj1, obj2, ops = [window.DIFF_INSERT, window.DIFF_DELETE, window.DIFF_EQUAL]) {
- const dmp = new diff_match_patch();
- const lines = dmp.diff_linesToChars_(
- obj1 ? JSON.stringify(obj1, null, 4) : "",
- obj2 ? JSON.stringify(obj2, null, 4) : ""
- );
- const diffs = dmp.diff_main(lines.chars1, lines.chars2, false);
-
- dmp.diff_charsToLines_(diffs, lines.lineArray);
-
- return diffsToHtml(diffs, ops);
- }
-
- function submitWithConfirm() {
- if (deletedCollections.length) {
- const deletedNames = deletedCollections.map((c) => c.name);
-
+ if (deletedFieldNames.length) {
confirm(
- `Do you really want to delete the following collections and their related records data:\n- ${deletedNames.join(
+ `Do you really want to delete the following collection fields and their related records data:\n- ${deletedFieldNames.join(
"\n- "
- )}?`,
+ )}`,
() => {
submit();
}
@@ -133,8 +99,8 @@
isImporting = true;
try {
- await ApiClient.collections.import(newCollections, true);
- addSuccessToast("Successfully imported the collections configuration.");
+ await ApiClient.collections.import(newCollections, deleteMissing);
+ addSuccessToast("Successfully imported collections configuration.");
dispatch("submit");
} catch (err) {
ApiClient.errorResponseHandler(err);
@@ -148,7 +114,7 @@
-
-
diff --git a/ui/src/components/settings/PageExportCollections.svelte b/ui/src/components/settings/PageExportCollections.svelte
index 9accfcca..fd599566 100644
--- a/ui/src/components/settings/PageExportCollections.svelte
+++ b/ui/src/components/settings/PageExportCollections.svelte
@@ -15,7 +15,7 @@
let collections = [];
let isLoadingCollections = false;
- $: schema = JSON.stringify(collections, null, 2);
+ $: schema = JSON.stringify(collections, null, 4);
loadCollections();
diff --git a/ui/src/components/settings/PageImportCollections.svelte b/ui/src/components/settings/PageImportCollections.svelte
index dc7bfafa..9557fa78 100644
--- a/ui/src/components/settings/PageImportCollections.svelte
+++ b/ui/src/components/settings/PageImportCollections.svelte
@@ -19,7 +19,8 @@
let isLoadingFile = false;
let newCollections = [];
let oldCollections = [];
- let collectionsToModify = [];
+ let deleteMissing = false;
+ let collectionsToChange = [];
let isLoadingOldCollections = false;
$: if (typeof schemas !== "undefined") {
@@ -44,10 +45,32 @@
}
$: hasChanges =
- !!schemas && (collectionsToDelete.length || collectionsToAdd.length || collectionsToModify.length);
+ !!schemas && (collectionsToDelete.length || collectionsToAdd.length || collectionsToChange.length);
$: canImport = !isLoadingOldCollections && isValid && hasChanges;
+ $: idReplacableCollections = newCollections.filter((collection) => {
+ const old = CommonHelper.findByKey(oldCollections, "name", collection.name);
+ if (!old?.id) {
+ return false;
+ }
+
+ if (old.id != collection.id) {
+ return true;
+ }
+
+ const oldSchema = Array.isArray(old.schema) ? old.schema : [];
+ const newSchema = Array.isArray(collection.schema) ? collection.schema : [];
+ for (const field of newSchema) {
+ const oldField = CommonHelper.findByKey(oldSchema, "name", field.name);
+ if (oldField && field.id != oldField.id) {
+ return true;
+ }
+ }
+
+ return false;
+ });
+
loadOldCollections();
async function loadOldCollections() {
@@ -68,7 +91,7 @@
}
function loadCollectionsToModify() {
- collectionsToModify = [];
+ collectionsToChange = [];
if (!isValid) {
return;
@@ -85,7 +108,7 @@
continue;
}
- collectionsToModify.push({
+ collectionsToChange.push({
new: newCollection,
old: oldCollection,
});
@@ -105,13 +128,52 @@
newCollections = CommonHelper.filterDuplicatesByKey(newCollections);
}
- // delete timestamps
+ // normalizations
for (let collection of newCollections) {
+ // delete timestamps
delete collection.created;
delete collection.updated;
+
+ // merge fields with duplicated ids
+ collection.schema = CommonHelper.filterDuplicatesByKey(collection.schema);
}
}
+ function replaceIds() {
+ for (let collection of newCollections) {
+ const old = CommonHelper.findByKey(oldCollections, "name", collection.name);
+ if (!old?.id) {
+ continue;
+ }
+
+ const originalId = collection.id;
+ const replacedId = old.id;
+ collection.id = replacedId;
+
+ // replace field ids
+ const oldSchema = Array.isArray(old.schema) ? old.schema : [];
+ const newSchema = Array.isArray(collection.schema) ? collection.schema : [];
+ for (const field of newSchema) {
+ const oldField = CommonHelper.findByKey(oldSchema, "name", field.name);
+ field.id = oldField.id;
+ }
+
+ // update references
+ for (let ref of newCollections) {
+ if (!Array.isArray(ref.schema)) {
+ continue;
+ }
+ for (let field of ref.schema) {
+ if (field.options?.collectionId === originalId) {
+ field.options.collectionId = replacedId;
+ }
+ }
+ }
+ }
+
+ schemas = JSON.stringify(newCollections, null, 4);
+ }
+
function loadFile(file) {
isLoadingFile = true;
@@ -207,6 +269,14 @@
{/if}
+
+
+
+
+
{#if isValid && newCollections.length && !hasChanges}
@@ -234,10 +304,10 @@
{/each}
{/if}
- {#if collectionsToModify.length}
- {#each collectionsToModify as pair (pair.old.id + pair.new.id)}
+ {#if collectionsToChange.length}
+ {#each collectionsToChange as pair (pair.old.id + pair.new.id)}
-
Modified
+
Changed
{#if pair.old.name !== pair.new.name}
{pair.old.name} -
@@ -254,7 +324,7 @@
{#if collectionsToAdd.length}
{#each collectionsToAdd as collection (collection.id)}
- New
+ Added
{collection.name}
{#if collection.id}
({collection.id})
@@ -265,6 +335,26 @@
{/if}
+ {#if idReplacableCollections.length}
+
+
+
+
+
+
+ Some of the imported collections shares the same name but has different IDs.
+
+
+
+
+ {/if}
+
{#if !!schemas}
diff --git a/ui/src/components/settings/SettingsSidebar.svelte b/ui/src/components/settings/SettingsSidebar.svelte
index bfe49fca..998fc33f 100644
--- a/ui/src/components/settings/SettingsSidebar.svelte
+++ b/ui/src/components/settings/SettingsSidebar.svelte
@@ -29,7 +29,10 @@
Files storage
-
+