import { toDeleteProp } from "@/base/fieldSettings"; import { collectionAuthOptionsTab } from "./collectionAuthOptionsTab"; import { collectionFieldsTab } from "./collectionFieldsTab"; import { collectionRulesTab } from "./collectionRulesTab"; import { collectionViewQueryTab } from "./collectionViewQueryTab"; window.app = window.app || {}; window.app.modals = window.app.modals || {}; window.app.modals.openCollectionUpsert = function(collection = {}, modalSettings = { // base modal events onbeforeopen: null, // function(el) {}, onafteropen: null, // function(el) {}, onbeforeclose: null, // function(el) {}, onafterclose: null, // function(el) {}, // collection specific events onsave: null, // function(collection, isNew) {}, ondelete: null, // function(collection) {}, onduplicate: null, // function(collection) {}, ontruncate: null, // function(collection) {}, }) { app.store.errors = null; // reset const modal = collectionUpsertModal(collection || {}, modalSettings || {}); document.body.appendChild(modal); app.modals.open(modal); }; window.app.collectionTypes = { "base": { "icon": "ri-folder-2-line", "tabs": { "Fields": collectionFieldsTab, "API rules": collectionRulesTab, }, }, "view": { "icon": "ri-table-line", "tabs": { "Query": collectionViewQueryTab, "API rules": collectionRulesTab, }, }, "auth": { "icon": "ri-group-line", "tabs": { "Fields": collectionFieldsTab, "API rules": collectionRulesTab, "Options": collectionAuthOptionsTab, }, }, }; function collectionUpsertModal(rawCollection, modalSettings) { let modal; const uniqueId = "collection_upsert_" + app.utils.randomString(); const data = store({ isSaving: false, originalCollection: {}, collection: {}, selectedTab: "", errorTabs: {}, get activeTab() { if (!app.collectionTypes[data.collection.type]?.tabs) { return data.selectedTab; } if ( !data.selectedTab || !app.collectionTypes[data.collection.type].tabs?.[data.selectedTab] ) { return Object.keys(app.collectionTypes[data.collection.type].tabs)?.[0] || ""; } return data.selectedTab; }, get isNew() { return app.utils.isEmpty(data.originalCollection?.id); }, get collectionTypeOptions() { return Object.keys(app.collectionTypes).map((type) => { return { value: type, label: app.utils.sentenize(type, false) + " collection", }; }); }, get collectionHash() { Object.keys(data.collection).length; return JSON.stringify(data.collection); }, get originalCollectionHash() { return JSON.stringify(data.originalCollection); }, get hasChanges() { return data.originalCollectionHash != data.collectionHash; }, get canSave() { return !data.isSaving && (data.isNew || data.hasChanges); }, }); async function initCollection(collection) { if (app.utils.isEmpty(collection)) { collection = JSON.parse(JSON.stringify(app.store.collectionScaffolds.base)) || { type: "base", fields: [], }; // add commonly used timestamp fields collection.fields.push({ type: "autodate", name: "created", onCreate: true, }); collection.fields.push({ type: "autodate", name: "updated", onCreate: true, onUpdate: true, }); } data.originalCollection = JSON.parse(JSON.stringify(collection)); data.collection = JSON.parse(JSON.stringify(collection)); } async function confirmSave(close = true) { if (!data.canSave) { return; } data.isSaving = true; app.modals.openCollectionChangesConfirmation( data.originalCollection, data.collection, () => save(close), () => { data.isSaving = false; }, ); } function exportPayload() { const payload = JSON.parse(JSON.stringify(data.collection)); payload.fields = payload.fields || []; // remove fields marked for deletion for (let i = payload.fields.length - 1; i >= 0; i--) { const field = payload.fields[i]; if (field[toDeleteProp]) { payload.fields.splice(i, 1); continue; } } return payload; } async function save(close = true) { data.isSaving = true; try { const payload = exportPayload(); const isNew = app.utils.isEmpty(data.originalCollection?.id); let request; if (isNew) { request = app.pb.collections.create(payload); } else { request = app.pb.collections.update(data.originalCollection.id, payload); } const rawCollection = JSON.stringify(await request); // manually reload scaffolds to ensure that followup collections would have up-to-date clean state app.store.collectionScaffolds = await app.pb.collections.getScaffolds({ requestKey: "save.reloadScaffolds", }); data.originalCollection = JSON.parse(rawCollection); data.collection = JSON.parse(rawCollection); app.store.addOrUpdateCollection(JSON.parse(rawCollection)); modalSettings?.onsave?.(JSON.parse(rawCollection), isNew); data.isSaving = false; app.toasts.success( isNew ? `Successfully created collection "${data.collection.name}".` : `Successfully updated collection "${data.collection.name}".`, { key: "collectionSave" }, ); // reset all errors app.store.errors = null; if (close) { app.modals.close(modal, true); } } catch (err) { if (!err?.isAbort) { data.isSaving = false; app.checkApiError(err, false); app.toasts.error(err.message || "Failed to save collection.", { key: "collectionSave" }); } } } function resetForm() { data.collection = JSON.parse(JSON.stringify(data.originalCollection)); // reset all errors app.store.errors = null; } async function duplicate() { const clone = data.originalCollection ? JSON.parse(JSON.stringify(data.originalCollection)) : {}; clone.id = ""; clone.system = false; clone.name = clone.name + "_duplicate"; clone.created = ""; clone.updated = ""; // updated indexes ids clone.indexes = clone.indexes?.map((idx) => { return app.utils.replaceIndexFields(idx, (parsed) => { return { indexName: parsed.indexName + app.utils.randomString(3), tableName: clone.name, }; }); }) || []; await modalSettings.onduplicate?.(clone); return initCollection(clone); } async function changeTab(tabName) { data.selectedTab = tabName; // ui tick await new Promise((r) => setTimeout(r, 0)); // refresh errors in case to retrigger validations if (app.store.errors) { app.store.errors = JSON.parse(JSON.stringify(app.store.errors)); } } modal = t.div( { pbEvent: "collectionUpsertModal", "html-data-collectionId": () => data.originalCollection?.id, "html-data-collectionName": () => data.originalCollection?.name, className: "modal collection-upsert-modal", inert: () => data.isSaving, onkeydown: (e) => { if ( (e.ctrlKey || e.metaKey) && e.code == "KeyS" && app.modals.getTop() === modal ) { e.preventDefault(); // temp blur any active input to make sure that onchange/blur events are fired const input = document.activeElement; input?.blur(); confirmSave(false); // restore previous active input input?.focus(); } }, onbeforeopen: () => { initCollection(rawCollection); return modalSettings.onbeforeopen?.(el); }, onafteropen: (el) => { modalSettings.onafteropen?.(el); }, onbeforeclose: (el, forceClosed) => { if (forceClosed) { return modalSettings.onbeforeclose?.(el); } if (data.isSaving) { return false; } if (!data.hasChanges) { return modalSettings.onbeforeclose?.(el); } return new Promise((r) => { app.modals.confirm( "You have unsaved changes. Do you really want to discard them?", () => r(modalSettings.onbeforeclose?.(el)), () => r(false), ); }); }, onafterclose: (el) => { modalSettings.onafterclose?.(el); el?.remove(); }, onmount: (el) => { el._watchers?.forEach((w) => w?.unwatch()); el._watchers = [ watch( () => data.collection.type, (newType, oldType) => { if (!oldType || newType == oldType || !app.store.collectionScaffolds[newType]) { return; } // reset fields list errors on type change app.utils.deleteByPath(app.store.errors, "fields"); // merge with the scaffold to ensure that the minimal props are set const scaffold = JSON.parse(JSON.stringify(app.store.collectionScaffolds[newType])); data.collection = Object.assign( structuredClone(scaffold), JSON.parse(JSON.stringify(data.collection)), ); data.originalCollection = scaffold; syncFieldsAndIndexesWithScaffold(data.collection); }, ), // collection rename watch( () => data.collection.name, (newName, oldName) => { newName = app.utils.slugify(newName); data.collection.name = newName; if (typeof oldName == "undefined" || !newName || newName == oldName) { return; } // update indexes with the latest collection name as table name clearTimeout(el.__collectionRenameTimeoutId); el.__collectionRenameTimeoutId = setTimeout(() => { data.collection.indexes = data.collection.indexes?.map((idx) => { return app.utils.replaceIndexFields(idx, { tableName: data.collection.name }); }); }, 150); }, ), // check for tab errors watch( () => JSON.stringify(app.store.errors), async (errors) => { if (!errors) { data.errorTabs = {}; return; } // ui tick await new Promise((r) => setTimeout(r, 0)); data.errorTabs = {}; // reset after the tick to minimize flickering const errorElems = modal?.querySelectorAll(".modal-content [data-tab] .error"); for (let el of errorElems) { const tabName = el.closest("[data-tab]")?.dataset?.tab; if (tabName) { data.errorTabs[tabName] = true; } } }, ), ]; }, onunmount: (el) => { clearTimeout(el?.__collectionRenameTimeoutId); el?._watchers?.forEach((w) => w?.unwatch()); }, }, t.header( { className: "modal-header isolated" }, t.div( { className: "grid sm" }, t.div( { className: "col-12 flex" }, t.h6( { className: "modal-title" }, t.span(null, () => (data.isNew ? "Create " : "Edit ")), t.strong( { hidden: () => data.isNew, className: "txt-ellipsis collection-name", }, () => data.originalCollection?.name, ), t.span(null, " collection"), ), t.div({ className: "flex-fill" }), () => { if (app.utils.isEmpty(data.originalCollection?.id)) { return; } return [ t.button( { type: "button", className: "btn sm circle transparent", title: "More options", "html-popovertarget": uniqueId + "modal-header-dropdown", }, t.i({ className: "ri-more-line", ariaHidden: true }), ), t.div( { id: uniqueId + "modal-header-dropdown", className: "dropdown nowrap modal-header-dropdown", popover: "auto", }, t.button( { type: "button", className: "dropdown-item", onclick: (e) => { e.target.closest(".dropdown").hidePopover(); app.utils.copyToClipboard( JSON.stringify(data.originalCollection, null, 2), ); app.toasts.success("Collection copied to clipboard!"); }, }, t.i({ className: "ri-braces-line", ariaHidden: true }), t.span({ className: "txt" }, "Copy JSON"), ), t.button( { type: "button", className: "dropdown-item", onclick: (e) => { e.target.closest(".dropdown").hidePopover(); if (data.hasChanges) { app.modals.confirm( "You have unsaved changes. Do you really want to discard them?", duplicate, null, { yesButton: "Yes, discard" }, ); } else { duplicate(); } }, }, t.i({ className: "ri-file-copy-line", ariaHidden: true }), t.span({ className: "txt" }, "Duplicate"), ), t.hr(), () => { if (data.collection.type == "view") { return; // view don't have their own records } return truncateDropdownItem(data, modalSettings); }, () => { if (data.collection.system) { return; // system collections cannot be deleted } return deleteDropdownItem(data, modalSettings); }, ), ]; }, ), t.div( { className: "col-12" }, t.div( { className: "fields" }, t.div( { className: "field" }, t.label({ htmlFor: uniqueId + "col_name", textContent: () => { return `Name${data.collection?.system ? " (system)" : ""}`; }, }), t.input({ id: uniqueId + "col_name", type: "text", name: "name", required: true, spellcheck: false, placeholder: "e.g. posts", autofocus: () => data.isNew, disabled: () => !data.isNew && data.collection?.system, value: () => data.collection.name || "", onmount: (el) => { el.addEventListener("compositionend", (e) => { data.collection.name = e.target.value; }); }, oninput: (e) => { if (e.isComposing) { return; } data.collection.name = e.target.value; }, }), ), t.div( { className: "field addon" }, t.button( { type: "button", disabled: () => !data.isNew, className: () => `btn sm collection-type-select ${data.isNew ? "outline" : "transparent"}`, "html-popovertarget": uniqueId + "col_type_dropdown", }, t.span( { className: "txt" }, "Type: ", () => app.utils.sentenize(data.collection.type, false) || "N/A", ), t.i({ hidden: () => !data.isNew, ariaHidden: true, className: "ri-arrow-drop-down-line m-l-auto", }), ), t.div( { id: uniqueId + "col_type_dropdown", className: "dropdown nowrap collection-type-dropdown", popover: "auto", }, () => { let options = []; for (const opt of data.collectionTypeOptions) { options.push( t.button( { type: "button", className: () => `dropdown-item ${ opt.value == data.collection.type ? "active" : "" }`, onclick: (e) => { e.target.closest(".dropdown").hidePopover(); data.collection.type = opt.value; }, }, t.i({ ariaHidden: true, className: app.collectionTypes[opt.value]?.icon || app.utils.fallbackCollectionIcon, }), t.span({ className: "txt" }, opt.label || opt.value), ), ); } return options; }, ), ), ), ), t.div( { className: "col-12" }, t.nav( { className: "tabs-header equal-width" }, () => { const tabHeaderItems = []; const tabs = app.collectionTypes[data.collection.type]?.tabs || {}; for (let tabName in tabs) { tabHeaderItems.push( t.button( { type: "button", "html-data-tab": tabName, disabled: () => data.isSaving, className: () => `tab-item ${data.activeTab == tabName ? "active" : ""}`, onclick: () => changeTab(tabName), }, t.span({ className: "txt" }, tabName), () => { if (!data.errorTabs[tabName]) { return; } return t.i({ className: "ri-error-warning-fill txt-danger txt-base", ariaDescription: app.attrs.tooltip("Has errors"), }); }, ), ); } return tabHeaderItems; }, ), ), ), ), t.div( { className: "modal-content" }, () => { const tabContentItems = []; const tabs = app.collectionTypes[data.collection.type]?.tabs || {}; for (let tabName in tabs) { tabContentItems.push( t.div( { "html-data-tab": tabName, hidden: () => data.activeTab != tabName, className: "tab-content-wrapper block", }, () => app.collectionTypes[data.collection.type]?.tabs?.[tabName]?.(data), ), ); } return tabContentItems; }, ), t.footer( { className: "modal-footer" }, t.button( { type: "button", className: "btn transparent m-r-auto", disabled: () => data.isSaving, onclick: () => app.modals.close(modal), }, t.span({ className: "txt" }, "Close"), ), () => { const rawErrors = JSON.stringify(app.store.errors, null, 2); if (rawErrors == "" || rawErrors == "null" || rawErrors == "{}" || rawErrors == "[]") { return; } return t.i({ className: "ri-error-warning-line txt-danger", ariaDescription: app.attrs.tooltip(() => "Raw errors:\n" + rawErrors, "top", "code"), }); }, t.div( { className: "btns" }, t.button( { type: "button", className: () => `btn expanded-lg ${data.isSaving ? "loading" : ""}`, disabled: () => !data.canSave, onclick: () => confirmSave(true), }, t.span({ className: "txt" }, () => (data.isNew ? "Create" : "Save changes")), ), t.button( { type: "button", title: "Save options", className: () => `btn p-5`, disabled: () => !data.canSave, "html-popovertarget": uniqueId + "save_options", }, t.i({ className: "ri-arrow-up-s-line", ariaHidden: true }), ), t.div( { id: uniqueId + "save_options", className: "dropdown nowrap", popover: "auto" }, t.button( { type: "button", className: "dropdown-item", onclick: (e) => { e.target.closest(".dropdown").hidePopover(); confirmSave(false); }, }, t.span({ className: "txt" }, "Save and continue"), t.small({ className: "txt-hint" }, "(Ctrl+S)"), ), t.hr(), t.button( { type: "button", className: "dropdown-item", onclick: (e) => { e.target.closest(".dropdown").hidePopover(); resetForm(); }, }, t.span({ className: "txt" }, "Reset form"), ), ), ), ), ); return modal; } function syncFieldsAndIndexesWithScaffold(collection) { const newScaffold = JSON.parse(JSON.stringify(app.store.collectionScaffolds[collection.type])); // merge fields // ----------------------------------------------------------- const oldFields = JSON.parse(JSON.stringify(collection.fields)) || []; const nonSystemFields = oldFields.filter((f) => !f.system); collection.fields = newScaffold.fields || []; for (const oldField of oldFields) { if (!oldField.system) { continue; } const field = collection.fields.find((f) => f.name == oldField.name); if (!field) { continue; } // merge the default field with the existing one Object.assign(field, oldField); } for (const field of nonSystemFields) { collection.fields.push(field); } // merge indexes // ----------------------------------------------------------- collection.indexes = collection.indexes || []; if (collection.indexes.length) { const scaffoldIndexes = newScaffold?.indexes || []; indexesLoop: for (let i = collection.indexes.length - 1; i >= 0; i--) { const parsed = app.utils.parseIndex(collection.indexes[i]); const parsedName = parsed.indexName.toLowerCase(); // remove old scaffold indexes for (const idx of scaffoldIndexes) { const oldScaffoldName = app.utils.parseIndex(idx).indexName.toLowerCase(); if (parsedName == oldScaffoldName) { collection.indexes.splice(i, 1); continue indexesLoop; } } // remove indexes to nonexisting fields for (const column of parsed.columns) { const hasFieldWithName = !!collection.fields.find( (f) => f.name.toLowerCase() == column.name.toLowerCase(), ); if (!hasFieldWithName) { collection.indexes.splice(i, 1); continue indexesLoop; } } } } // merge new scaffold indexes app.utils.mergeUnique(collection.indexes, newScaffold.indexes); // ensure that all indexes reference the latest colection name collection.indexes = collection.indexes?.map((idx) => { return app.utils.replaceIndexFields(idx, { tableName: collection.name || "" }); }); } function truncateDropdownItem(data, modalSettings) { const uniqueId = "truncate_" + app.utils.randomString(); const local = store({ isSubmitting: false, nameConfirm: "", }); async function truncateCollection() { if ( local.isSubmitting || !data.originalCollection?.name || data.originalCollection.name != local.nameConfirm ) { return false; } local.isSubmitting = true; try { await app.pb.collections.truncate(data.originalCollection.name); modalSettings.ontruncate?.(JSON.parse(JSON.stringify(data.originalCollection))); app.toasts.success(`Successfully truncated collection "${data.originalCollection.name}".`); local.isSubmitting = false; return true; } catch (err) { local.isSubmitting = false; app.checkApiError(err); } return false; } return t.button( { type: "button", className: "dropdown-item txt-danger", disabled: () => local.isSubmitting, onclick: (e) => { e.target.closest(".dropdown").hidePopover(); app.modals.confirm( t.div( null, t.h6( { className: "block txt-center" }, "Do you really want to delete all records of the collection?", ), t.div( { className: "confirm-collection-label txt-bold m-t-sm m-b-sm" }, "Type the collection name ", t.div( { className: "label" }, () => data.originalCollection.name, app.components.copyButton(() => data.originalCollection?.name), ), " to confirm:", ), t.div( { className: "field" }, t.label({ htmlFor: uniqueId + ".confirm_name" }, "Collection name"), t.input({ id: uniqueId + ".confirm_name", type: "text", required: true, pattern: () => RegExp.escape(data.originalCollection.name), value: () => local.nameConfirm, oninput: (e) => local.nameConfirm = e.target.value, }), ), ), async () => { document.getElementById(uniqueId + ".confirm_name")?.reportValidity(); const truncated = await truncateCollection(); if (!truncated) { return false; } app.modals.close(e.target.closest(".modal")); }, () => { local.nameConfirm = ""; }, ); }, }, t.i({ className: "ri-eraser-line", ariaHidden: true }), t.span({ className: "txt" }, "Truncate"), ); } function deleteDropdownItem(data, modalSettings) { const uniqueId = "delete_" + app.utils.randomString(); const local = store({ isSubmitting: false, nameConfirm: "", }); async function deleteCollection() { if ( local.isSubmitting || !data.originalCollection?.name || data.originalCollection.name != local.nameConfirm ) { return false; } local.isSubmitting = true; try { await app.pb.collections.delete(data.originalCollection.name); modalSettings?.ondelete?.(JSON.parse(JSON.stringify(data.originalCollection))); app.utils.removeByKey(app.store.collections, "id", data.originalCollection.id); app.toasts.success(`Successfully deleted collection "${data.originalCollection.name}".`); local.isSubmitting = false; return true; } catch (err) { local.isSubmitting = false; app.checkApiError(err); } return false; } return t.button( { type: "button", className: "dropdown-item txt-danger", disabled: () => local.isSubmitting, onclick: (e) => { e.target.closest(".dropdown").hidePopover(); const collectionModal = e.target.closest(".modal"); app.modals.confirm( t.div( { className: "block" }, t.h6( { className: "block txt-center" }, () => { if (data.originalCollection.type == "view") { return "Do you really want to delete the selected collection?"; } return "Do you really want to delete the selected collection and all its records"; }, ), t.div( { className: "confirm-collection-label txt-bold m-t-sm m-b-sm" }, "Type the collection name ", t.div( { className: "label" }, () => data.originalCollection.name, app.components.copyButton(() => data.originalCollection?.name), ), " to confirm:", ), t.div( { className: "field" }, t.label({ htmlFor: uniqueId + ".confirm_name" }, "Collection name"), t.input({ id: uniqueId + ".confirm_name", type: "text", required: true, pattern: () => RegExp.escape(data.originalCollection.name), value: () => local.nameConfirm, oninput: (e) => local.nameConfirm = e.target.value, }), ), ), async () => { document.getElementById(uniqueId + ".confirm_name")?.reportValidity(); const deleted = await deleteCollection(); if (!deleted) { return false; } app.modals.close(collectionModal); }, () => { local.nameConfirm = ""; }, ); }, }, t.i({ className: "ri-delete-bin-7-line", ariaHidden: true }), t.span({ className: "txt" }, "Delete"), ); }