diff --git a/.gitignore b/.gitignore
index faf7cc95a3..edb129357e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,4 +9,6 @@
*.sw*
.run
node_modules
-ignore
\ No newline at end of file
+ignore
+.kilo*
+.claude*
\ No newline at end of file
diff --git a/client/web/compose/src/components/ModuleFields/Configurator/basic.vue b/client/web/compose/src/components/ModuleFields/Configurator/basic.vue
index 89949ebf15..6ce0d554d3 100644
--- a/client/web/compose/src/components/ModuleFields/Configurator/basic.vue
+++ b/client/web/compose/src/components/ModuleFields/Configurator/basic.vue
@@ -30,6 +30,62 @@
{{ $t('defaultValue') }}
+
+ {{ $t('globalField.makeGlobal') }}
+
+
+
+
+ {{ $t('globalField.selector.label') }}
+
+
+ {{ $t('globalField.linked') }}
+
+
+
+
+
+
+ {{ $t('globalField.selector.placeholder') }}
+
+
+
+
+
+
+
+
+ {{ $t('globalField.selector.load') }}
+
+
+
+
+
0
+ },
+
+ globalFieldOptions () {
+ return this.globalFields.map(gf => ({
+ value: gf.config.globalField.fieldID,
+ text: gf.name || gf.config.globalField.fieldID,
+ }))
+ },
},
watch: {
@@ -391,6 +474,84 @@ export default {
this.initMocks()
}
},
+
+ handleGlobalMasterToggle (value) {
+ if (!this.field.config) {
+ this.$set(this.field, 'config', {})
+ }
+
+ if (value) {
+ this.$set(this.field.config, 'globalField', {
+ namespaceID: this.namespace.namespaceID,
+ fieldID: this.field.fieldID,
+ })
+ } else {
+ this.$delete(this.field.config, 'globalField')
+ }
+ },
+
+ handleGlobalFieldSelect () {
+ const fieldID = this.selectedGlobalField
+ if (!fieldID) {
+ if (this.field.config && this.field.config.globalField) {
+ this.$delete(this.field.config, 'globalField')
+ }
+ return
+ }
+
+ const ns = this.$store.getters['namespace/getByID'](this.namespace.namespaceID)
+ if (!ns) return
+
+ const globalFields = ns.fields || []
+ const selectedField = globalFields.find(gf => gf.config.globalField.fieldID === fieldID)
+
+ if (!selectedField) {
+ return
+ }
+
+ const localHint = this.field.options.hint
+ const localDescription = this.field.options.description
+
+ this.field.isRequired = selectedField.isRequired !== undefined ? selectedField.isRequired : this.field.isRequired
+ this.field.isMulti = selectedField.isMulti !== undefined ? selectedField.isMulti : this.field.isMulti
+ this.field.defaultValue = (selectedField.defaultValue && selectedField.defaultValue.length)
+ ? [...selectedField.defaultValue]
+ : this.field.defaultValue
+ this.field.expressions = selectedField.expressions
+ ? { ...selectedField.expressions }
+ : this.field.expressions
+
+ const globalOptions = selectedField.options || {}
+ this.field.options = {
+ ...globalOptions,
+ hint: localHint,
+ description: localDescription,
+ }
+
+ if (selectedField.config) {
+ this.$set(this.field, 'config', {
+ ...selectedField.config,
+ globalField: {
+ namespaceID: this.namespace.namespaceID,
+ fieldID: selectedField.config.globalField.fieldID,
+ },
+ })
+ } else {
+ if (!this.field.config) {
+ this.$set(this.field, 'config', {})
+ }
+ this.$set(this.field.config, 'globalField', {
+ namespaceID: this.namespace.namespaceID,
+ fieldID: selectedField.fieldID,
+ })
+ }
+
+ if (this.field.defaultValue.length) {
+ this.initMocks(this.field.defaultValue)
+ }
+
+ this.showValueExpr = !!(this.field.expressions && this.field.expressions.value)
+ },
},
}
diff --git a/client/web/compose/src/store/namespace.js b/client/web/compose/src/store/namespace.js
index d95f44d9f0..70a7720920 100644
--- a/client/web/compose/src/store/namespace.js
+++ b/client/web/compose/src/store/namespace.js
@@ -37,6 +37,16 @@ export default function (ComposeAPI) {
set (state) {
return state.set
},
+
+ getGlobalFieldsByKind (state, { getByID }) {
+ return (ID, kind) => {
+ const ns = getByID(ID)
+ if (!ns || !ns.fields) {
+ return []
+ }
+ return ns.fields.filter(f => f.kind === kind)
+ }
+ },
},
actions: {
diff --git a/client/web/compose/src/views/Admin/Modules/Edit.vue b/client/web/compose/src/views/Admin/Modules/Edit.vue
index 0d7f123b8a..ed94d72bf8 100644
--- a/client/web/compose/src/views/Admin/Modules/Edit.vue
+++ b/client/web/compose/src/views/Admin/Modules/Edit.vue
@@ -619,6 +619,7 @@ export default {
...mapGetters({
pages: 'page/set',
previousPage: 'ui/previousPage',
+ namespaces: 'namespace/set',
}),
title () {
@@ -763,6 +764,7 @@ export default {
createModule: 'module/create',
deleteModule: 'module/delete',
deletePage: 'page/delete',
+ updateNamespace: 'namespace/update',
}),
handleNewField () {
@@ -785,6 +787,60 @@ export default {
}
},
+ async updateNamespaceGlobalFields (savedModule, namespaceSource) {
+ const { fields = [] } = savedModule
+ const ns = namespaceSource || this.namespace
+
+ const globalFields = fields.filter(f => {
+ return f.config && f.config.globalField
+ })
+
+ for (const field of globalFields) {
+ const globalFieldConfig = field.config.globalField
+ if (!globalFieldConfig.fieldID) {
+ console.error('Global field missing fieldID:', field.name)
+ throw new Error(this.$t('notification:module.globalField.invalid'))
+ }
+ }
+
+ const existingGlobalFields = ns.fields || []
+
+ const fieldsMap = new Map()
+ existingGlobalFields.forEach(field => {
+ if (field.fieldID !== NoID) {
+ fieldsMap.set(field.fieldID, field)
+ }
+ })
+
+ globalFields.forEach(field => {
+ const globalFieldConfig = field.config.globalField
+ const fieldID = globalFieldConfig.fieldID
+
+ const newGlobalField = {
+ fieldID,
+ name: field.label || field.name,
+ kind: field.kind,
+ options: field.options,
+ isRequired: field.isRequired,
+ isMulti: field.isMulti,
+ defaultValue: field.defaultValue ? [...field.defaultValue] : [],
+ expressions: field.expressions ? { ...field.expressions } : {},
+ config: field.config ? { ...field.config } : {},
+ }
+ fieldsMap.set(fieldID, newGlobalField)
+ })
+
+ const combinedFields = Array.from(fieldsMap.values())
+
+ return this.updateNamespace({
+ ...ns,
+ fields: combinedFields,
+ }).then((updatedNs) => {
+ this.$store.dispatch('namespace/load', { force: true })
+ return updatedNs.fields
+ })
+ },
+
onDiscoverySettingsSave (changes) {
this.module.config = { ...this.module.config, ...changes }
},
@@ -840,6 +896,22 @@ export default {
module = await this.updateModule({ ...module, fields })
}
+ await Promise.all([
+ this.$store.dispatch('namespace/load', {
+ namespaceID: this.namespace.namespaceID,
+ force: true,
+ }),
+ this.findModuleByID({
+ ...module,
+ namespace: this.namespace,
+ force: true,
+ }),
+ ])
+
+ const currentNamespace = this.namespaces.find(n => n.namespaceID === this.namespace.namespaceID)
+
+ await this.updateNamespaceGlobalFields(module, currentNamespace || this.namespace)
+
this.loading = true
this.module = new compose.Module({ ...module }, this.namespace)
@@ -862,7 +934,23 @@ export default {
toggleProcessing(false)
})
} else {
- this.updateModule({ ...module, resourceTranslationLanguage }).then(module => {
+ this.updateModule({ ...module, resourceTranslationLanguage }).then(async module => {
+ await Promise.all([
+ this.$store.dispatch('namespace/load', {
+ namespaceID: this.namespace.namespaceID,
+ force: true,
+ }),
+ this.findModuleByID({
+ ...module,
+ namespace: this.namespace,
+ force: true,
+ }),
+ ])
+
+ const currentNamespace = this.namespaces.find(n => n.namespaceID === this.namespace.namespaceID)
+
+ await this.updateNamespaceGlobalFields(module, currentNamespace || this.namespace)
+
this.module = new compose.Module({ ...module }, this.namespace)
this.initialModuleState = this.module.clone()
diff --git a/client/web/compose/src/views/Admin/Modules/List.vue b/client/web/compose/src/views/Admin/Modules/List.vue
index f6b79411e1..6b7d030e96 100644
--- a/client/web/compose/src/views/Admin/Modules/List.vue
+++ b/client/web/compose/src/views/Admin/Modules/List.vue
@@ -1,4 +1,4 @@
-"
+
stdResolve(result))
}
@@ -228,6 +230,7 @@ export default class Compose {
slug,
enabled,
meta,
+ fields,
labels,
updatedAt,
} = (a as KV) || {}
@@ -252,6 +255,7 @@ export default class Compose {
slug,
enabled,
meta,
+ fields,
labels,
updatedAt,
}
diff --git a/lib/js/src/compose/types/module-field/base.ts b/lib/js/src/compose/types/module-field/base.ts
index 0cfc02bcd3..4865b30632 100644
--- a/lib/js/src/compose/types/module-field/base.ts
+++ b/lib/js/src/compose/types/module-field/base.ts
@@ -31,6 +31,7 @@ export interface Options {
view: string;
edit: string | undefined;
};
+ [key: string]: unknown;
}
export const defaultOptions = (): Readonly => Object.freeze({
@@ -44,6 +45,11 @@ export const defaultOptions = (): Readonly => Object.freeze({
},
})
+export interface GlobalFieldLink {
+ namespaceID: string;
+ fieldID: string;
+}
+
interface Config {
dal: {
encodingStrategy: fieldEncoding;
@@ -57,6 +63,8 @@ interface Config {
recordRevisions: {
enabled: boolean;
};
+
+ globalField?: GlobalFieldLink;
}
export interface Expressions {
diff --git a/lib/js/src/compose/types/namespace.ts b/lib/js/src/compose/types/namespace.ts
index eb925b116f..cc228cf460 100644
--- a/lib/js/src/compose/types/namespace.ts
+++ b/lib/js/src/compose/types/namespace.ts
@@ -20,8 +20,21 @@ interface Meta {
logoEnabled: boolean;
}
+export interface GlobalField {
+ fieldID?: string;
+ kind: string;
+ name: string;
+ options?: Record;
+ isRequired?: boolean;
+ isMulti?: boolean;
+ defaultValue?: Array<{ name?: string; value: string }>;
+ expressions?: Record;
+ config?: Record;
+}
+
interface PartialNamespace extends Partial> {
meta?: Partial;
+ fields?: Array,
createdAt?: string|number|Date;
updatedAt?: string|number|Date;
deletedAt?: string|number|Date;
@@ -38,6 +51,8 @@ export class Namespace {
public meta: object = {}
+ public fields: Array = []
+
public createdAt?: Date = undefined
public updatedAt?: Date = undefined
public deletedAt?: Date = undefined
@@ -78,6 +93,10 @@ export class Namespace {
this.labels = { ...n.labels }
}
+ if (n.fields) {
+ this.fields = n.fields ? n.fields.filter(f => f.kind) : []
+ }
+
Apply(this, n, ISO8601Date, 'createdAt', 'updatedAt', 'deletedAt')
Apply(this, n, Boolean,
'canDeleteNamespace',
diff --git a/locale/en/corteza-webapp-compose/field.yaml b/locale/en/corteza-webapp-compose/field.yaml
index fbb4ed4b50..d6bf64784c 100644
--- a/locale/en/corteza-webapp-compose/field.yaml
+++ b/locale/en/corteza-webapp-compose/field.yaml
@@ -292,4 +292,12 @@ constraints:
totalFieldConstraintCount: The field is used in {{total}} other unique value constraint. See "unique values" tab on module editor.
tooltip:
performance: Using unique value constraints will impact performance
+globalField:
+ selector:
+ label: Connect global field
+ placeholder: Select global field
+ load: Link field
+ makeGlobal: Global Field
+ conflictError: Global field name conflict
+ linked: Linked
diff --git a/server/codegen/tool/templating.go b/server/codegen/tool/templating.go
index 59fc81f1de..5b56c51f74 100644
--- a/server/codegen/tool/templating.go
+++ b/server/codegen/tool/templating.go
@@ -38,6 +38,7 @@ func loadTemplates(rTpl *template.Template, rootDir string) (*template.Template,
}
name := path[pfx:]
+ name = strings.ReplaceAll(name, "\\", "/")
rTpl, err = rTpl.New(name).Parse(string(b))
return err
diff --git a/server/compose/envoy/yaml_decode.gen.go b/server/compose/envoy/yaml_decode.gen.go
index c368e1d186..8e01ce52ea 100644
--- a/server/compose/envoy/yaml_decode.gen.go
+++ b/server/compose/envoy/yaml_decode.gen.go
@@ -1306,6 +1306,25 @@ func (d *auxYamlDoc) unmarshalNamespaceNode(dctx documentContext, n *yaml.Node,
switch strings.ToLower(k.Value) {
+ case "fields":
+
+ // Handle custom node decoder
+ //
+ // The decoder may update the passed resource with arbitrary values
+ // as well as provide additional references and identifiers for the node.
+ var (
+ auxRefs map[string]envoyx.Ref
+ auxIdents envoyx.Identifiers
+ )
+ auxRefs, auxIdents, err = d.unmarshalNamespaceFieldsNode(r, n)
+ if err != nil {
+ return err
+ }
+ refs = envoyx.MergeRefs(refs, auxRefs)
+ ii = ii.Merge(auxIdents)
+
+ break
+
case "id":
// Handle identifiers
err = y7s.DecodeScalar(n, "id", &auxNodeValue)
@@ -2405,14 +2424,13 @@ func unmarshalRBACNode(n *yaml.Node, acc rbac.Access) (out envoyx.NodeSet, err e
// Example:
//
// modules:
-//
-// module1:
-// name: "module 1"
-// fields: ...
-// allow:
-// role1:
-// - read
-// - delete
+// module1:
+// name: "module 1"
+// fields: ...
+// allow:
+// role1:
+// - read
+// - delete
func unmarshalNestedRBACNode(n *yaml.Node, acc rbac.Access) (out envoyx.NodeSet, err error) {
return out, y7s.EachMap(n, func(role, op *yaml.Node) error {
out = append(out, &envoyx.Node{
@@ -2438,11 +2456,10 @@ func unmarshalNestedRBACNode(n *yaml.Node, acc rbac.Access) (out envoyx.NodeSet,
// Example:
//
// allow:
-//
-// role1:
-// corteza::system/:
-// - users.search
-// - users.create
+// role1:
+// corteza::system/:
+// - users.search
+// - users.create
func unmarshalFlatRBACNode(n *yaml.Node, acc rbac.Access) (out envoyx.NodeSet, err error) {
// Handles role
return out, y7s.EachMap(n, func(role, perm *yaml.Node) error {
diff --git a/server/compose/envoy/yaml_decode.go b/server/compose/envoy/yaml_decode.go
index 1135ad5160..dec4cbdf9b 100644
--- a/server/compose/envoy/yaml_decode.go
+++ b/server/compose/envoy/yaml_decode.go
@@ -362,6 +362,51 @@ func (d *auxYamlDoc) unmarshalModuleFieldExpressionsNode(r *types.ModuleField, n
return
}
+func (d *auxYamlDoc) unmarshalNamespaceFieldsNode(r *types.Namespace, n *yaml.Node) (refs map[string]envoyx.Ref, idents envoyx.Identifiers, err error) {
+ refs = map[string]envoyx.Ref{}
+ r.Fields = types.GlobalFields{}
+
+ err = y7s.EachSeq(n, func(fieldNode *yaml.Node) error {
+ var gf types.GlobalField
+ err = y7s.EachMap(fieldNode, func(k, v *yaml.Node) error {
+ switch strings.ToLower(k.Value) {
+ case "name":
+ gf.Name = v.Value
+ case "kind":
+ gf.Kind = v.Value
+ case "isrequired", "is_required":
+ var b bool
+ if err = v.Decode(&b); err != nil {
+ b = v.Value == "true"
+ err = nil
+ }
+ gf.Required = b
+ case "ismulti", "is_multi":
+ var b bool
+ if err = v.Decode(&b); err != nil {
+ b = v.Value == "true"
+ err = nil
+ }
+ gf.Multi = b
+ case "options":
+ return v.Decode(&gf.Options)
+ case "expressions":
+ return v.Decode(&gf.Expressions)
+ case "config":
+ return v.Decode(&gf.Config)
+ case "defaultvalue", "default_value":
+ return v.Decode(&gf.DefaultValue)
+ }
+ return nil
+ })
+ if gf.Kind != "" {
+ r.Fields = append(r.Fields, gf)
+ }
+ return err
+ })
+ return
+}
+
func (d *auxYamlDoc) postProcessNestedModuleNodes(nn envoyx.NodeSet) (out envoyx.NodeSet, err error) {
// Get all references from all module fields
refs := make(map[string]envoyx.Ref)
diff --git a/server/compose/envoy/yaml_encode.gen.go b/server/compose/envoy/yaml_encode.gen.go
index 701997a9d3..f443a6305e 100644
--- a/server/compose/envoy/yaml_encode.gen.go
+++ b/server/compose/envoy/yaml_encode.gen.go
@@ -407,6 +407,11 @@ func (e YamlEncoder) encodeNamespace(ctx context.Context, p envoyx.EncodeParams,
return
}
+ auxFields, err := e.encodeNamespaceFieldsC(ctx, p, tt, node, res, res.Fields)
+ if err != nil {
+ return
+ }
+
auxUpdatedAt, err := e.encodeTimestampNil(p, res.UpdatedAt)
if err != nil {
return
@@ -416,6 +421,7 @@ func (e YamlEncoder) encodeNamespace(ctx context.Context, p envoyx.EncodeParams,
"createdAt", auxCreatedAt,
"deletedAt", auxDeletedAt,
"enabled", res.Enabled,
+ "fields", auxFields,
"id", res.ID,
"meta", res.Meta,
"name", res.Name,
diff --git a/server/compose/envoy/yaml_encode.go b/server/compose/envoy/yaml_encode.go
index 6a5dc8420d..483e598357 100644
--- a/server/compose/envoy/yaml_encode.go
+++ b/server/compose/envoy/yaml_encode.go
@@ -88,6 +88,34 @@ func (e YamlEncoder) encodeModuleFieldOptionsC(ctx context.Context, p envoyx.Enc
return nopt, nil
}
+func (e YamlEncoder) encodeNamespaceFieldsC(ctx context.Context, p envoyx.EncodeParams, tt envoyx.Traverser, n *envoyx.Node, ns *types.Namespace, gf types.GlobalFields) (_ any, err error) {
+ out, _ := y7s.MakeSeq()
+
+ for _, gF := range ns.Fields {
+ var node *yaml.Node
+ node, err = y7s.MakeMap(
+ "name", gF.Name,
+ "kind", gF.Kind,
+ "options", gF.Options,
+ "isRequired", gF.Required,
+ "isMulti", gF.Multi,
+ "defaultValue", gF.DefaultValue,
+ "expressions", gF.Expressions,
+ "config", gF.Config,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ out, err = y7s.AddSeq(out, node)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return out, nil
+}
+
func (e YamlEncoder) encodePageBlocksC(ctx context.Context, p envoyx.EncodeParams, tt envoyx.Traverser, n *envoyx.Node, pg *types.Page, bb types.PageBlocks) (_ any, err error) {
out, _ := y7s.MakeSeq()
diff --git a/server/compose/model/models.gen.go b/server/compose/model/models.gen.go
index 13f1992f59..eafe35c878 100644
--- a/server/compose/model/models.gen.go
+++ b/server/compose/model/models.gen.go
@@ -522,6 +522,14 @@ var Namespace = &dal.Model{
Store: &dal.CodecAlias{Ident: "meta"},
},
+ &dal.Attribute{
+ Ident: "Fields",
+ Type: &dal.TypeJSON{
+ DefaultValue: "{}",
+ },
+ Store: &dal.CodecAlias{Ident: "fields"},
+ },
+
&dal.Attribute{
Ident: "Name", Sortable: true,
Type: &dal.TypeText{},
diff --git a/server/compose/namespace.cue b/server/compose/namespace.cue
index dcfba7d91f..9993fa9b1b 100644
--- a/server/compose/namespace.cue
+++ b/server/compose/namespace.cue
@@ -27,6 +27,18 @@ namespace: {
omitSetter: true
omitGetter: true
}
+ fields: {
+ goType: "types.GlobalFields"
+ dal: { type: "JSON", defaultEmptyObject: true }
+ omitSetter: true
+ omitGetter: true
+ envoy: {
+ yaml: {
+ customDecoder: true
+ customEncoder: true
+ }
+ }
+ }
name: {
sortable: true
dal: {}
diff --git a/server/compose/rest.yaml b/server/compose/rest.yaml
index 271805e7bc..1c271c5227 100644
--- a/server/compose/rest.yaml
+++ b/server/compose/rest.yaml
@@ -73,6 +73,10 @@ endpoints:
name: meta
required: true
title: Meta data
+ - type: sqlxTypes.JSONText
+ name: fields
+ required: false
+ title: Fields
- name: read
method: GET
title: Read namespace
@@ -110,6 +114,10 @@ endpoints:
name: meta
required: true
title: Meta data
+ - type: sqlxTypes.JSONText
+ name: fields
+ required: false
+ title: Fields
- type: map[string]labelTypes.LabelValue
name: labels
title: Labels
diff --git a/server/compose/rest/namespace.go b/server/compose/rest/namespace.go
index c399fc5f6a..c237fe1089 100644
--- a/server/compose/rest/namespace.go
+++ b/server/compose/rest/namespace.go
@@ -140,6 +140,12 @@ func (ctrl Namespace) Create(ctx context.Context, r *request.NamespaceCreate) (i
return nil, err
}
+ if len(r.Fields) > 2 {
+ if err = r.Fields.Unmarshal(&ns.Fields); err != nil {
+ return nil, err
+ }
+ }
+
ns, err = ctrl.namespace.Create(ctx, ns)
return ctrl.makePayload(ctx, ns, err)
}
@@ -174,6 +180,12 @@ func (ctrl Namespace) Update(ctx context.Context, r *request.NamespaceUpdate) (i
return nil, err
}
+ if len(r.Fields) > 2 {
+ if err = r.Fields.Unmarshal(&ns.Fields); err != nil {
+ return nil, err
+ }
+ }
+
ns, err = ctrl.namespace.Update(ctx, ns)
return ctrl.makePayload(ctx, ns, err)
}
diff --git a/server/compose/rest/request/namespace.go b/server/compose/rest/request/namespace.go
index ec7d030ca0..87800fba3a 100644
--- a/server/compose/rest/request/namespace.go
+++ b/server/compose/rest/request/namespace.go
@@ -11,17 +11,18 @@ package request
import (
"encoding/json"
"fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "strings"
+ "time"
+
"github.com/cortezaproject/corteza/server/pkg/label"
labelTypes "github.com/cortezaproject/corteza/server/pkg/label/types"
"github.com/cortezaproject/corteza/server/pkg/locale"
"github.com/cortezaproject/corteza/server/pkg/payload"
"github.com/go-chi/chi/v5"
sqlxTypes "github.com/jmoiron/sqlx/types"
- "io"
- "mime/multipart"
- "net/http"
- "strings"
- "time"
)
// dummy vars to prevent
@@ -100,6 +101,11 @@ type (
//
// Meta data
Meta sqlxTypes.JSONText
+
+ // Fields POST parameter
+ //
+ // Fields
+ Fields sqlxTypes.JSONText
}
NamespaceRead struct {
@@ -135,6 +141,11 @@ type (
// Meta data
Meta sqlxTypes.JSONText
+ // Fields POST parameter
+ //
+ // Fields
+ Fields sqlxTypes.JSONText
+
// Labels POST parameter
//
// Labels
@@ -380,6 +391,7 @@ func (r NamespaceCreate) Auditable() map[string]interface{} {
"slug": r.Slug,
"enabled": r.Enabled,
"meta": r.Meta,
+ "fields": r.Fields,
}
}
@@ -408,6 +420,11 @@ func (r NamespaceCreate) GetMeta() sqlxTypes.JSONText {
return r.Meta
}
+// Auditable returns all auditable/loggable parameters
+func (r NamespaceCreate) GetFields() sqlxTypes.JSONText {
+ return r.Fields
+}
+
// Fill processes request and fills internal variables
func (r *NamespaceCreate) Fill(req *http.Request) (err error) {
@@ -468,6 +485,13 @@ func (r *NamespaceCreate) Fill(req *http.Request) (err error) {
return err
}
}
+
+ if val, ok := req.MultipartForm.Value["fields"]; ok && len(val) > 0 {
+ r.Fields, err = payload.ParseJSONTextWithErr(val[0])
+ if err != nil {
+ return err
+ }
+ }
}
}
@@ -517,6 +541,13 @@ func (r *NamespaceCreate) Fill(req *http.Request) (err error) {
return err
}
}
+
+ if val, ok := req.Form["fields"]; ok && len(val) > 0 {
+ r.Fields, err = payload.ParseJSONTextWithErr(val[0])
+ if err != nil {
+ return err
+ }
+ }
}
return err
@@ -570,6 +601,7 @@ func (r NamespaceUpdate) Auditable() map[string]interface{} {
"slug": r.Slug,
"enabled": r.Enabled,
"meta": r.Meta,
+ "fields": r.Fields,
"labels": r.Labels,
"updatedAt": r.UpdatedAt,
}
@@ -605,6 +637,11 @@ func (r NamespaceUpdate) GetLabels() map[string]labelTypes.LabelValue {
return r.Labels
}
+// Auditable returns all auditable/loggable parameters
+func (r NamespaceUpdate) GetFields() sqlxTypes.JSONText {
+ return r.Fields
+}
+
// Auditable returns all auditable/loggable parameters
func (r NamespaceUpdate) GetUpdatedAt() *time.Time {
return r.UpdatedAt
@@ -659,6 +696,13 @@ func (r *NamespaceUpdate) Fill(req *http.Request) (err error) {
}
}
+ if val, ok := req.MultipartForm.Value["fields"]; ok && len(val) > 0 {
+ r.Fields, err = payload.ParseJSONTextWithErr(val[0])
+ if err != nil {
+ return err
+ }
+ }
+
if val, ok := req.MultipartForm.Value["labels[]"]; ok {
r.Labels, err = label.ParseStrings(val)
if err != nil {
@@ -727,6 +771,13 @@ func (r *NamespaceUpdate) Fill(req *http.Request) (err error) {
}
}
+ if val, ok := req.Form["fields"]; ok && len(val) > 0 {
+ r.Fields, err = payload.ParseJSONTextWithErr(val[0])
+ if err != nil {
+ return err
+ }
+ }
+
if val, ok := req.Form["updatedAt"]; ok && len(val) > 0 {
r.UpdatedAt, err = payload.ParseISODatePtrWithErr(val[0])
if err != nil {
diff --git a/server/compose/service/module.go b/server/compose/service/module.go
index da8713d8f0..7b8a00ac20 100644
--- a/server/compose/service/module.go
+++ b/server/compose/service/module.go
@@ -368,6 +368,10 @@ func (svc module) Create(ctx context.Context, new *types.Module) (*types.Module,
f.UpdatedAt = nil
f.DeletedAt = nil
+ if f.Config.GlobalField != nil && f.Config.GlobalField.FieldID == 0 {
+ f.Config.GlobalField.FieldID = f.ID
+ }
+
// Assure validatorID
for i, v := range f.Expressions.Validators {
v.ValidatorID = uint64(i) + 1
@@ -771,6 +775,12 @@ func (svc module) handleUpdate(ctx context.Context, upd *types.Module) moduleUpd
if (len(upd.Fields) > 0 || len(res.Fields) > 0) && !reflect.DeepEqual(res.Fields, upd.Fields) {
changes |= moduleFieldsChanged
res.Fields = upd.Fields
+
+ for _, f := range res.Fields {
+ if f.Config.GlobalField != nil && f.Config.GlobalField.FieldID == 0 {
+ f.Config.GlobalField.FieldID = f.ID
+ }
+ }
}
// Assure validatorIDs
diff --git a/server/compose/service/namespace.go b/server/compose/service/namespace.go
index 38a8476af3..f727fb7407 100644
--- a/server/compose/service/namespace.go
+++ b/server/compose/service/namespace.go
@@ -653,6 +653,112 @@ func (svc namespace) handleUpdate(ctx context.Context, upd *types.Namespace) nam
res.Meta = upd.Meta
}
+ fieldID := uint64(0)
+ for _, f := range res.Fields {
+ if f.FieldID > fieldID {
+ fieldID = f.FieldID
+ }
+ }
+
+ for i := range upd.Fields {
+ if upd.Fields[i].FieldID == 0 {
+ fieldID++
+ upd.Fields[i].FieldID = fieldID
+ changes |= namespaceChanged
+ }
+ }
+
+ if !reflect.DeepEqual(res.Fields, upd.Fields) {
+ updatedGlobalByID := make(map[uint64]*types.GlobalField, len(upd.Fields))
+ for i := range upd.Fields {
+ if upd.Fields[i].Config.GlobalField != nil {
+ if upd.Fields[i].Config.GlobalField.FieldID == 0 {
+ return namespaceUnchanged, NamespaceErrInvalidID()
+ }
+ }
+
+ if upd.Fields[i].FieldID != 0 {
+ updatedGlobalByID[upd.Fields[i].FieldID] = &upd.Fields[i]
+ }
+ }
+
+ if len(updatedGlobalByID) > 0 {
+ mm, _, merr := store.SearchComposeModules(ctx, svc.store, types.ModuleFilter{NamespaceID: res.ID})
+ if merr != nil {
+ return namespaceUnchanged, merr
+ }
+
+ moduleIDs := make([]uint64, len(mm))
+ for i, m := range mm {
+ moduleIDs[i] = m.ID
+ }
+
+ fieldSet, _, ferr := store.SearchComposeModuleFields(ctx, svc.store, types.ModuleFieldFilter{ModuleID: moduleIDs})
+ if ferr != nil {
+ return namespaceUnchanged, ferr
+ }
+
+ fieldsByModule := make(map[uint64]types.ModuleFieldSet)
+ for _, f := range fieldSet {
+ fieldsByModule[f.ModuleID] = append(fieldsByModule[f.ModuleID], f)
+ }
+
+ for _, m := range mm {
+ moduleFields := fieldsByModule[m.ID]
+ moduleChanged := false
+ for _, f := range moduleFields {
+ gf := f.Config.GlobalField
+ if gf == nil || gf.FieldID == 0 {
+ continue
+ }
+
+ updatedGF, linked := updatedGlobalByID[gf.FieldID]
+ if !linked {
+ continue
+ }
+
+ localHint, hasHint := f.Options["hint"]
+ localDesc, hasDesc := f.Options["description"]
+ localGlobalField := f.Config.GlobalField
+ f.Required = updatedGF.Required
+ f.Multi = updatedGF.Multi
+ f.DefaultValue = updatedGF.DefaultValue
+ f.Expressions = updatedGF.Expressions
+
+ newOptions := make(types.ModuleFieldOptions)
+ for k, v := range updatedGF.Options {
+ newOptions[k] = v
+ }
+ if hasHint {
+ newOptions["hint"] = localHint
+ }
+ if hasDesc {
+ newOptions["description"] = localDesc
+ }
+ f.Options = newOptions
+
+ f.Config = updatedGF.Config
+ f.Config.GlobalField = localGlobalField
+
+ if uferr := store.UpdateComposeModuleField(ctx, svc.store, f); uferr != nil {
+ return namespaceUnchanged, uferr
+ }
+
+ moduleChanged = true
+ }
+
+ if moduleChanged {
+ if serr := store.UpdateComposeModule(ctx, svc.store, m); serr != nil {
+ return namespaceUnchanged, serr
+ }
+ }
+ }
+ }
+
+ res.Fields = upd.Fields
+ changes |= namespaceChanged
+ }
+
if upd.Labels != nil {
if label.Changed(res.Labels, upd.Labels) {
changes |= namespaceLabelsChanged
diff --git a/server/compose/types/module_field.go b/server/compose/types/module_field.go
index 675f9f14ca..a3c7c52e10 100644
--- a/server/compose/types/module_field.go
+++ b/server/compose/types/module_field.go
@@ -11,9 +11,9 @@ import (
"github.com/cortezaproject/corteza/server/pkg/sql"
"github.com/cortezaproject/corteza/server/pkg/filter"
+ labelTypes "github.com/cortezaproject/corteza/server/pkg/label/types"
"github.com/cortezaproject/corteza/server/pkg/locale"
"github.com/spf13/cast"
- labelTypes "github.com/cortezaproject/corteza/server/pkg/label/types"
)
type (
@@ -57,6 +57,18 @@ type (
Privacy ModuleFieldConfigDataPrivacy `json:"privacy"`
RecordRevisions ModuleFieldConfigRecordRevisions `json:"recordRevisions"`
+
+ // GlobalField links this field to a namespace-level global field template.
+ // When FieldID is 0 (NoID), this field IS the global master and will be
+ // persisted to the namespace fields array on module save.
+ // When FieldID is non-zero, this field references (mirrors) that global field.
+ GlobalField *ModuleFieldConfigGlobalField `json:"globalField,omitempty"`
+ }
+
+ // ModuleFieldConfigGlobalField links a module field to a namespace-level global field.
+ ModuleFieldConfigGlobalField struct {
+ NamespaceID uint64 `json:"namespaceID,string,omitempty"`
+ FieldID uint64 `json:"fieldID,string,omitempty"`
}
// ModuleFieldConfigDAL holds DAL configuration for a specific field
diff --git a/server/compose/types/module_field_options.go b/server/compose/types/module_field_options.go
index c1ee1d532c..a3309d9d95 100644
--- a/server/compose/types/module_field_options.go
+++ b/server/compose/types/module_field_options.go
@@ -181,3 +181,16 @@ func (opt ModuleFieldOptions) Index() *ModuleFieldOptionIndex {
return nil
}
+
+// GlobalField returns global field configuration from options
+func (opt ModuleFieldOptions) GlobalField() interface{} {
+ if val, has := opt["globalField"]; has {
+ return val
+ }
+ return nil
+}
+
+// SetGlobalField sets global field configuration in options
+func (opt ModuleFieldOptions) SetGlobalField(value interface{}) {
+ opt["globalField"] = value
+}
diff --git a/server/compose/types/module_field_options_test.go b/server/compose/types/module_field_options_test.go
index c42d31833d..b759fe7ea4 100644
--- a/server/compose/types/module_field_options_test.go
+++ b/server/compose/types/module_field_options_test.go
@@ -30,3 +30,22 @@ func TestModuleFieldOptions_Int64Def(t *testing.T) {
})
}
}
+
+func TestModuleFieldOptions_GlobalField(t *testing.T) {
+ tests := []struct {
+ name string
+ opt ModuleFieldOptions
+ want interface{}
+ }{
+ {"existing", ModuleFieldOptions{"globalField": map[string]string{"handle": "test_field", "name": "Test Field"}}, map[string]string{"handle": "test_field", "name": "Test Field"}},
+ {"nil", ModuleFieldOptions{"globalField": nil}, nil},
+ {"unexisting", ModuleFieldOptions{}, nil},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := tt.opt.GlobalField(); got != tt.want {
+ t.Errorf("GlobalField() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/server/compose/types/namespace.go b/server/compose/types/namespace.go
index ea181286eb..f195440012 100644
--- a/server/compose/types/namespace.go
+++ b/server/compose/types/namespace.go
@@ -6,8 +6,8 @@ import (
"time"
"github.com/cortezaproject/corteza/server/pkg/filter"
- "github.com/cortezaproject/corteza/server/pkg/sql"
labelTypes "github.com/cortezaproject/corteza/server/pkg/label/types"
+ "github.com/cortezaproject/corteza/server/pkg/sql"
)
type (
@@ -16,6 +16,7 @@ type (
Slug string `json:"slug"`
Enabled bool `json:"enabled"`
Meta NamespaceMeta `json:"meta"`
+ Fields GlobalFields `json:"fields"`
Labels map[string]labelTypes.LabelValue `json:"labels,omitempty"`
@@ -29,6 +30,29 @@ type (
Name string `json:"name"`
}
+ GlobalFields []GlobalField
+
+ GlobalField struct {
+ FieldID uint64 `json:"fieldID,string,omitempty"`
+
+ // Kind is the field type (e.g., "String", "Number", "Select", etc.)
+ Kind string `json:"kind"`
+
+ // Name is the display name for the global field
+ Name string `json:"name"`
+
+ // Options contains the field-type-specific configuration
+ // (excludes hint and description which are local per-field)
+ Options map[string]interface{} `json:"options,omitempty"`
+
+ // Complete field config
+ Required bool `json:"isRequired"`
+ Multi bool `json:"isMulti"`
+ DefaultValue RecordValueSet `json:"defaultValue"`
+ Expressions ModuleFieldExpr `json:"expressions"`
+ Config ModuleFieldConfig `json:"config"`
+ }
+
NamespaceFilter struct {
NamespaceID []string `json:"namespaceID"`
@@ -36,7 +60,7 @@ type (
Slug string `json:"slug"`
Name string `json:"name"`
- LabeledIDs []uint64 `json:"-"`
+ LabeledIDs []uint64 `json:"-"`
Labels map[string]labelTypes.LabelValue `json:"labels,omitempty"`
Deleted filter.State `json:"deleted"`
@@ -92,3 +116,11 @@ func (set NamespaceSet) FindByHandle(handle string) *Namespace {
func (nm *NamespaceMeta) Scan(src any) error { return sql.ParseJSON(src, nm) }
func (nm NamespaceMeta) Value() (driver.Value, error) { return json.Marshal(nm) }
+
+func (gf *GlobalFields) Scan(src any) error { return sql.ParseJSON(src, gf) }
+func (gf GlobalFields) Value() (driver.Value, error) {
+ if gf == nil {
+ return "[]", nil
+ }
+ return json.Marshal(gf)
+}
diff --git a/server/store/adapters/rdbms/aux_types.gen.go b/server/store/adapters/rdbms/aux_types.gen.go
index 96a72d66df..2ba5a2e840 100644
--- a/server/store/adapters/rdbms/aux_types.gen.go
+++ b/server/store/adapters/rdbms/aux_types.gen.go
@@ -279,6 +279,7 @@ type (
Slug string `db:"slug"`
Enabled bool `db:"enabled"`
Meta composeType.NamespaceMeta `db:"meta"`
+ Fields composeType.GlobalFields `db:"fields"`
Name string `db:"name"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt *time.Time `db:"updated_at"`
@@ -1619,6 +1620,7 @@ func (aux *auxComposeNamespace) encode(res *composeType.Namespace) (_ error) {
aux.Slug = res.Slug
aux.Enabled = res.Enabled
aux.Meta = res.Meta
+ aux.Fields = res.Fields
aux.Name = res.Name
aux.CreatedAt = res.CreatedAt
aux.UpdatedAt = res.UpdatedAt
@@ -1635,6 +1637,7 @@ func (aux auxComposeNamespace) decode() (res *composeType.Namespace, _ error) {
res.Slug = aux.Slug
res.Enabled = aux.Enabled
res.Meta = aux.Meta
+ res.Fields = aux.Fields
res.Name = aux.Name
res.CreatedAt = aux.CreatedAt
res.UpdatedAt = aux.UpdatedAt
@@ -1651,6 +1654,7 @@ func (aux *auxComposeNamespace) scan(row scanner) error {
&aux.Slug,
&aux.Enabled,
&aux.Meta,
+ &aux.Fields,
&aux.Name,
&aux.CreatedAt,
&aux.UpdatedAt,
diff --git a/server/store/adapters/rdbms/queries.gen.go b/server/store/adapters/rdbms/queries.gen.go
index 78e231e6d0..6e79e4a107 100644
--- a/server/store/adapters/rdbms/queries.gen.go
+++ b/server/store/adapters/rdbms/queries.gen.go
@@ -1869,6 +1869,7 @@ var (
"slug",
"enabled",
"meta",
+ "fields",
"name",
"created_at",
"updated_at",
@@ -1886,6 +1887,7 @@ var (
"slug": res.Slug,
"enabled": res.Enabled,
"meta": res.Meta,
+ "fields": res.Fields,
"name": res.Name,
"created_at": res.CreatedAt,
"updated_at": res.UpdatedAt,
@@ -1906,6 +1908,7 @@ var (
"slug": res.Slug,
"enabled": res.Enabled,
"meta": res.Meta,
+ "fields": res.Fields,
"name": res.Name,
"created_at": res.CreatedAt,
"updated_at": res.UpdatedAt,
@@ -1924,6 +1927,7 @@ var (
"slug": res.Slug,
"enabled": res.Enabled,
"meta": res.Meta,
+ "fields": res.Fields,
"name": res.Name,
"created_at": res.CreatedAt,
"updated_at": res.UpdatedAt,
diff --git a/server/store/adapters/rdbms/upgrade_fixes.go b/server/store/adapters/rdbms/upgrade_fixes.go
index 03e209306e..5b89e619b4 100644
--- a/server/store/adapters/rdbms/upgrade_fixes.go
+++ b/server/store/adapters/rdbms/upgrade_fixes.go
@@ -55,6 +55,7 @@ var (
fix_2024_09_03_renameFederationNodeSyncComposeID,
fix_2024_09_03_addFederationNodeSyncNodeIDIndex,
fix_2024_9_7_migrateLabelsValueToJsonb,
+ fix_2024_09_07_addNamespaceGlobalFields,
}
fixesPost = []func(context.Context, *Store) error{
@@ -1312,6 +1313,14 @@ func fix_2024_9_7_migrateLabelsValueToJsonb(ctx context.Context, s *Store) (err
return nil
}
+
+func fix_2024_09_07_addNamespaceGlobalFields(ctx context.Context, s *Store) (err error) {
+ return addColumn(ctx, s,
+ "compose_namespace",
+ &dal.Attribute{Ident: "fields", Type: &dal.TypeJSON{HasDefault: true, DefaultValue: "[]"}},
+ )
+}
+
func count(ctx context.Context, s *Store, table string, ee ...goqu.Expression) (count int) {
db := s.DB.(goqu.SQLDatabase)