diff --git a/internal/module/evolution/schema/foreignkey.go b/internal/module/evolution/schema/foreignkey.go index 9226b28fc..251f55fb8 100644 --- a/internal/module/evolution/schema/foreignkey.go +++ b/internal/module/evolution/schema/foreignkey.go @@ -98,8 +98,11 @@ func (m *foreignKeyMigrator) getManyToOneKeys() ([]ForeignKeyInfo, error) { continue } - // Treat fields with a select clause as virtual and skip FK creation. - if _, hasSelect := opts["select"]; hasSelect { + skip, skipErr := shouldSkipManyToOneForeignKey(field, opts) + if skipErr != nil { + return nil, skipErr + } + if skip { continue } @@ -139,6 +142,51 @@ func (m *foreignKeyMigrator) getManyToOneKeys() ([]ForeignKeyInfo, error) { return foreignKeys, nil } +// shouldSkipManyToOneForeignKey skips virtual / non-column ManyToOne fields. +// Prefer ResolvedSpec.Migration.ShouldCreateColumn (COMPUTE_STORE_FALSE, related, +// sqlCompute, …); also skip @Field({ select }) and bare @Compute({ store: false }) +// when ResolvedSpec is absent. +func shouldSkipManyToOneForeignKey(field *meta.Field, opts map[string]interface{}) (bool, error) { + if _, hasSelect := opts["select"]; hasSelect { + return true, nil + } + if field == nil { + return false, nil + } + resolved, err := field.GetResolvedSpec() + if err != nil { + return false, fmt.Errorf("error unmarshal field resolved spec for %s: %w", field.Name, err) + } + if resolved != nil { + return !resolved.Migration.ShouldCreateColumn, nil + } + return fieldHasNonStoredComputeDecorator(field), nil +} + +func fieldHasNonStoredComputeDecorator(field *meta.Field) bool { + if field == nil { + return false + } + for _, decorator := range field.Decorators { + if decorator == nil || decorator.Name != "Compute" { + continue + } + for _, arg := range decorator.Arguments { + if arg == nil || arg.Type != "ObjectLiteral" || arg.Value == "" { + continue + } + var computeOpts map[string]interface{} + if err := json.Unmarshal([]byte(arg.Value), &computeOpts); err != nil { + continue + } + if store, ok := computeOpts["store"].(bool); ok && !store { + return true + } + } + } + return false +} + func (m *foreignKeyMigrator) resolveTargetModelByPath(targetModelPath string) (*meta.Model, error) { for _, model := range m.models { if model != nil && model.Path == targetModelPath { @@ -235,7 +283,14 @@ func (m *foreignKeyMigrator) createForeignKeys(foreignKeys []ForeignKeyInfo) err // Create the new foreign key. createSQL := builder.BuildForeignKeySQL(fk) if err := db.Exec(createSQL).Error; err != nil { - return fmt.Errorf("create foreign key failed: %v", err) + return fmt.Errorf( + "create foreign key failed: table=%s column=%s refer=%s.%s: %w", + fk.TableName, + fk.ColumnName, + fk.ReferTableName, + fk.ReferColumnName, + err, + ) } } diff --git a/internal/module/evolution/schema/foreignkey_test.go b/internal/module/evolution/schema/foreignkey_test.go index bc245ec7f..76fb50cc1 100644 --- a/internal/module/evolution/schema/foreignkey_test.go +++ b/internal/module/evolution/schema/foreignkey_test.go @@ -250,11 +250,285 @@ func TestForeignKeyMigratorRuntimePaths(t *testing.T) { fkMigrator := newForeignKeyMigrator(runtimeScope, nil, nil).(*foreignKeyMigrator) fks := []ForeignKeyInfo{{TableName: "sales_order", ColumnName: "owner_id", ReferTableName: "sales_user", ReferColumnName: "id", OnDelete: "CASCADE"}} - if err := fkMigrator.createForeignKeys(fks); err == nil || !strings.Contains(err.Error(), "create foreign key failed") { + err := fkMigrator.createForeignKeys(fks) + if err == nil || !strings.Contains(err.Error(), "create foreign key failed") { t.Fatalf("expected create foreign key error, got %v", err) } + for _, want := range []string{ + "table=sales_order", + "column=owner_id", + "refer=sales_user.id", + } { + if !strings.Contains(err.Error(), want) { + t.Fatalf("expected %q in error, got %v", want, err) + } + } if err := fkMigrator.MigrateForeignKeys(); err != nil { t.Fatalf("MigrateForeignKeys() with no keys error = %v", err) } }) + + t.Run("skips non-stored compute many-to-one via ResolvedSpec", func(t *testing.T) { + runtimeScope := newSchemaTestScope(t) + target := &meta.Model{Name: "Line", Path: "sandbox/line.ts", ModelTable: "sandbox_line"} + stored := newRelationField("OwnerId", "sandbox/line", `{"type":"ManyToOne"}`) + virtual := newRelationField("PrimaryLineId", "sandbox/line", `{"type":"ManyToOne","index":true,"readonly":true}`) + if err := virtual.SetResolvedSpec(&meta.FieldResolvedSpec{ + FieldName: "PrimaryLineId", + Structural: meta.FieldStructuralSpec{ + Name: "PrimaryLineId", + FieldType: "ManyToOne", + }, + Migration: meta.FieldMigrationDecision{ + StorageKind: "virtualRuntime", + ShouldCreateColumn: false, + ReasonCode: "COMPUTE_STORE_FALSE", + }, + }); err != nil { + t.Fatalf("SetResolvedSpec: %v", err) + } + source := &meta.Model{ + Name: "Record", + Path: "sandbox/record.ts", + ModelTable: "sandbox_record", + Fields: []*meta.Field{stored, virtual}, + } + + keys, err := newForeignKeyMigrator(runtimeScope, nil, []*meta.Model{source, target}).(*foreignKeyMigrator).getManyToOneKeys() + if err != nil { + t.Fatalf("getManyToOneKeys() error = %v", err) + } + if len(keys) != 1 || keys[0].ColumnName != "owner_id" { + t.Fatalf("expected only stored owner_id FK, got %#v", keys) + } + }) + + t.Run("skips non-stored compute many-to-one via Compute decorator fallback", func(t *testing.T) { + runtimeScope := newSchemaTestScope(t) + target := &meta.Model{Name: "Line", Path: "sandbox/line.ts", ModelTable: "sandbox_line"} + virtual := &meta.Field{ + Name: "PrimaryLineId", + ModuleSpecPath: "sandbox/line", + Decorators: []*meta.Decorator{ + { + Name: "Field", + Arguments: []*meta.Argument{{Type: "ObjectLiteral", Value: `{"type":"ManyToOne"}`}}, + }, + { + Name: "Compute", + Arguments: []*meta.Argument{ + {Type: "StringLiteral", Value: "PrimaryLineId"}, + {Type: "ObjectLiteral", Value: `{"deps":["Lines.Id"],"store":false}`}, + }, + }, + }, + } + source := &meta.Model{ + Name: "Record", + Path: "sandbox/record.ts", + ModelTable: "sandbox_record", + Fields: []*meta.Field{virtual}, + } + + keys, err := newForeignKeyMigrator(runtimeScope, nil, []*meta.Model{source, target}).(*foreignKeyMigrator).getManyToOneKeys() + if err != nil { + t.Fatalf("getManyToOneKeys() error = %v", err) + } + if len(keys) != 0 { + t.Fatalf("expected no FK for store:false Compute, got %#v", keys) + } + }) +} + +func TestShouldSkipManyToOneForeignKey(t *testing.T) { + t.Parallel() + + t.Run("select option skips without consulting field", func(t *testing.T) { + skip, err := shouldSkipManyToOneForeignKey(nil, map[string]interface{}{"select": "expr"}) + if err != nil || !skip { + t.Fatalf("expected skip for select, got skip=%v err=%v", skip, err) + } + }) + + t.Run("nil field without select does not skip", func(t *testing.T) { + skip, err := shouldSkipManyToOneForeignKey(nil, map[string]interface{}{}) + if err != nil || skip { + t.Fatalf("expected no skip for nil field, got skip=%v err=%v", skip, err) + } + }) + + t.Run("GetResolvedSpec error wraps field name", func(t *testing.T) { + field := &meta.Field{Name: "OwnerId", ResolvedSpec: `{not-json`} + _, err := shouldSkipManyToOneForeignKey(field, map[string]interface{}{}) + if err == nil || !strings.Contains(err.Error(), "error unmarshal field resolved spec for OwnerId") { + t.Fatalf("expected resolved-spec unwrap error, got %v", err) + } + }) + + t.Run("getManyToOneKeys propagates shouldSkip ResolvedSpec errors", func(t *testing.T) { + runtimeScope := newSchemaTestScope(t) + target := &meta.Model{Name: "Line", Path: "sandbox/line.ts", ModelTable: "sandbox_line"} + bad := &meta.Field{ + Name: "OwnerId", + ModuleSpecPath: "sandbox/line", + ResolvedSpec: `{not-json`, + Decorators: []*meta.Decorator{ + {Name: "Field", Arguments: []*meta.Argument{{Type: "ObjectLiteral", Value: `{"type":"ManyToOne"}`}}}, + }, + } + source := &meta.Model{ + Name: "Record", + Path: "sandbox/record.ts", + ModelTable: "sandbox_record", + Fields: []*meta.Field{bad}, + } + _, err := newForeignKeyMigrator(runtimeScope, nil, []*meta.Model{source, target}).(*foreignKeyMigrator).getManyToOneKeys() + if err == nil || !strings.Contains(err.Error(), "error unmarshal field resolved spec for OwnerId") { + t.Fatalf("expected getManyToOneKeys to propagate skipErr, got %v", err) + } + }) + + t.Run("ResolvedSpec ShouldCreateColumn true/false", func(t *testing.T) { + stored := &meta.Field{Name: "OwnerId"} + if err := stored.SetResolvedSpec(&meta.FieldResolvedSpec{ + FieldName: "OwnerId", + Migration: meta.FieldMigrationDecision{ShouldCreateColumn: true}, + }); err != nil { + t.Fatalf("SetResolvedSpec: %v", err) + } + skip, err := shouldSkipManyToOneForeignKey(stored, nil) + if err != nil || skip { + t.Fatalf("expected keep stored column FK, got skip=%v err=%v", skip, err) + } + + virtual := &meta.Field{Name: "PrimaryLineId"} + if err := virtual.SetResolvedSpec(&meta.FieldResolvedSpec{ + FieldName: "PrimaryLineId", + Migration: meta.FieldMigrationDecision{ShouldCreateColumn: false, ReasonCode: "COMPUTE_STORE_FALSE"}, + }); err != nil { + t.Fatalf("SetResolvedSpec: %v", err) + } + skip, err = shouldSkipManyToOneForeignKey(virtual, nil) + if err != nil || !skip { + t.Fatalf("expected skip virtual column, got skip=%v err=%v", skip, err) + } + }) + + t.Run("falls back to Compute decorator when ResolvedSpec absent", func(t *testing.T) { + virtual := &meta.Field{ + Name: "PrimaryLineId", + Decorators: []*meta.Decorator{{ + Name: "Compute", + Arguments: []*meta.Argument{ + {Type: "ObjectLiteral", Value: `{"store":false}`}, + }, + }}, + } + skip, err := shouldSkipManyToOneForeignKey(virtual, map[string]interface{}{}) + if err != nil || !skip { + t.Fatalf("expected skip via Compute store:false, got skip=%v err=%v", skip, err) + } + }) +} + +func TestFieldHasNonStoredComputeDecorator(t *testing.T) { + t.Parallel() + + if fieldHasNonStoredComputeDecorator(nil) { + t.Fatal("nil field must be false") + } + + cases := []struct { + name string + field *meta.Field + want bool + }{ + { + name: "empty decorators", + field: &meta.Field{Name: "X"}, + want: false, + }, + { + name: "nil decorator skipped", + field: &meta.Field{ + Name: "X", + Decorators: []*meta.Decorator{nil}, + }, + want: false, + }, + { + name: "non-Compute decorator ignored", + field: &meta.Field{ + Name: "X", + Decorators: []*meta.Decorator{{ + Name: "Field", + Arguments: []*meta.Argument{{Type: "ObjectLiteral", Value: `{"store":false}`}}, + }}, + }, + want: false, + }, + { + name: "Compute store true does not skip", + field: &meta.Field{ + Name: "X", + Decorators: []*meta.Decorator{{ + Name: "Compute", + Arguments: []*meta.Argument{ + {Type: "ObjectLiteral", Value: `{"store":true}`}, + }, + }}, + }, + want: false, + }, + { + name: "Compute store false skips", + field: &meta.Field{ + Name: "X", + Decorators: []*meta.Decorator{{ + Name: "Compute", + Arguments: []*meta.Argument{ + {Type: "StringLiteral", Value: "fn"}, + {Type: "ObjectLiteral", Value: `{"deps":["A"],"store":false}`}, + }, + }}, + }, + want: true, + }, + { + name: "invalid Compute JSON ignored", + field: &meta.Field{ + Name: "X", + Decorators: []*meta.Decorator{{ + Name: "Compute", + Arguments: []*meta.Argument{ + {Type: "ObjectLiteral", Value: `{bad`}, + }, + }}, + }, + want: false, + }, + { + name: "empty ObjectLiteral value ignored", + field: &meta.Field{ + Name: "X", + Decorators: []*meta.Decorator{{ + Name: "Compute", + Arguments: []*meta.Argument{ + nil, + {Type: "ObjectLiteral", Value: ""}, + {Type: "StringLiteral", Value: `{"store":false}`}, + }, + }}, + }, + want: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := fieldHasNonStoredComputeDecorator(tc.field); got != tc.want { + t.Fatalf("got %v, want %v", got, tc.want) + } + }) + } } diff --git a/internal/module/lifecycle/install_tx_timing.go b/internal/module/lifecycle/install_tx_timing.go index 112db4456..c7a8608fb 100644 --- a/internal/module/lifecycle/install_tx_timing.go +++ b/internal/module/lifecycle/install_tx_timing.go @@ -11,7 +11,7 @@ import ( // LogModuleCommitTxHold records how long a per-module Commit Required // transaction was held (install / upgrade / uninstall). Successful holds stay -// Debug (ops metric); failures stay Info. +// Debug (ops metric); failures log at Error. func LogModuleCommitTxHold(logger *slog.Logger, op string, caller string, started time.Time, err error) { if logger == nil || started.IsZero() { return @@ -36,13 +36,14 @@ func logModuleCommitTxHold(logger *slog.Logger, op string, caller string, durati "caller", caller, "duration_ms", duration.Milliseconds(), } - msg := op + " commit transaction hold completed" if err != nil { - attrs = append(attrs, "error", err.Error()) - logger.Info(msg, attrs...) + // Pass the error value (not err.Error()) so console/devslog/json + // ReplaceAttr can expand xfmt / QuickJS frames into a stack trace. + attrs = append(attrs, "error", err) + logger.Error(op+" commit transaction hold failed", attrs...) return } - logger.Debug(msg, attrs...) + logger.Debug(op+" commit transaction hold completed", attrs...) } // LogInstallOuterTxHold records install Commit / legacy outer Required hold time. diff --git a/internal/module/lifecycle/install_tx_timing_test.go b/internal/module/lifecycle/install_tx_timing_test.go index 88c8159c6..bf2b51ff1 100644 --- a/internal/module/lifecycle/install_tx_timing_test.go +++ b/internal/module/lifecycle/install_tx_timing_test.go @@ -69,8 +69,8 @@ func TestLogModuleCommitTxHoldIncludesError(t *testing.T) { logs := logBuf.String() for _, want := range []string{ - `"level":"INFO"`, - `"msg":"uninstall commit transaction hold completed"`, + `"level":"ERROR"`, + `"msg":"uninstall commit transaction hold failed"`, `"error":"commit failed"`, } { if !strings.Contains(logs, want) { @@ -79,6 +79,32 @@ func TestLogModuleCommitTxHoldIncludesError(t *testing.T) { } } +func TestLogModuleCommitTxHoldPassesErrorValueForStack(t *testing.T) { + t.Parallel() + var logBuf bytes.Buffer + // Capture attrs via a thin handler so we can assert the error is not stringified. + logger := slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{ + Level: slog.LevelError, + ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { + if a.Key == "error" { + if a.Value.Kind() != slog.KindAny { + t.Fatalf("error attr must remain KindAny for stack expansion, got kind=%v", a.Value.Kind()) + } + errVal, ok := a.Value.Any().(error) + if !ok || errVal == nil || !strings.Contains(errVal.Error(), "commit failed") { + t.Fatalf("error attr must remain the original error value, got %#v", a.Value.Any()) + } + } + return a + }, + })) + errSample := errors.New("commit failed") + logModuleCommitTxHold(logger, "install", "module_commit", 10*time.Millisecond, errSample) + if !strings.Contains(logBuf.String(), "commit failed") { + t.Fatalf("expected error text in logs, got %q", logBuf.String()) + } +} + func TestLogInstallOuterTxHoldEmitsDuration(t *testing.T) { t.Parallel() var logBuf bytes.Buffer @@ -127,6 +153,9 @@ func TestLogInstallOuterTxHoldIncludesError(t *testing.T) { LogInstallOuterTxHold(logger, "cli", time.Now().Add(-10*time.Millisecond), errSample) logs := logBuf.String() + if !strings.Contains(logs, `"level":"ERROR"`) { + t.Fatalf("expected ERROR level, got %q", logs) + } if !strings.Contains(logs, `"error":"outer failed"`) { t.Fatalf("expected error attr, got %q", logs) } diff --git a/modules/core/i18n/core.pot b/modules/core/i18n/core.pot index 4f7f7c6c8..b548a2dbc 100644 --- a/modules/core/i18n/core.pot +++ b/modules/core/i18n/core.pot @@ -7,27 +7,27 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: service/orm/model/model.ts:430 +#: service/orm/model/model.ts:455 msgctxt "core.model.BaseModel.fields" msgid "Created At" msgstr "" -#: service/orm/model/model.ts:466 +#: service/orm/model/model.ts:491 msgctxt "core.model.BaseModel.fields" msgid "Created By" msgstr "" -#: service/orm/model/model.ts:452 +#: service/orm/model/model.ts:477 msgctxt "core.model.BaseModel.fields" msgid "Deleted At" msgstr "" -#: service/orm/model/model.ts:494 +#: service/orm/model/model.ts:519 msgctxt "core.model.BaseModel.fields" msgid "Deleted By" msgstr "" -#: service/orm/model/model.ts:408 +#: service/orm/model/model.ts:433 msgctxt "core.model.BaseModel.fields" msgid "Display Name" msgstr "" @@ -37,48 +37,93 @@ msgctxt "core.model.BaseModel.fields" msgid "Parent Path" msgstr "" -#: service/orm/model/model.ts:441 +#: service/orm/model/model.ts:466 msgctxt "core.model.BaseModel.fields" msgid "Updated At" msgstr "" -#: service/orm/model/model.ts:480 +#: service/orm/model/model.ts:505 msgctxt "core.model.BaseModel.fields" msgid "Updated By" msgstr "" -#: service/orm/model/model.ts:287 +#: service/orm/model/property_definition_base_model.ts +msgctxt "core.model.PropertyDefinition.fields" +msgid "Container Id" +msgstr "" + +#: service/orm/model/property_definition_base_model.ts +msgctxt "core.model.PropertyDefinition.fields" +msgid "Container Model" +msgstr "" + +#: service/orm/model/property_definition_base_model.ts +msgctxt "core.model.PropertyDefinition.fields" +msgid "Definition" +msgstr "" + +#: service/orm/model/property_definition_base_model.ts +msgctxt "core.model.PropertyDefinition.fields" +msgid "Leave empty for app-level scope; set to the parent model short name for record-scoped definitions." +msgstr "" + +#: service/orm/model/property_definition_base_model.ts +msgctxt "core.model.PropertyDefinition.fields" +msgid "Leave empty for app-level scope; set with Container Model to bind this definition to one parent record." +msgstr "" + +#: service/orm/model/property_definition_base_model.ts +msgctxt "core.model.PropertyDefinition.fields" +msgid "Model that owns the properties column (e.g. SandboxRecord)." +msgstr "" + +#: service/orm/model/property_definition_base_model.ts +msgctxt "core.model.PropertyDefinition.fields" +msgid "Name of the properties field on the target model (e.g. Properties)." +msgstr "" + +#: service/orm/model/property_definition_base_model.ts +msgctxt "core.model.PropertyDefinition.fields" +msgid "Properties Field" +msgstr "" + +#: service/orm/model/property_definition_base_model.ts +msgctxt "core.model.PropertyDefinition.fields" +msgid "Target Model" +msgstr "" + +#: service/orm/model/model.ts:308 msgctxt "service/orm/model/model" msgid "current company is required" msgstr "" -#: service/orm/model/model.ts:276 +#: service/orm/model/model.ts:297 msgctxt "service/orm/model/model" msgid "current user is required" msgstr "" -#: service/orm/model/model_instance.ts:189 +#: service/orm/model/model_instance.ts:197 msgctxt "service/orm/model/model_instance" msgid "Delete failed: Record with Id %s not found or could not be deleted" msgstr "" -#: service/orm/model/model_instance.ts:226 -#: service/orm/model/model_instance.ts:89 +#: service/orm/model/model_instance.ts:238 +#: service/orm/model/model_instance.ts:92 msgctxt "service/orm/model/model_instance" msgid "Record with Id %s no longer exists" msgstr "" -#: service/orm/model/model_read.ts:318 +#: service/orm/model/model_read.ts:324 msgctxt "service/orm/model/model_read" msgid "%s not found" msgstr "" -#: service/orm/model/model_soft_delete_scope.ts:29 +#: service/orm/model/model_soft_delete_scope.ts:23 msgctxt "service/orm/model/model_soft_delete_scope" msgid "withDeleted and onlyDeleted cannot both be true" msgstr "" -#: service/orm/model/model_update.ts:664 +#: service/orm/model/model_update.ts:667 msgctxt "service/orm/model/model_update" msgid "Update failed: Record %s not found." msgstr "" @@ -155,32 +200,32 @@ msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "failed to fetch record rule condition" msgstr "" -#: service/orm/repository/authz/record_rule_helpers.ts:203 +#: service/orm/repository/authz/record_rule_helpers.ts:214 msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "invalid JSON in record rule condition" msgstr "" -#: service/orm/repository/authz/record_rule_helpers.ts:228 +#: service/orm/repository/authz/record_rule_helpers.ts:239 msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "invalid record rule condition shape (type=%s, isArray=%s, preview=%s)" msgstr "" -#: service/orm/repository/authz/record_rule_helpers.ts:154 +#: service/orm/repository/authz/record_rule_helpers.ts:165 msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "missing ctx.activeCompanyId for $companyId token" msgstr "" -#: service/orm/repository/authz/record_rule_helpers.ts:166 +#: service/orm/repository/authz/record_rule_helpers.ts:177 msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "missing ctx.enabledCompanyIds for $companyIds token" msgstr "" -#: service/orm/repository/authz/record_rule_helpers.ts:252 +#: service/orm/repository/authz/record_rule_helpers.ts:263 msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "missing identity.userId for $userId token" msgstr "" -#: service/orm/repository/authz/record_rule_helpers.ts:266 +#: service/orm/repository/authz/record_rule_helpers.ts:277 msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "unknown token in record rule condition" msgstr "" @@ -211,7 +256,7 @@ msgctxt "service/orm/repository/validation/kernel" msgid "Field \"%s\" is required" msgstr "" -#: service/orm/repository/validation/kernel.ts:224 +#: service/orm/repository/validation/kernel.ts:231 msgctxt "service/orm/repository/validation/kernel" msgid "Field \"%s\" must be a valid decimal that satisfies the precision and scale limits" msgstr "" diff --git a/modules/core/i18n/zh_CN.po b/modules/core/i18n/zh_CN.po index 9e44aa5a6..4a09b367c 100644 --- a/modules/core/i18n/zh_CN.po +++ b/modules/core/i18n/zh_CN.po @@ -1,27 +1,27 @@ msgid "" msgstr "Content-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit\nLanguage: zh_CN\n" -#: service/orm/model/model.ts:430 +#: service/orm/model/model.ts:455 msgctxt "core.model.BaseModel.fields" msgid "Created At" msgstr "创建时间" -#: service/orm/model/model.ts:466 +#: service/orm/model/model.ts:491 msgctxt "core.model.BaseModel.fields" msgid "Created By" msgstr "创建人" -#: service/orm/model/model.ts:452 +#: service/orm/model/model.ts:477 msgctxt "core.model.BaseModel.fields" msgid "Deleted At" msgstr "删除时间" -#: service/orm/model/model.ts:494 +#: service/orm/model/model.ts:519 msgctxt "core.model.BaseModel.fields" msgid "Deleted By" msgstr "删除人" -#: service/orm/model/model.ts:408 +#: service/orm/model/model.ts:433 msgctxt "core.model.BaseModel.fields" msgid "Display Name" msgstr "显示名称" @@ -31,47 +31,92 @@ msgctxt "core.model.BaseModel.fields" msgid "Parent Path" msgstr "父级路径" -#: service/orm/model/model.ts:441 +#: service/orm/model/model.ts:466 msgctxt "core.model.BaseModel.fields" msgid "Updated At" msgstr "更新时间" -#: service/orm/model/model.ts:480 +#: service/orm/model/model.ts:505 msgctxt "core.model.BaseModel.fields" msgid "Updated By" msgstr "更新人" -#: service/orm/model/model.ts:287 +#: service/orm/model/property_definition_base_model.ts +msgctxt "core.model.PropertyDefinition.fields" +msgid "Container Id" +msgstr "容器 ID" + +#: service/orm/model/property_definition_base_model.ts +msgctxt "core.model.PropertyDefinition.fields" +msgid "Container Model" +msgstr "容器模型" + +#: service/orm/model/property_definition_base_model.ts +msgctxt "core.model.PropertyDefinition.fields" +msgid "Definition" +msgstr "定义" + +#: service/orm/model/property_definition_base_model.ts +msgctxt "core.model.PropertyDefinition.fields" +msgid "Leave empty for app-level scope; set to the parent model short name for record-scoped definitions." +msgstr "应用级作用域请留空;记录级定义则填写父模型短名。" + +#: service/orm/model/property_definition_base_model.ts +msgctxt "core.model.PropertyDefinition.fields" +msgid "Leave empty for app-level scope; set with Container Model to bind this definition to one parent record." +msgstr "应用级作用域请留空;与「容器模型」一起填写以绑定到一条父记录。" + +#: service/orm/model/property_definition_base_model.ts +msgctxt "core.model.PropertyDefinition.fields" +msgid "Model that owns the properties column (e.g. SandboxRecord)." +msgstr "拥有属性列的业务模型(例如 SandboxRecord)。" + +#: service/orm/model/property_definition_base_model.ts +msgctxt "core.model.PropertyDefinition.fields" +msgid "Name of the properties field on the target model (e.g. Properties)." +msgstr "目标模型上的属性字段名(例如 Properties)。" + +#: service/orm/model/property_definition_base_model.ts +msgctxt "core.model.PropertyDefinition.fields" +msgid "Properties Field" +msgstr "属性字段" + +#: service/orm/model/property_definition_base_model.ts +msgctxt "core.model.PropertyDefinition.fields" +msgid "Target Model" +msgstr "目标模型" + +#: service/orm/model/model.ts:308 msgctxt "service/orm/model/model" msgid "current company is required" msgstr "需要当前公司" -#: service/orm/model/model.ts:276 +#: service/orm/model/model.ts:297 msgctxt "service/orm/model/model" msgid "current user is required" msgstr "需要当前用户" -#: service/orm/model/model_instance.ts:189 +#: service/orm/model/model_instance.ts:197 msgctxt "service/orm/model/model_instance" msgid "Delete failed: Record with Id %s not found or could not be deleted" msgstr "删除失败:未找到 Id 为 %s 的记录,或无法删除" -#: service/orm/model/model_instance.ts:226 service/orm/model/model_instance.ts:89 +#: service/orm/model/model_instance.ts:238 service/orm/model/model_instance.ts:92 msgctxt "service/orm/model/model_instance" msgid "Record with Id %s no longer exists" msgstr "Id 为 %s 的记录已不存在" -#: service/orm/model/model_read.ts:318 +#: service/orm/model/model_read.ts:324 msgctxt "service/orm/model/model_read" msgid "%s not found" msgstr "未找到 %s" -#: service/orm/model/model_soft_delete_scope.ts:29 +#: service/orm/model/model_soft_delete_scope.ts:23 msgctxt "service/orm/model/model_soft_delete_scope" msgid "withDeleted and onlyDeleted cannot both be true" msgstr "withDeleted 与 onlyDeleted 不能同时为 true" -#: service/orm/model/model_update.ts:664 +#: service/orm/model/model_update.ts:667 msgctxt "service/orm/model/model_update" msgid "Update failed: Record %s not found." msgstr "更新失败:未找到记录 %s。" @@ -146,32 +191,32 @@ msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "failed to fetch record rule condition" msgstr "获取记录规则条件失败" -#: service/orm/repository/authz/record_rule_helpers.ts:203 +#: service/orm/repository/authz/record_rule_helpers.ts:214 msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "invalid JSON in record rule condition" msgstr "记录规则条件中的 JSON 无效" -#: service/orm/repository/authz/record_rule_helpers.ts:228 +#: service/orm/repository/authz/record_rule_helpers.ts:239 msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "invalid record rule condition shape (type=%s, isArray=%s, preview=%s)" msgstr "记录规则条件形状无效(type=%s, isArray=%s, preview=%s)" -#: service/orm/repository/authz/record_rule_helpers.ts:154 +#: service/orm/repository/authz/record_rule_helpers.ts:165 msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "missing ctx.activeCompanyId for $companyId token" msgstr "$companyId 令牌缺少 ctx.activeCompanyId" -#: service/orm/repository/authz/record_rule_helpers.ts:166 +#: service/orm/repository/authz/record_rule_helpers.ts:177 msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "missing ctx.enabledCompanyIds for $companyIds token" msgstr "$companyIds 令牌缺少 ctx.enabledCompanyIds" -#: service/orm/repository/authz/record_rule_helpers.ts:252 +#: service/orm/repository/authz/record_rule_helpers.ts:263 msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "missing identity.userId for $userId token" msgstr "$userId 令牌缺少 identity.userId" -#: service/orm/repository/authz/record_rule_helpers.ts:266 +#: service/orm/repository/authz/record_rule_helpers.ts:277 msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "unknown token in record rule condition" msgstr "记录规则条件中存在未知令牌" @@ -201,7 +246,7 @@ msgctxt "service/orm/repository/validation/kernel" msgid "Field \"%s\" is required" msgstr "字段 \"%s\" 为必填" -#: service/orm/repository/validation/kernel.ts:224 +#: service/orm/repository/validation/kernel.ts:231 msgctxt "service/orm/repository/validation/kernel" msgid "Field \"%s\" must be a valid decimal that satisfies the precision and scale limits" msgstr "字段 \"%s\" 必须是满足精度与小数位限制的有效小数" @@ -290,3 +335,4 @@ msgstr "意外错误" msgctxt "web/rpc/errors" msgid "Unknown API error" msgstr "未知 API 错误" + diff --git a/modules/core/service/orm/model/index.ts b/modules/core/service/orm/model/index.ts index 3fb6c6c61..9c93ff020 100644 --- a/modules/core/service/orm/model/index.ts +++ b/modules/core/service/orm/model/index.ts @@ -24,8 +24,16 @@ export { normalizePropertiesMap, isPlainPropertiesMap, type PropertyItemDefinition, + type PropertyLabel, type ResolvedPropertyItem, } from './properties_types'; +export { + TRANSLATED_BASE_LANG, + mergePropertyLabelWrite, + mergeSelectionLabelsWrite, + translatePropertyDefinitionItems, + unwrapPropertyLabel, +} from './properties_label_i18n'; export { lookupPropertyDefinitionModel, __setLookupPropertyDefinitionModelForTest, diff --git a/modules/core/service/orm/model/properties_label_i18n.test.ts b/modules/core/service/orm/model/properties_label_i18n.test.ts new file mode 100644 index 000000000..08d9e3ce9 --- /dev/null +++ b/modules/core/service/orm/model/properties_label_i18n.test.ts @@ -0,0 +1,228 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { + mergePropertyLabelWrite, + mergeSelectionLabelsWrite, + normalizePropertyLabelForStorage, + normalizeSelectionLabelsForStorage, + parsePropertyLabelMap, + translatePropertyDefinitionItems, + unwrapPropertyLabel, +} from './properties_label_i18n'; +import { assertValidPropertyDefinitionItems } from './properties_types'; + +test('parsePropertyLabelMap treats plain string as en_US', () => { + expect(parsePropertyLabelMap('Priority')).toEqual({ en_US: 'Priority' }); + expect(parsePropertyLabelMap({ en_US: 'Priority', zh_CN: '优先级' })).toEqual({ + en_US: 'Priority', + zh_CN: '优先级', + }); + expect(parsePropertyLabelMap(null)).toBeNull(); + expect(parsePropertyLabelMap(['x'])).toBeNull(); +}); + +test('parsePropertyLabelMap skips hyphen keys and coerces number/boolean/null values', () => { + expect( + parsePropertyLabelMap({ + en_US: 'Base', + 'zh-CN': 'skipped', + '': 'empty-key', + zh_CN: 12, + fr_FR: true, + de_DE: false, + ja_JP: null, + bad: { nested: 1 }, + }) + ).toEqual({ + en_US: 'Base', + zh_CN: '12', + fr_FR: 'true', + de_DE: 'false', + ja_JP: '', + }); + expect(parsePropertyLabelMap({ 'zh-CN': 'only-hyphen' })).toBeNull(); +}); + +test('unwrapPropertyLabel falls back to en_US and keeps explicit empty', () => { + expect(unwrapPropertyLabel({ en_US: 'Priority', zh_CN: '优先级' }, 'zh_CN')).toBe('优先级'); + expect(unwrapPropertyLabel({ en_US: 'Priority' }, 'zh_CN')).toBe('Priority'); + expect(unwrapPropertyLabel({ en_US: 'Priority', zh_CN: '' }, 'zh_CN')).toBe(''); + expect(unwrapPropertyLabel('Priority', 'zh_CN')).toBe('Priority'); + expect(unwrapPropertyLabel(undefined, 'zh_CN')).toBe(''); +}); + +test('normalizePropertyLabelForStorage keeps string and cleans maps', () => { + expect(normalizePropertyLabelForStorage('Priority')).toBe('Priority'); + expect(normalizePropertyLabelForStorage({ en_US: 'Priority' })).toBe('Priority'); + expect(normalizePropertyLabelForStorage({ en_US: 'Priority', zh_CN: '优先级' })).toEqual({ + en_US: 'Priority', + zh_CN: '优先级', + }); + expect(normalizePropertyLabelForStorage(null)).toBeUndefined(); + expect(normalizePropertyLabelForStorage({})).toBeUndefined(); + expect(normalizePropertyLabelForStorage({ zh_CN: 7, fr_FR: false, en_US: null })).toEqual({ + zh_CN: '7', + fr_FR: 'false', + en_US: '', + }); + expect(() => normalizePropertyLabelForStorage({ 'zh-CN': 'x' })).toThrow(/terminology code/); + expect(() => normalizePropertyLabelForStorage({ en_US: { nested: 1 } as any })).toThrow(/must be a string/); + expect(() => normalizePropertyLabelForStorage(['not-a-map'] as any)).toThrow(/string or lang map/); + expect(() => normalizePropertyLabelForStorage(12 as any)).toThrow(/string or lang map/); +}); + +test('mergePropertyLabelWrite preserves other langs and seeds en_US', () => { + expect(mergePropertyLabelWrite({ en_US: 'Priority', zh_CN: '旧' }, '优先级', 'zh_CN')).toEqual({ + en_US: 'Priority', + zh_CN: '优先级', + }); + expect(mergePropertyLabelWrite(undefined, '优先级', 'zh_CN')).toEqual({ + en_US: '优先级', + zh_CN: '优先级', + }); + expect(mergePropertyLabelWrite('Priority', '优先级', 'zh_CN')).toEqual({ + en_US: 'Priority', + zh_CN: '优先级', + }); +}); + +test('mergeSelectionLabelsWrite merges by option value', () => { + const merged = mergeSelectionLabelsWrite( + [ + ['high', { en_US: 'High', zh_CN: '高' }], + ['low', 'Low'], + ], + [ + { value: 'high', label: '很高' }, + { value: 'mid', label: '中' }, + ], + 'zh_CN' + ); + expect(merged).toEqual([ + ['high', { en_US: 'High', zh_CN: '很高' }], + ['mid', { en_US: '中', zh_CN: '中' }], + ]); +}); + +test('mergeSelectionLabelsWrite skips invalid previous options and accepts object form', () => { + expect(mergeSelectionLabelsWrite(undefined, [{ value: 'a', label: 'A' }], 'en_US')).toEqual([ + ['a', 'A'], + ]); + const merged = mergeSelectionLabelsWrite( + [ + { value: 'a', label: { en_US: 'Alpha', zh_CN: '甲' } }, + { value: 1 as any, label: 'skip-non-string-value' }, + null as any, + [1 as any, 'non-string-tuple-value'], + ], + [ + { value: 'a', label: '新甲' }, + { value: 'b', label: '乙' }, + ], + 'zh_CN' + ); + expect(merged).toEqual([ + ['a', { en_US: 'Alpha', zh_CN: '新甲' }], + ['b', { en_US: '乙', zh_CN: '乙' }], + ]); +}); + +test('normalizeSelectionLabelsForStorage keeps tuple vs object shape and null labels', () => { + expect( + normalizeSelectionLabelsForStorage( + [ + ['high', { en_US: 'High', zh_CN: '高' }], + { value: 'low', label: null }, + { value: 'mid' }, + ['solo'], + ], + 'priority' + ) + ).toEqual([ + ['high', { en_US: 'High', zh_CN: '高' }], + { value: 'low', label: 'low' }, + { value: 'mid', label: 'mid' }, + ['solo', 'solo'], + ]); + expect(() => normalizeSelectionLabelsForStorage(null, 'priority')).toThrow(/selection options are invalid/); + expect(() => normalizeSelectionLabelsForStorage([{ label: 'x' }], 'priority')).toThrow( + /selection options are invalid/ + ); + expect(() => normalizeSelectionLabelsForStorage([42], 'priority')).toThrow(/selection options are invalid/); +}); + +test('translatePropertyDefinitionItems unwraps string, help, and selection labels', () => { + const items = translatePropertyDefinitionItems( + [ + { + name: 'priority', + type: 'selection', + string: { en_US: 'Priority', zh_CN: '优先级' }, + help: { en_US: 'Record urgency', zh_CN: '记录紧急程度' }, + selection: [ + ['high', { en_US: 'High', zh_CN: '高' }], + { value: 'low', label: { en_US: 'Low', zh_CN: '低' } }, + 'passthrough-invalid' as any, + [1 as any, 'non-string-value'], + ], + default: 'high', + }, + { + name: 'code', + type: 'char', + }, + ], + 'zh_CN' + ); + expect(items[0].string).toBe('优先级'); + expect(items[0].help).toBe('记录紧急程度'); + expect(items[0].default).toBe('high'); + expect(items[0].selection).toEqual([ + ['high', '高'], + { value: 'low', label: '低' }, + 'passthrough-invalid', + [1, 'non-string-value'], + ]); + expect(items[1].name).toBe('code'); + expect(Object.prototype.hasOwnProperty.call(items[1], 'string')).toBe(false); + + expect(translatePropertyDefinitionItems([], '')).toEqual([]); + expect(translatePropertyDefinitionItems(null as any, ' ')).toEqual([]); +}); + +test('assertValidPropertyDefinitionItems accepts lang-map labels and help', () => { + const out = assertValidPropertyDefinitionItems([ + { + name: 'priority', + type: 'selection', + string: { en_US: 'Priority', zh_CN: '优先级' }, + help: { en_US: 'Record urgency', zh_CN: '记录紧急程度' }, + selection: [['high', { en_US: 'High', zh_CN: '高' }]], + default: 'high', + }, + { + name: 'code', + type: 'char', + string: 'External Code', + help: 'Short unique code used in references', + }, + ]); + expect(out[0].string).toEqual({ en_US: 'Priority', zh_CN: '优先级' }); + expect(out[0].help).toEqual({ en_US: 'Record urgency', zh_CN: '记录紧急程度' }); + expect(out[0].selection).toEqual([['high', { en_US: 'High', zh_CN: '高' }]]); + expect(out[1].string).toBe('External Code'); + expect(out[1].help).toBe('Short unique code used in references'); +}); + +test('assertValidPropertyDefinitionItems rejects invalid label maps', () => { + expect(() => + assertValidPropertyDefinitionItems([{ name: 'x', type: 'char', string: { en_US: { bad: 1 } as any } }]) + ).toThrow(/must be a string/); +}); + +test('assertValidPropertyDefinitionItems rejects invalid help maps', () => { + expect(() => + assertValidPropertyDefinitionItems([{ name: 'x', type: 'char', help: { en_US: { bad: 1 } as any } }]) + ).toThrow(/must be a string/); +}); diff --git a/modules/core/service/orm/model/properties_label_i18n.ts b/modules/core/service/orm/model/properties_label_i18n.ts new file mode 100644 index 000000000..5c72394ee --- /dev/null +++ b/modules/core/service/orm/model/properties_label_i18n.ts @@ -0,0 +1,203 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { + TRANSLATED_BASE_LANG, + assertTranslatedLangKey, + isTranslatedLangMap, + unwrapTranslatedValue, +} from '../repository/projection/translated_field_codec'; +import type { PropertyItemDefinition, PropertyLabel } from './properties_types'; + +/** Re-export for web/UI callers (avoid deep `@/core/service/orm/repository/projection/*` imports). */ +export { TRANSLATED_BASE_LANG }; + +function hasOwn(map: object, key: string): boolean { + return Object.prototype.hasOwnProperty.call(map, key); +} + +/** Parse a stored property label into a lang map (plain string → `{ en_US: s }`). */ +export function parsePropertyLabelMap(value: unknown): Record | null { + if (value == null) return null; + if (typeof value === 'string') { + return { [TRANSLATED_BASE_LANG]: value }; + } + if (!isTranslatedLangMap(value)) return null; + const out: Record = {}; + for (const [rawKey, rawVal] of Object.entries(value)) { + const lang = String(rawKey || '').trim(); + if (!lang || lang.includes('-')) continue; + if (rawVal == null) { + out[lang] = ''; + continue; + } + if (typeof rawVal === 'string' || typeof rawVal === 'number' || typeof rawVal === 'boolean') { + out[lang] = String(rawVal); + } + } + return Object.keys(out).length ? out : null; +} + +/** + * Normalize a label for Definition write storage. + * Plain strings are kept; lang maps are cleaned (string values only). + * Returns `undefined` when the label should be omitted. + */ +export function normalizePropertyLabelForStorage(value: unknown, fieldHint = 'string'): PropertyLabel | undefined { + if (value == null) return undefined; + if (typeof value === 'string') { + return value; + } + if (!isTranslatedLangMap(value) || Array.isArray(value)) { + throw new Error(`PropertyDefinition label "${fieldHint}" must be a string or lang map`); + } + const out: Record = {}; + for (const [rawKey, rawVal] of Object.entries(value)) { + const lang = assertTranslatedLangKey(rawKey, fieldHint); + if (rawVal == null) { + out[lang] = ''; + continue; + } + if (typeof rawVal !== 'string' && typeof rawVal !== 'number' && typeof rawVal !== 'boolean') { + throw new Error(`PropertyDefinition label "${fieldHint}" lang map value for "${lang}" must be a string`); + } + out[lang] = String(rawVal); + } + if (!Object.keys(out).length) return undefined; + // Single en_US-only map collapses to plain string for compact storage. + const keys = Object.keys(out); + if (keys.length === 1 && keys[0] === TRANSLATED_BASE_LANG) { + return out[TRANSLATED_BASE_LANG]; + } + return out; +} + +/** Unwrap a stored label for a request language (missing → en_US → ''). */ +export function unwrapPropertyLabel(label: unknown, lang: string): string { + const map = parsePropertyLabelMap(label); + if (!map) return ''; + const unwrapped = unwrapTranslatedValue(map, lang); + return unwrapped == null ? '' : unwrapped; +} + +/** + * Merge a current-language label write into a previous PropertyLabel. + * Preserves other language keys; seeds en_US on first write when missing. + */ +export function mergePropertyLabelWrite( + previous: unknown, + currentLangValue: string, + lang: string, +): PropertyLabel { + const key = assertTranslatedLangKey(lang, 'string'); + const current = String(currentLangValue ?? ''); + const prevMap = parsePropertyLabelMap(previous) || {}; + const next: Record = { ...prevMap, [key]: current }; + if (!hasOwn(next, TRANSLATED_BASE_LANG)) { + next[TRANSLATED_BASE_LANG] = current; + } + return normalizePropertyLabelForStorage(next, 'string') ?? current; +} + +export function selectionOptionValue(opt: unknown): string | null { + if (Array.isArray(opt) && typeof opt[0] === 'string') return opt[0]; + if (opt && typeof opt === 'object' && !Array.isArray(opt) && typeof (opt as { value?: unknown }).value === 'string') { + return String((opt as { value: string }).value); + } + return null; +} + +export function selectionOptionLabel(opt: unknown): unknown { + if (Array.isArray(opt)) return opt[1] ?? opt[0]; + if (opt && typeof opt === 'object' && !Array.isArray(opt)) { + return (opt as { label?: unknown }).label ?? (opt as { value?: unknown }).value; + } + return undefined; +} + +/** Normalize selection options; labels may be string or lang map. */ +export function normalizeSelectionLabelsForStorage( + selection: unknown, + itemName: string +): PropertyItemDefinition['selection'] { + if (!Array.isArray(selection)) { + throw new Error(`PropertyDefinition.Definition item "${itemName}" selection options are invalid`); + } + const out: NonNullable = []; + for (let i = 0; i < selection.length; i++) { + const opt = selection[i]; + const value = selectionOptionValue(opt); + if (value == null) { + throw new Error(`PropertyDefinition.Definition item "${itemName}" selection options are invalid`); + } + const rawLabel = selectionOptionLabel(opt); + const label = + rawLabel === undefined || rawLabel === null + ? value + : (normalizePropertyLabelForStorage(rawLabel, `${itemName}.selection.${value}`) ?? value); + if (Array.isArray(opt)) { + out.push([value, label] as [string, PropertyLabel]); + } else { + out.push({ value, label }); + } + } + return out; +} + +/** + * Merge current-lang selection labels into previous options (by value). + * Used by the definition editor so saving does not wipe other languages. + */ +export function mergeSelectionLabelsWrite( + previous: PropertyItemDefinition['selection'] | undefined, + incoming: Array<{ value: string; label: string }>, + lang: string +): PropertyItemDefinition['selection'] { + const prevByValue = new Map(); + for (const opt of previous || []) { + const value = selectionOptionValue(opt); + if (value == null) continue; + prevByValue.set(value, selectionOptionLabel(opt)); + } + return incoming.map(opt => { + const prevLabel = prevByValue.get(opt.value); + const merged = mergePropertyLabelWrite(prevLabel, opt.label, lang); + return [opt.value, merged] as [string, PropertyLabel]; + }); +} + +/** + * Expand item `string` / `help` / selection labels to the request language. + * Returns a new array; does not mutate input. Selection values are unchanged. + */ +export function translatePropertyDefinitionItems( + items: PropertyItemDefinition[], + lang: string +): PropertyItemDefinition[] { + const code = String(lang || '').trim() || TRANSLATED_BASE_LANG; + return (items || []).map(item => { + const next: PropertyItemDefinition = { ...item }; + if (Object.prototype.hasOwnProperty.call(item, 'string')) { + next.string = unwrapPropertyLabel(item.string, code); + } + if (Object.prototype.hasOwnProperty.call(item, 'help')) { + next.help = unwrapPropertyLabel(item.help, code); + } + if (Array.isArray(item.selection)) { + next.selection = item.selection.map(opt => { + if (Array.isArray(opt) && typeof opt[0] === 'string') { + const value = opt[0]; + const label = unwrapPropertyLabel(opt[1] ?? value, code) || value; + return [value, label] as [string, string]; + } + if (opt && typeof opt === 'object' && !Array.isArray(opt) && typeof (opt as { value?: unknown }).value === 'string') { + const value = String((opt as { value: string }).value); + const label = unwrapPropertyLabel((opt as { label?: unknown }).label ?? value, code) || value; + return { value, label }; + } + return opt; + }) as PropertyItemDefinition['selection']; + } + return next; + }); +} diff --git a/modules/core/service/orm/model/properties_resolve.test.ts b/modules/core/service/orm/model/properties_resolve.test.ts index 334126973..4f81fd01a 100644 --- a/modules/core/service/orm/model/properties_resolve.test.ts +++ b/modules/core/service/orm/model/properties_resolve.test.ts @@ -3,6 +3,10 @@ import { Model } from '../decorator/model'; import { Field } from '../decorator/field'; +import { + clearGlobalRequestContextProvider, + setGlobalRequestContextProvider, +} from '../../../rpc/context'; import BaseModel from './model'; import PropertyDefinitionBaseModel from './property_definition_base_model'; import { @@ -132,6 +136,46 @@ test('properties resolve: App-level merges schema ⊕ value', async () => { } }); +test('properties resolve: unwraps Definition label lang maps by request lang', async () => { + installDefinitionRows([ + { + TargetModel: 'Pp1Partner', + PropertiesField: 'PartnerProperties', + ContainerId: null, + Definition: [ + { + name: 'priority', + type: 'selection', + string: { en_US: 'Priority', zh_CN: '优先级' }, + help: { en_US: 'Record urgency', zh_CN: '记录紧急程度' }, + selection: [ + ['high', { en_US: 'High', zh_CN: '高' }], + ['low', 'Low'], + ], + default: 'high', + }, + { name: 'code', type: 'char', string: 'External Code' }, + ], + }, + ]); + setGlobalRequestContextProvider({ lang: 'zh_CN' }); + try { + const items = await resolveProperties(Pp1Partner as any, { PartnerProperties: { priority: 'high' } }, 'PartnerProperties'); + expect(items[0]?.string).toBe('优先级'); + expect(items[0]?.help).toBe('记录紧急程度'); + expect(items[0]?.selection).toEqual([ + ['high', '高'], + ['low', 'Low'], + ]); + expect(items[0]?.value).toBe('high'); + expect(items[0]?.default).toBe('high'); + expect(items[1]?.string).toBe('External Code'); + } finally { + clearGlobalRequestContextProvider(); + __clearLookupPropertyDefinitionModelForTest(); + } +}); + test('properties resolve: empty parent id → [] without App-level fallback (PP2)', async () => { installDefinitionRows([ { diff --git a/modules/core/service/orm/model/properties_resolve.ts b/modules/core/service/orm/model/properties_resolve.ts index e357223bc..32a3bec7a 100644 --- a/modules/core/service/orm/model/properties_resolve.ts +++ b/modules/core/service/orm/model/properties_resolve.ts @@ -7,7 +7,9 @@ import { asObjectRecord } from '../../../utils/object'; import type { ObjectRecord } from '../../../utils/types'; import type BaseModel from './model'; import type { ModelCtor } from './types'; +import { resolveTranslatedFieldLang } from '../repository/projection/translated_field_codec'; import { lookupPropertyDefinitionModel } from './properties_lookup'; +import { translatePropertyDefinitionItems } from './properties_label_i18n'; import { filterReadablePropertyDefinitionItems, normalizePropertiesMap, @@ -153,7 +155,9 @@ export async function resolveProperties( items = await loadDefinitionItems(application, targetModel, fieldName, 'parent', containerModel, containerId); } - return items.map(item => { + // Expand Definition label lang maps for the request language; do not cache translated items. + const localized = translatePropertyDefinitionItems(items, resolveTranslatedFieldLang()); + return localized.map(item => { const resolved: ResolvedPropertyItem = { ...item }; if (Object.prototype.hasOwnProperty.call(valueMap, item.name)) { resolved.value = valueMap[item.name]; diff --git a/modules/core/service/orm/model/properties_types.ts b/modules/core/service/orm/model/properties_types.ts index cabe0d289..3f53164c1 100644 --- a/modules/core/service/orm/model/properties_types.ts +++ b/modules/core/service/orm/model/properties_types.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: 2026-present Brian Wang // SPDX-License-Identifier: Apache-2.0 +import { normalizePropertyLabelForStorage, normalizeSelectionLabelsForStorage } from './properties_label_i18n'; + /** V1 property item types (PP7). */ export const PROPERTIES_V1_TYPES = new Set([ 'boolean', @@ -13,13 +15,18 @@ export const PROPERTIES_V1_TYPES = new Set([ 'selection', ]); +/** Definition item label: plain string (≡ `{ en_US: s }`) or lang map. */ +export type PropertyLabel = string | Record; + export type PropertyItemDefinition = { name: string; type: string; - string?: string; + string?: PropertyLabel; + /** Field tip (plain string ≡ `{ en_US: s }` or lang map); shown next to the item label. */ + help?: PropertyLabel; default?: unknown; readonly?: boolean; - selection?: Array<{ value: string; label?: string } | [string, string]>; + selection?: Array<{ value: string; label?: PropertyLabel } | [string, PropertyLabel]>; [key: string]: unknown; }; @@ -132,6 +139,32 @@ export function assertValidPropertyDefinitionItems(raw: unknown): PropertyItemDe ); } const normalized = { ...rec, name, type } as PropertyItemDefinition; + if (Object.prototype.hasOwnProperty.call(rec, 'string')) { + try { + const label = normalizePropertyLabelForStorage(rec.string, `${name}.string`); + if (label === undefined) delete normalized.string; + else normalized.string = label; + } catch (err) { + throw new Error( + err instanceof Error + ? err.message + : `PropertyDefinition.Definition item "${name}" string label is invalid` + ); + } + } + if (Object.prototype.hasOwnProperty.call(rec, 'help')) { + try { + const help = normalizePropertyLabelForStorage(rec.help, `${name}.help`); + if (help === undefined) delete normalized.help; + else normalized.help = help; + } catch (err) { + throw new Error( + err instanceof Error + ? err.message + : `PropertyDefinition.Definition item "${name}" help is invalid` + ); + } + } if (type === 'selection') { const values = selectionOptionValues(rec.selection); if (values.length === 0) { @@ -141,7 +174,15 @@ export function assertValidPropertyDefinitionItems(raw: unknown): PropertyItemDe if (values.length !== (rec.selection as unknown[]).length) { throw new Error(`PropertyDefinition.Definition item "${name}" selection options are invalid`); } - normalized.selection = rec.selection as PropertyItemDefinition['selection']; + try { + normalized.selection = normalizeSelectionLabelsForStorage(rec.selection, name); + } catch (err) { + throw new Error( + err instanceof Error + ? err.message + : `PropertyDefinition.Definition item "${name}" selection options are invalid` + ); + } } if (Object.prototype.hasOwnProperty.call(rec, 'default') && !propertyValueMatchesType(normalized, rec.default)) { throw new Error(`PropertyDefinition.Definition item "${name}" default does not match type "${type}"`); diff --git a/modules/core/service/orm/model/property_definition_base_model.ts b/modules/core/service/orm/model/property_definition_base_model.ts index 84b7cb1f4..a142c2f97 100644 --- a/modules/core/service/orm/model/property_definition_base_model.ts +++ b/modules/core/service/orm/model/property_definition_base_model.ts @@ -3,6 +3,7 @@ import { Field } from '../decorator/field'; import { raiseDomainError } from '@/core/service/error'; +import { _lt } from '@/core/service/i18n_binder'; import { MetadataStorage } from '../metadata/storage'; import BaseModel from './model'; import { registerLogicalModelName } from './logical_model_registry'; @@ -181,22 +182,61 @@ async function ensureDefinitionUniqueIndex(ctor: ModelCtor> | undefined = undefined>( diff --git a/modules/core/service/orm/model/property_definition_fields_get_help.test.ts b/modules/core/service/orm/model/property_definition_fields_get_help.test.ts new file mode 100644 index 000000000..72f04c547 --- /dev/null +++ b/modules/core/service/orm/model/property_definition_fields_get_help.test.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { Model } from '../decorator/model'; +import PropertyDefinitionBaseModel from './property_definition_base_model'; + +@Model('PropertyDefinition', { application: 'pdhelp' }) +class PdHelpPropertyDefinition extends PropertyDefinitionBaseModel {} + +test('PropertyDefinition FieldsGet returns help for scope fields', async () => { + const meta = await (PdHelpPropertyDefinition as any).FieldsGet( + ['TargetModel', 'PropertiesField', 'ContainerModel', 'ContainerId'], + ['help', 'helpText', 'string'], + ); + expect(String(meta.TargetModel?.help || '')).toMatch(/properties column/i); + expect(String(meta.PropertiesField?.help || '')).toMatch(/properties field/i); + expect(String(meta.ContainerModel?.help || '')).toMatch(/app-level scope/i); + expect(String(meta.ContainerId?.help || '')).toMatch(/Container Model/i); + expect(meta.ContainerModel?.helpText?.src).toBeTruthy(); +}); diff --git a/modules/core/service/orm/utils/relation_patch_diff.test.ts b/modules/core/service/orm/utils/relation_patch_diff.test.ts index 9afbe4673..89dcaa086 100644 --- a/modules/core/service/orm/utils/relation_patch_diff.test.ts +++ b/modules/core/service/orm/utils/relation_patch_diff.test.ts @@ -378,6 +378,27 @@ test('buildUpdatePayload treats jsonobject via resolvedColumnType as whole-map r expect(payload.Meta).toEqual({ a: 9, b: 2 }); }); +test('buildUpdatePayload keeps jsonobject array columns as arrays (not o2m / numeric-key maps)', () => { + const original = { + Definition: [ + { name: 'external_code', type: 'char', string: { en_US: 'External Code', zh_CN: '外部编码' } }, + { name: 'is_vip', type: 'boolean', string: { en_US: 'VIP', zh_CN: 'VIP' } }, + ], + }; + const current = { + Definition: [ + { name: 'external_code', type: 'char', string: { en_US: 'External Code', zh_CN: '外部' } }, + { name: 'is_vip', type: 'boolean', string: { en_US: 'VIP', zh_CN: 'VIP' } }, + ], + }; + const payload = buildUpdatePayload(original, current, { + Definition: { type: 'jsonobject' }, + }); + expect(Array.isArray(payload.Definition)).toBe(true); + expect(payload.Definition).toEqual(current.Definition); + expect((payload.Definition as object | undefined)?.constructor).toBe(Array); +}); + test('buildUpdatePayload clears whole-map column when current is null', () => { const payload = buildUpdatePayload( { Properties: { a: 1 } }, diff --git a/modules/core/utils/diff.ts b/modules/core/utils/diff.ts index 20901e36e..a1facf8db 100644 --- a/modules/core/utils/diff.ts +++ b/modules/core/utils/diff.ts @@ -32,6 +32,9 @@ function isWholeMapReplaceFieldMeta(fm: { type?: string; resolvedColumnType?: st function mergePlainMaps(original: unknown, current: unknown): Plain | unknown { if (current === null || current === undefined) return current; + // jsonobject may store a JSON array (e.g. PropertyDefinition.Definition). + // Spreading arrays into a plain object yields {0:…,1:…} and breaks BE Array.isArray checks. + if (Array.isArray(current) || Array.isArray(original)) return current; const oMap = asObjectRecord(original); const cMap = asObjectRecord(current); if (oMap && cMap) return { ...oMap, ...cMap }; @@ -409,6 +412,8 @@ export function buildUpdatePayload(original: Plain, current: Plain, fieldsMeta?: for (const key of allKeys) { if (processed.has(key) || readonlyKeys.has(key)) continue; if (fieldsMeta?.[key]?.relation) continue; + // jsonobject / properties may hold JSON arrays; never treat them as o2m. + if (isWholeMapReplaceFieldMeta(fieldsMeta?.[key])) continue; const oVal = original?.[key]; const cVal = current?.[key]; diff --git a/modules/sandbox/data/bootstrap.json b/modules/sandbox/data/bootstrap.json index c3093dc87..21ab9b947 100644 --- a/modules/sandbox/data/bootstrap.json +++ b/modules/sandbox/data/bootstrap.json @@ -72,63 +72,70 @@ { "name": "priority", "type": "selection", - "string": "Priority", + "string": { "en_US": "Priority", "zh_CN": "优先级" }, + "help": { + "en_US": "How urgently this record should be handled.", + "zh_CN": "该记录需要处理的紧急程度。" + }, "selection": [ - [ - "low", - "Low" - ], - [ - "normal", - "Normal" - ], - [ - "high", - "High" - ] + ["low", { "en_US": "Low", "zh_CN": "低" }], + ["normal", { "en_US": "Normal", "zh_CN": "普通" }], + ["high", { "en_US": "High", "zh_CN": "高" }] ], "default": "normal" }, { "name": "external_code", "type": "char", - "string": "External Code" + "string": { "en_US": "External Code", "zh_CN": "外部编码" }, + "help": { + "en_US": "Short code used to reference this record in external systems.", + "zh_CN": "用于在外部系统中引用本记录的短编码。" + } }, { "name": "is_vip", "type": "boolean", - "string": "VIP", + "string": { "en_US": "VIP", "zh_CN": "VIP" }, + "help": { + "en_US": "Mark the record as VIP for preferential handling.", + "zh_CN": "标记为 VIP,以便优先处理。" + }, "default": false }, { "name": "qty_hint", "type": "integer", - "string": "Qty Hint" + "string": { "en_US": "Qty Hint", "zh_CN": "数量提示" } }, { "name": "score", "type": "float", - "string": "Score" + "string": { "en_US": "Score", "zh_CN": "分数" } }, { "name": "note", "type": "text", - "string": "Note" + "string": { "en_US": "Note", "zh_CN": "备注" } }, { "name": "due_date", "type": "date", - "string": "Due Date" + "string": { "en_US": "Due Date", "zh_CN": "截止日期" } }, { "name": "reviewed_at", "type": "datetime", - "string": "Reviewed At" + "string": { "en_US": "Reviewed At", "zh_CN": "审核时间" } }, { "name": "locked_code", "type": "char", - "string": "Locked Code", + "string": { "en_US": "Locked Code", "zh_CN": "锁定编码" }, + "help": { + "en_US": "Read-only lock token assigned when the record is sealed.", + "zh_CN": "记录锁定后分配的只读锁定码。" + }, "readonly": true, "default": "RO" } diff --git a/modules/sandbox/i18n/sandbox.pot b/modules/sandbox/i18n/sandbox.pot index 81cd084b5..5871007d3 100644 --- a/modules/sandbox/i18n/sandbox.pot +++ b/modules/sandbox/i18n/sandbox.pot @@ -332,32 +332,32 @@ msgctxt "sandbox.model.SandboxRecord.fields" msgid "Translatable varchar; use the translation action on the field." msgstr "" -#: service/models/sandbox_record_category.ts:28 +#: service/models/sandbox_record_category.ts:31 msgctxt "sandbox.model.SandboxRecordCategory.fields" msgid "Category" msgstr "" -#: service/models/sandbox_record_category.ts:18 +#: service/models/sandbox_record_category.ts:21 msgctxt "sandbox.model.SandboxRecordCategory.fields" msgid "Record" msgstr "" -#: service/models/sandbox_record_tag.ts:18 +#: service/models/sandbox_record_tag.ts:21 msgctxt "sandbox.model.SandboxRecordTag.fields" msgid "Record" msgstr "" -#: service/models/sandbox_record_tag.ts:29 +#: service/models/sandbox_record_tag.ts:32 msgctxt "sandbox.model.SandboxRecordTag.fields" msgid "Tag" msgstr "" -#: service/models/sandbox_record_tag_chip.ts:18 +#: service/models/sandbox_record_tag_chip.ts:21 msgctxt "sandbox.model.SandboxRecordTagChip.fields" msgid "Record" msgstr "" -#: service/models/sandbox_record_tag_chip.ts:29 +#: service/models/sandbox_record_tag_chip.ts:32 msgctxt "sandbox.model.SandboxRecordTagChip.fields" msgid "Tag" msgstr "" @@ -377,16 +377,31 @@ msgctxt "sandbox.model.SandboxTag.fields" msgid "Name" msgstr "" +#: web/menu/titles.ts:10 +msgctxt "web/menu/titles" +msgid "Configuration" +msgstr "" + #: web/menu/titles.ts:9 msgctxt "web/menu/titles" msgid "Field Kitchen Sink" msgstr "" +#: web/menu/titles.ts:11 +msgctxt "web/menu/titles" +msgid "Record Properties" +msgstr "" + #: web/menu/titles.ts:8 msgctxt "web/menu/titles" msgid "Sandbox" msgstr "" +#: web/pages/SandboxPropertyDefinitionList.vue:11 +msgctxt "web/pages/SandboxPropertyDefinitionList" +msgid "Record Properties" +msgstr "" + #: web/pages/SandboxRecordChart.vue:12 msgctxt "web/pages/SandboxRecordChart" msgid "Sandbox Record Chart" @@ -402,6 +417,11 @@ msgctxt "web/pages/SandboxRecordList" msgid "Field Kitchen Sink" msgstr "" +#: web/route/routes.ts:156 +msgctxt "web/route/routes" +msgid "New Property Definition" +msgstr "" + #: web/route/routes.ts:82 msgctxt "web/route/routes" msgid "New Sandbox Record" @@ -412,6 +432,16 @@ msgctxt "web/route/routes" msgid "New Sandbox Tag" msgstr "" +#: web/route/routes.ts:170 +msgctxt "web/route/routes" +msgid "Property Definition" +msgstr "" + +#: web/route/routes.ts:142 +msgctxt "web/route/routes" +msgid "Record Properties" +msgstr "" + #: web/route/routes.ts:61 msgctxt "web/route/routes" msgid "Sandbox Record Chart" @@ -487,6 +517,91 @@ msgctxt "web/views/SandboxLinesVirtTools" msgid "Short Lines stay content-sized. Open VIRT-LINES or fill rows to exercise capped table / kanban virtualize (not saved until you Save)." msgstr "" +#: web/views/SandboxPropertyDefinitionFormView.vue:21 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Container Id" +msgstr "" + +#: web/views/SandboxPropertyDefinitionFormView.vue:18 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Container Model" +msgstr "" + +#: web/views/sandbox_property_definition_actions.ts:10 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Copy Property Definition" +msgstr "" + +#: web/views/sandbox_property_definition_actions.ts:10 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Create Property Definition" +msgstr "" + +#: web/views/SandboxPropertyDefinitionFormView.vue:26 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Definition" +msgstr "" + +#: web/views/sandbox_property_definition_actions.ts:10 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Delete Property Definition" +msgstr "" + +#: web/views/sandbox_property_definition_actions.ts:10 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Edit Property Definition" +msgstr "" + +#: web/views/SandboxPropertyDefinitionFormView.vue:15 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Properties Field" +msgstr "" + +#: web/views/sandbox_property_definition_actions.ts:11 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Property Definition" +msgstr "" + +#: web/views/SandboxPropertyDefinitionFormView.vue:21 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Required" +msgstr "" + +#: web/views/SandboxPropertyDefinitionFormView.vue:9 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Scope" +msgstr "" + +#: web/views/SandboxPropertyDefinitionFormView.vue:12 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Target Model" +msgstr "" + +#: web/views/SandboxPropertyDefinitionListView.vue:11 +msgctxt "web/views/SandboxPropertyDefinitionListView" +msgid "Container Id" +msgstr "" + +#: web/views/SandboxPropertyDefinitionListView.vue:10 +msgctxt "web/views/SandboxPropertyDefinitionListView" +msgid "Container Model" +msgstr "" + +#: web/views/SandboxPropertyDefinitionListView.vue:12 +msgctxt "web/views/SandboxPropertyDefinitionListView" +msgid "Definition" +msgstr "" + +#: web/views/SandboxPropertyDefinitionListView.vue:9 +msgctxt "web/views/SandboxPropertyDefinitionListView" +msgid "Properties Field" +msgstr "" + +#: web/views/SandboxPropertyDefinitionListView.vue:8 +msgctxt "web/views/SandboxPropertyDefinitionListView" +msgid "Target Model" +msgstr "" + #: web/views/SandboxRecordChartView.vue:43 msgctxt "web/views/SandboxRecordChartView" msgid "Chart View" diff --git a/modules/sandbox/i18n/zh_CN.po b/modules/sandbox/i18n/zh_CN.po index e37f32ad7..82ec61e36 100644 --- a/modules/sandbox/i18n/zh_CN.po +++ b/modules/sandbox/i18n/zh_CN.po @@ -326,32 +326,32 @@ msgctxt "sandbox.model.SandboxRecord.fields" msgid "Translatable varchar; use the translation action on the field." msgstr "可翻译 varchar;请使用字段上的翻译操作。" -#: service/models/sandbox_record_category.ts:28 +#: service/models/sandbox_record_category.ts:31 msgctxt "sandbox.model.SandboxRecordCategory.fields" msgid "Category" msgstr "分类" -#: service/models/sandbox_record_category.ts:18 +#: service/models/sandbox_record_category.ts:21 msgctxt "sandbox.model.SandboxRecordCategory.fields" msgid "Record" msgstr "记录" -#: service/models/sandbox_record_tag.ts:18 +#: service/models/sandbox_record_tag.ts:21 msgctxt "sandbox.model.SandboxRecordTag.fields" msgid "Record" msgstr "记录" -#: service/models/sandbox_record_tag.ts:29 +#: service/models/sandbox_record_tag.ts:32 msgctxt "sandbox.model.SandboxRecordTag.fields" msgid "Tag" msgstr "标签" -#: service/models/sandbox_record_tag_chip.ts:18 +#: service/models/sandbox_record_tag_chip.ts:21 msgctxt "sandbox.model.SandboxRecordTagChip.fields" msgid "Record" msgstr "记录" -#: service/models/sandbox_record_tag_chip.ts:29 +#: service/models/sandbox_record_tag_chip.ts:32 msgctxt "sandbox.model.SandboxRecordTagChip.fields" msgid "Tag" msgstr "标签" @@ -371,16 +371,31 @@ msgctxt "sandbox.model.SandboxTag.fields" msgid "Name" msgstr "名称" +#: web/menu/titles.ts:10 +msgctxt "web/menu/titles" +msgid "Configuration" +msgstr "配置" + #: web/menu/titles.ts:9 msgctxt "web/menu/titles" msgid "Field Kitchen Sink" msgstr "字段试验场" +#: web/menu/titles.ts:11 +msgctxt "web/menu/titles" +msgid "Record Properties" +msgstr "记录属性" + #: web/menu/titles.ts:8 msgctxt "web/menu/titles" msgid "Sandbox" msgstr "沙箱" +#: web/pages/SandboxPropertyDefinitionList.vue:11 +msgctxt "web/pages/SandboxPropertyDefinitionList" +msgid "Record Properties" +msgstr "记录属性" + #: web/pages/SandboxRecordChart.vue:12 msgctxt "web/pages/SandboxRecordChart" msgid "Sandbox Record Chart" @@ -396,6 +411,11 @@ msgctxt "web/pages/SandboxRecordList" msgid "Field Kitchen Sink" msgstr "字段试验场" +#: web/route/routes.ts:156 +msgctxt "web/route/routes" +msgid "New Property Definition" +msgstr "新建属性定义" + #: web/route/routes.ts:82 msgctxt "web/route/routes" msgid "New Sandbox Record" @@ -406,6 +426,16 @@ msgctxt "web/route/routes" msgid "New Sandbox Tag" msgstr "新建沙箱标签" +#: web/route/routes.ts:170 +msgctxt "web/route/routes" +msgid "Property Definition" +msgstr "属性定义" + +#: web/route/routes.ts:142 +msgctxt "web/route/routes" +msgid "Record Properties" +msgstr "记录属性" + #: web/route/routes.ts:61 msgctxt "web/route/routes" msgid "Sandbox Record Chart" @@ -481,6 +511,91 @@ msgctxt "web/views/SandboxLinesVirtTools" msgid "Short Lines stay content-sized. Open VIRT-LINES or fill rows to exercise capped table / kanban virtualize (not saved until you Save)." msgstr "短 Lines 保持内容高度。打开 VIRT-LINES 或填充行以练习封顶表格 / kanban 虚拟化(保存前不落库)。" +#: web/views/SandboxPropertyDefinitionFormView.vue:21 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Container Id" +msgstr "容器 ID" + +#: web/views/SandboxPropertyDefinitionFormView.vue:18 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Container Model" +msgstr "容器模型" + +#: web/views/sandbox_property_definition_actions.ts:10 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Copy Property Definition" +msgstr "复制属性定义" + +#: web/views/sandbox_property_definition_actions.ts:10 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Create Property Definition" +msgstr "创建属性定义" + +#: web/views/SandboxPropertyDefinitionFormView.vue:26 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Definition" +msgstr "定义" + +#: web/views/sandbox_property_definition_actions.ts:10 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Delete Property Definition" +msgstr "删除属性定义" + +#: web/views/sandbox_property_definition_actions.ts:10 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Edit Property Definition" +msgstr "编辑属性定义" + +#: web/views/SandboxPropertyDefinitionFormView.vue:15 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Properties Field" +msgstr "属性字段" + +#: web/views/sandbox_property_definition_actions.ts:11 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Property Definition" +msgstr "属性定义" + +#: web/views/SandboxPropertyDefinitionFormView.vue:21 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Required" +msgstr "必填" + +#: web/views/SandboxPropertyDefinitionFormView.vue:9 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Scope" +msgstr "作用域" + +#: web/views/SandboxPropertyDefinitionFormView.vue:12 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Target Model" +msgstr "目标模型" + +#: web/views/SandboxPropertyDefinitionListView.vue:11 +msgctxt "web/views/SandboxPropertyDefinitionListView" +msgid "Container Id" +msgstr "容器 ID" + +#: web/views/SandboxPropertyDefinitionListView.vue:10 +msgctxt "web/views/SandboxPropertyDefinitionListView" +msgid "Container Model" +msgstr "容器模型" + +#: web/views/SandboxPropertyDefinitionListView.vue:12 +msgctxt "web/views/SandboxPropertyDefinitionListView" +msgid "Definition" +msgstr "定义" + +#: web/views/SandboxPropertyDefinitionListView.vue:9 +msgctxt "web/views/SandboxPropertyDefinitionListView" +msgid "Properties Field" +msgstr "属性字段" + +#: web/views/SandboxPropertyDefinitionListView.vue:8 +msgctxt "web/views/SandboxPropertyDefinitionListView" +msgid "Target Model" +msgstr "目标模型" + #: web/views/SandboxRecordChartView.vue:43 msgctxt "web/views/SandboxRecordChartView" msgid "Chart View" @@ -834,10 +949,38 @@ msgstr "标签" #~ msgid "Per-company varchar; open Company Values from the field chrome." #~ msgstr "按公司存储的 varchar;可从字段控件打开公司值。" +#~ msgctxt "web/views/SandboxPropertyDefinitionFormView" +#~ msgid "Container scope" +#~ msgstr "容器作用域" + +#~ msgctxt "web/views/SandboxPropertyDefinitionFormView" +#~ msgid "Leave empty for app-level scope; set to the parent model short name for record-scoped definitions." +#~ msgstr "应用级作用域请留空;记录级定义则填写父模型短名。" + +#~ msgctxt "web/views/SandboxPropertyDefinitionFormView" +#~ msgid "Leave empty for app-level scope; set with Container Model to bind this definition to one parent record." +#~ msgstr "应用级作用域请留空;与「容器模型」一起填写以绑定到一条父记录。" + +#~ msgctxt "web/views/SandboxPropertyDefinitionFormView" +#~ msgid "Model that owns the properties column (e.g. SandboxRecord)." +#~ msgstr "拥有属性列的业务模型(例如 SandboxRecord)。" + +#~ msgctxt "web/views/SandboxPropertyDefinitionFormView" +#~ msgid "Name of the properties field on the target model (e.g. Properties)." +#~ msgstr "目标模型上的属性字段名(例如 Properties)。" + +#~ msgctxt "web/views/SandboxPropertyDefinitionFormView" +#~ msgid "Optional parent container for record-scoped definitions. Leave empty for app-level properties (e.g. SandboxRecord.Properties)." +#~ msgstr "可选的父级容器,用于记录级定义。应用级属性(如 SandboxRecord.Properties)请留空。" + #~ msgctxt "web/views/SandboxRecordFormView" #~ msgid "Add Line" #~ msgstr "添加明细" +#~ msgctxt "web/views/SandboxRecordPropertiesDefinitionView" +#~ msgid "Edit the app-level schema for SandboxRecord.Properties. Labels support multiple languages; use Translate next to each label (and selection options) before Save." +#~ msgstr "编辑 SandboxRecord.Properties 的应用级 schema。标签支持多语言;请在保存前使用每项标签(及选项)旁的「翻译」。" + #~ msgctxt "web/views/SandboxTagListView" #~ msgid "Copy Sandbox Tag" #~ msgstr "复制沙箱标签" diff --git a/modules/sandbox/web/__tests__/lt_titles.test.ts b/modules/sandbox/web/__tests__/lt_titles.test.ts index 5d8e46017..9a6a0bbc2 100644 --- a/modules/sandbox/web/__tests__/lt_titles.test.ts +++ b/modules/sandbox/web/__tests__/lt_titles.test.ts @@ -2,15 +2,25 @@ // SPDX-License-Identifier: Apache-2.0 import { createTranslate } from '@/core/service/i18n'; -import { sandboxRecordsMenuTitle, sandboxRootMenuTitle } from '../menu/titles'; +import { + sandboxConfigurationMenuTitle, + sandboxRecordPropertiesMenuTitle, + sandboxRecordsMenuTitle, + sandboxRootMenuTitle, +} from '../menu/titles'; import { sandboxRoutes } from '../route/routes'; const listRouteTitle = createTranslate('sandbox', { scope: 'web/route/routes' })._lt('Sandbox Record List'); +const recordPropertiesRouteTitle = createTranslate('sandbox', { scope: 'web/route/routes' })._lt( + 'Record Properties', +); test('sandbox menu titles: titles.ts registers shared TermReferences', () => { expect(sandboxRootMenuTitle.src).toBe('Sandbox'); expect(sandboxRootMenuTitle.module).toBe('sandbox'); expect(sandboxRecordsMenuTitle.src).toBe('Field Kitchen Sink'); + expect(sandboxConfigurationMenuTitle.src).toBe('Configuration'); + expect(sandboxRecordPropertiesMenuTitle.src).toBe('Record Properties'); }); test('sandbox route titles: list route pins pageTitleText', () => { @@ -19,3 +29,10 @@ test('sandbox route titles: list route pins pageTitleText', () => { expect(list.meta?.pageTitle).toBe('Sandbox Record List'); expect(list.meta?.pageTitleText).toEqual(listRouteTitle); }); + +test('sandbox route titles: PropertyDefinition list pins pageTitleText', () => { + const route = sandboxRoutes.find(r => r.name === 'SandboxPropertyDefinitionList') as any; + expect(route).toBeTruthy(); + expect(route.meta?.pageTitle).toBe('Record Properties'); + expect(route.meta?.pageTitleText).toEqual(recordPropertiesRouteTitle); +}); diff --git a/modules/sandbox/web/__tests__/resourceWiring.test.ts b/modules/sandbox/web/__tests__/resourceWiring.test.ts index d21b30eff..ed6778dbd 100644 --- a/modules/sandbox/web/__tests__/resourceWiring.test.ts +++ b/modules/sandbox/web/__tests__/resourceWiring.test.ts @@ -85,3 +85,13 @@ test('sandbox resource wiring: list/kanban/chart routes expose view switcher met viewPaths, }); }); + +test('sandbox resource wiring: PropertyDefinition List→Form routes', () => { + const byName = Object.fromEntries(sandboxRoutes.map(route => [String(route.name), route])); + expect(byName.SandboxPropertyDefinitionList?.path).toBe('sandbox/configuration/record-properties'); + expect(byName.SandboxPropertyDefinitionCreate?.path).toBe('sandbox/configuration/record-properties/new'); + expect(byName.SandboxPropertyDefinitionDetail?.path).toBe('sandbox/configuration/record-properties/:id'); + expect(byName.SandboxPropertyDefinitionList?.meta).toMatchObject({ requiresAuth: true }); + expect(byName.SandboxPropertyDefinitionCreate?.meta).toMatchObject({ requiresAuth: true }); + expect(byName.SandboxPropertyDefinitionDetail?.meta).toMatchObject({ requiresAuth: true }); +}); diff --git a/modules/sandbox/web/menu/menus.ts b/modules/sandbox/web/menu/menus.ts index 0427fe48c..2376bad61 100644 --- a/modules/sandbox/web/menu/menus.ts +++ b/modules/sandbox/web/menu/menus.ts @@ -4,7 +4,12 @@ import type { MenuItem } from '@/core/web/menu'; import { defineMenu } from '@/core/web/resource'; import { FlaskConical } from 'lucide-vue-next'; -import { sandboxRecordsMenuTitle, sandboxRootMenuTitle } from './titles'; +import { + sandboxConfigurationMenuTitle, + sandboxRecordPropertiesMenuTitle, + sandboxRecordsMenuTitle, + sandboxRootMenuTitle, +} from './titles'; export const sandboxMenus: MenuItem[] = [ defineMenu('sandbox.menu.root', { @@ -18,6 +23,18 @@ export const sandboxMenus: MenuItem[] = [ path: '/sandbox/records', sequence: 10, }), + // Pathless folder: app configuration (properties schema, future settings). + defineMenu('sandbox.menu.configuration', { + title: sandboxConfigurationMenuTitle, + sequence: 90, + children: [ + defineMenu('sandbox.menu.record_properties', { + title: sandboxRecordPropertiesMenuTitle, + path: '/sandbox/configuration/record-properties', + sequence: 10, + }), + ], + }), ], }), ]; diff --git a/modules/sandbox/web/menu/titles.ts b/modules/sandbox/web/menu/titles.ts index 6e4f895fa..af3f4edba 100644 --- a/modules/sandbox/web/menu/titles.ts +++ b/modules/sandbox/web/menu/titles.ts @@ -7,3 +7,5 @@ const { _lt } = createTranslate('sandbox', { scope: 'web/menu/titles' }); export const sandboxRootMenuTitle = _lt('Sandbox'); export const sandboxRecordsMenuTitle = _lt('Field Kitchen Sink'); +export const sandboxConfigurationMenuTitle = _lt('Configuration'); +export const sandboxRecordPropertiesMenuTitle = _lt('Record Properties'); diff --git a/modules/sandbox/web/pages/SandboxPropertyDefinition.vue b/modules/sandbox/web/pages/SandboxPropertyDefinition.vue new file mode 100644 index 000000000..e11727f48 --- /dev/null +++ b/modules/sandbox/web/pages/SandboxPropertyDefinition.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/modules/sandbox/web/pages/SandboxPropertyDefinitionList.vue b/modules/sandbox/web/pages/SandboxPropertyDefinitionList.vue new file mode 100644 index 000000000..75d742945 --- /dev/null +++ b/modules/sandbox/web/pages/SandboxPropertyDefinitionList.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/modules/sandbox/web/pages/page_mount_coverage.test.ts b/modules/sandbox/web/pages/page_mount_coverage.test.ts index c615ada48..0feb350bb 100644 --- a/modules/sandbox/web/pages/page_mount_coverage.test.ts +++ b/modules/sandbox/web/pages/page_mount_coverage.test.ts @@ -8,6 +8,8 @@ import SandboxRecordList from './SandboxRecordList.vue'; import SandboxRecordKanban from './SandboxRecordKanban.vue'; import SandboxRecordChart from './SandboxRecordChart.vue'; import SandboxTag from './SandboxTag.vue'; +import SandboxPropertyDefinitionList from './SandboxPropertyDefinitionList.vue'; +import SandboxPropertyDefinition from './SandboxPropertyDefinition.vue'; import { sandboxMenus } from '../menu/menus'; const pages: Array<[string, any, string]> = [ @@ -16,6 +18,8 @@ const pages: Array<[string, any, string]> = [ ['SandboxRecordChart', SandboxRecordChart, '/sandbox/records/chart'], ['SandboxRecord', SandboxRecord, '/sandbox/records/1'], ['SandboxTag', SandboxTag, '/sandbox/tags/new'], + ['SandboxPropertyDefinitionList', SandboxPropertyDefinitionList, '/sandbox/configuration/record-properties'], + ['SandboxPropertyDefinition', SandboxPropertyDefinition, '/sandbox/configuration/record-properties/new'], ]; test('sandbox page mount: every ChoyPage host mounts under choysumMount', async () => { @@ -45,4 +49,9 @@ test('sandbox menus: root declares an icon and leaves stay text-only', () => { expect(sandboxMenus.length).toBeGreaterThan(0); expect(sandboxMenus[0]!.icon).toBeTruthy(); expect(sandboxMenus[0]!.children?.every(child => child.icon == null)).toBe(true); + const configuration = sandboxMenus[0]!.children?.find(child => child.id === 'sandbox.menu.configuration'); + expect(configuration?.path).toBeFalsy(); + expect(configuration?.children?.some(child => child.path === '/sandbox/configuration/record-properties')).toBe( + true, + ); }); diff --git a/modules/sandbox/web/route/routes.ts b/modules/sandbox/web/route/routes.ts index 6459bcd66..0682b0edc 100644 --- a/modules/sandbox/web/route/routes.ts +++ b/modules/sandbox/web/route/routes.ts @@ -135,4 +135,48 @@ export const sandboxRoutes: RouteRecordRaw[] = [ ], meta: { requiresAuth: true }, }), + // PropertyDefinition List → Form (Definition edited via ChoyPropertiesDefinitionField). + // Write ACL comes from platform PropertyDefinition logical packs (auth RMA/RFR). + defineRoute('sandbox.route.property_definition_list', { + sequence: 80, + title: _lt('Record Properties'), + path: 'sandbox/configuration/record-properties', + name: 'SandboxPropertyDefinitionList', + component: () => import('../pages/SandboxPropertyDefinitionList.vue'), + actions: [ + 'sandbox.action.property_definition_create', + 'sandbox.action.property_definition_edit', + 'sandbox.action.property_definition_delete', + 'sandbox.action.property_definition_copy', + ], + meta: { requiresAuth: true }, + }), + defineRoute('sandbox.route.property_definition_create', { + sequence: 81, + title: _lt('New Property Definition'), + path: 'sandbox/configuration/record-properties/new', + name: 'SandboxPropertyDefinitionCreate', + component: () => import('../pages/SandboxPropertyDefinition.vue'), + actions: [ + 'sandbox.action.property_definition_create', + 'sandbox.action.property_definition_edit', + 'sandbox.action.property_definition_delete', + 'sandbox.action.property_definition_copy', + ], + meta: { requiresAuth: true }, + }), + defineRoute('sandbox.route.property_definition_detail', { + sequence: 82, + title: _lt('Property Definition'), + path: 'sandbox/configuration/record-properties/:id', + name: 'SandboxPropertyDefinitionDetail', + component: () => import('../pages/SandboxPropertyDefinition.vue'), + actions: [ + 'sandbox.action.property_definition_create', + 'sandbox.action.property_definition_edit', + 'sandbox.action.property_definition_delete', + 'sandbox.action.property_definition_copy', + ], + meta: { requiresAuth: true }, + }), ]; diff --git a/modules/sandbox/web/views/SandboxPropertyDefinitionFormView.vue b/modules/sandbox/web/views/SandboxPropertyDefinitionFormView.vue new file mode 100644 index 000000000..ba969cdc4 --- /dev/null +++ b/modules/sandbox/web/views/SandboxPropertyDefinitionFormView.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/modules/sandbox/web/views/SandboxPropertyDefinitionListView.vue b/modules/sandbox/web/views/SandboxPropertyDefinitionListView.vue new file mode 100644 index 000000000..0980e4f2a --- /dev/null +++ b/modules/sandbox/web/views/SandboxPropertyDefinitionListView.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/modules/sandbox/web/views/sandbox_property_definition_actions.ts b/modules/sandbox/web/views/sandbox_property_definition_actions.ts new file mode 100644 index 000000000..bfa39bbe8 --- /dev/null +++ b/modules/sandbox/web/views/sandbox_property_definition_actions.ts @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { defineModelActions } from '@/core/web/resource'; +import { createTranslate } from '@/web/web/i18n'; + +const { _lt } = createTranslate('sandbox', { scope: 'web/views/SandboxPropertyDefinitionFormView' }); + +/** Standard CRUD action ids for sandbox.PropertyDefinition (C2-injected logical model). */ +export const sandboxPropertyDefinitionActions = defineModelActions('sandbox.PropertyDefinition', { + entityTitle: _lt('Property Definition'), +}); diff --git a/modules/web/i18n/web.pot b/modules/web/i18n/web.pot index 64661e8dc..d4a21e8cf 100644 --- a/modules/web/i18n/web.pot +++ b/modules/web/i18n/web.pot @@ -274,22 +274,22 @@ msgctxt "web/components/field/ChoyDecimalField" msgid "Total digits must not exceed %s" msgstr "" -#: web/components/field/ChoyFieldBase.vue:228 +#: web/components/field/ChoyFieldBase.vue:247 msgctxt "web/components/field/ChoyFieldBase" msgid "Company values" msgstr "" -#: web/components/field/ChoyFieldBase.vue:228 +#: web/components/field/ChoyFieldBase.vue:247 msgctxt "web/components/field/ChoyFieldBase" msgid "Company values: %s" msgstr "" -#: web/components/field/ChoyFieldBase.vue:221 +#: web/components/field/ChoyFieldBase.vue:240 msgctxt "web/components/field/ChoyFieldBase" msgid "Translate field" msgstr "" -#: web/components/field/ChoyFieldBase.vue:221 +#: web/components/field/ChoyFieldBase.vue:240 msgctxt "web/components/field/ChoyFieldBase" msgid "Translate: %s" msgstr "" @@ -780,45 +780,45 @@ msgctxt "web/components/field/ChoyOneToManyField" msgid "Delete" msgstr "" -#: web/components/field/ChoyOneToManyKanbanField.vue:143 +#: web/components/field/ChoyOneToManyKanbanField.vue:189 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Add a line…" msgstr "" -#: web/components/field/ChoyOneToManyKanbanField.vue:384 +#: web/components/field/ChoyOneToManyKanbanField.vue:438 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Are you sure you want to delete this record? This action cannot be undone." msgstr "" -#: web/components/field/ChoyOneToManyKanbanField.vue:389 +#: web/components/field/ChoyOneToManyKanbanField.vue:443 #: web/components/field/ChoyOneToManyKanbanField.vue:93 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Cancel" msgstr "" -#: web/components/field/ChoyOneToManyKanbanField.vue:385 +#: web/components/field/ChoyOneToManyKanbanField.vue:439 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Confirm delete" msgstr "" #: web/components/field/ChoyOneToManyKanbanField.vue:20 -#: web/components/field/ChoyOneToManyKanbanField.vue:388 +#: web/components/field/ChoyOneToManyKanbanField.vue:442 #: web/components/field/ChoyOneToManyKanbanField.vue:90 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Delete" msgstr "" -#: web/components/field/ChoyOneToManyKanbanField.vue:146 +#: web/components/field/ChoyOneToManyKanbanField.vue:192 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Edit record" msgstr "" -#: web/components/field/ChoyOneToManyKanbanField.vue:145 +#: web/components/field/ChoyOneToManyKanbanField.vue:191 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "New record" msgstr "" -#: web/components/field/ChoyOneToManyKanbanField.vue:144 +#: web/components/field/ChoyOneToManyKanbanField.vue:190 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "No data" msgstr "" @@ -838,89 +838,224 @@ msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Save" msgstr "" -#: web/components/field/ChoyOneToManyKanbanField.vue:323 +#: web/components/field/ChoyOneToManyKanbanField.vue:377 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Untitled" msgstr "" -#: web/components/field/ChoyOneToManyKanbanField.vue:147 +#: web/components/field/ChoyOneToManyKanbanField.vue:193 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "View record" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:8 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "Add property" +#: web/components/field/ChoyPropertiesDefinitionField.vue:369 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "%s properties" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:33 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "Default" +#: web/components/field/ChoyPropertiesDefinitionField.vue:371 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "%s properties (%s, …)" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "Add a line…" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:32 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 +#: web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "Edit" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 +#: web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" msgid "Label" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:23 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 +#: web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" msgid "Name" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:19 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "No properties defined" +#: web/components/field/ChoyPropertiesDefinitionField.vue:365 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "No properties" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:143 -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:89 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "PropertyDefinition store is unavailable" +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 +#: web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "No properties defined" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:36 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "Readonly" +#: web/components/field/ChoyPropertiesDefinitionField.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "This row was changed while editing; please reopen it." msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:41 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 +#: web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" msgid "Remove" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:5 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 +#: web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "Reorder" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 +#: web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "Type" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 +#: web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "View" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:59 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Cancel" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:41 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Default" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:84 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Edit property: %s" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:136 +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:32 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Help" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:126 +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:147 +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:23 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Label" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:8 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Name" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:182 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Name is required" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:84 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "New property" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:82 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Property" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:82 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Property: %s" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:46 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Readonly" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:62 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" msgid "Save" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:38 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "Selection JSON" +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:49 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Selection" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:35 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Translate help" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:26 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Translate label" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:25 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:12 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" msgid "Type" msgstr "" -#: web/components/field/ChoyPropertiesField.vue:239 +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue:47 +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "Add a line…" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue:37 +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "Delete" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "Label" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue:16 +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "No options" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue:21 +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "Reorder" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue:31 +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "Translate option" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue:8 +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "Value" +msgstr "" + +#: web/components/field/ChoyPropertiesField.vue:292 msgctxt "web/components/field/ChoyPropertiesField" msgid "+%s more" msgstr "" -#: web/components/field/ChoyPropertiesField.vue:223 +#: web/components/field/ChoyPropertiesField.vue:276 msgctxt "web/components/field/ChoyPropertiesField" msgid "No" msgstr "" #: web/components/field/ChoyPropertiesField.vue:10 -#: web/components/field/ChoyPropertiesField.vue:46 +#: web/components/field/ChoyPropertiesField.vue:54 msgctxt "web/components/field/ChoyPropertiesField" msgid "No properties defined" msgstr "" -#: web/components/field/ChoyPropertiesField.vue:223 +#: web/components/field/ChoyPropertiesField.vue:276 msgctxt "web/components/field/ChoyPropertiesField" msgid "Yes" msgstr "" @@ -930,7 +1065,7 @@ msgctxt "web/components/field/ChoySelectionField" msgid "Clear" msgstr "" -#: web/components/field/ChoySelectionField.vue:343 +#: web/components/field/ChoySelectionField.vue:355 msgctxt "web/components/field/ChoySelectionField" msgid "Invalid option value: %s" msgstr "" @@ -955,7 +1090,7 @@ msgctxt "web/components/field/ChoySelectionField" msgid "Search…" msgstr "" -#: web/components/field/ChoySelectionField.vue:341 +#: web/components/field/ChoySelectionField.vue:353 msgctxt "web/components/field/ChoySelectionField" msgid "Value must be a string" msgstr "" @@ -995,32 +1130,32 @@ msgctxt "web/components/field/ChoyTimeField" msgid "Select time" msgstr "" -#: web/components/field/ChoyVarcharField.vue:154 +#: web/components/field/ChoyVarcharField.vue:170 msgctxt "web/components/field/ChoyVarcharField" msgid "Copied" msgstr "" -#: web/components/field/ChoyVarcharField.vue:124 +#: web/components/field/ChoyVarcharField.vue:140 msgctxt "web/components/field/ChoyVarcharField" msgid "Copy" msgstr "" -#: web/components/field/ChoyVarcharField.vue:156 +#: web/components/field/ChoyVarcharField.vue:172 msgctxt "web/components/field/ChoyVarcharField" msgid "Copy failed" msgstr "" -#: web/components/field/ChoyVarcharField.vue:280 +#: web/components/field/ChoyVarcharField.vue:296 msgctxt "web/components/field/ChoyVarcharField" msgid "Length must not exceed %s" msgstr "" -#: web/components/field/ChoyVarcharField.vue:274 +#: web/components/field/ChoyVarcharField.vue:290 msgctxt "web/components/field/ChoyVarcharField" msgid "Value is required" msgstr "" -#: web/components/field/ChoyVarcharField.vue:276 +#: web/components/field/ChoyVarcharField.vue:292 msgctxt "web/components/field/ChoyVarcharField" msgid "Value must be a string" msgstr "" @@ -1100,6 +1235,36 @@ msgctxt "web/components/field/FieldTranslationsDialog" msgid "Translations saved" msgstr "" +#: web/components/field/ChoyPropertyLabelTranslationsDialog.vue:12 +msgctxt "web/components/field/PropertyLabelTranslationsDialog" +msgid "Base language (cannot be deleted)" +msgstr "" + +#: web/components/field/ChoyPropertyLabelTranslationsDialog.vue:19 +msgctxt "web/components/field/PropertyLabelTranslationsDialog" +msgid "Cancel" +msgstr "" + +#: web/components/field/ChoyPropertyLabelTranslationsDialog.vue:96 +msgctxt "web/components/field/PropertyLabelTranslationsDialog" +msgid "Failed to load translations" +msgstr "" + +#: web/components/field/ChoyPropertyLabelTranslationsDialog.vue:21 +msgctxt "web/components/field/PropertyLabelTranslationsDialog" +msgid "Save translations" +msgstr "" + +#: web/components/field/ChoyPropertyLabelTranslationsDialog.vue:42 +msgctxt "web/components/field/PropertyLabelTranslationsDialog" +msgid "Translate label" +msgstr "" + +#: web/components/field/ChoyPropertyLabelTranslationsDialog.vue:42 +msgctxt "web/components/field/PropertyLabelTranslationsDialog" +msgid "Translate: %s" +msgstr "" + #: web/components/field/relationCountSummary.ts:14 msgctxt "web/components/field/relationCountSummary@formatRelationCountSummary" msgid "%s record" diff --git a/modules/web/i18n/zh_CN.po b/modules/web/i18n/zh_CN.po index 59cd3f57f..c1230a168 100644 --- a/modules/web/i18n/zh_CN.po +++ b/modules/web/i18n/zh_CN.po @@ -266,22 +266,22 @@ msgctxt "web/components/field/ChoyDecimalField" msgid "Total digits must not exceed %s" msgstr "总位数不得超过 %s" -#: web/components/field/ChoyFieldBase.vue:228 +#: web/components/field/ChoyFieldBase.vue:247 msgctxt "web/components/field/ChoyFieldBase" msgid "Company values" msgstr "公司值" -#: web/components/field/ChoyFieldBase.vue:228 +#: web/components/field/ChoyFieldBase.vue:247 msgctxt "web/components/field/ChoyFieldBase" msgid "Company values: %s" msgstr "公司值:%s" -#: web/components/field/ChoyFieldBase.vue:221 +#: web/components/field/ChoyFieldBase.vue:240 msgctxt "web/components/field/ChoyFieldBase" msgid "Translate field" msgstr "翻译字段" -#: web/components/field/ChoyFieldBase.vue:221 +#: web/components/field/ChoyFieldBase.vue:240 msgctxt "web/components/field/ChoyFieldBase" msgid "Translate: %s" msgstr "翻译:%s" @@ -761,42 +761,42 @@ msgctxt "web/components/field/ChoyOneToManyField" msgid "Delete" msgstr "删除" -#: web/components/field/ChoyOneToManyKanbanField.vue:143 +#: web/components/field/ChoyOneToManyKanbanField.vue:189 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Add a line…" msgstr "添加一行…" -#: web/components/field/ChoyOneToManyKanbanField.vue:384 +#: web/components/field/ChoyOneToManyKanbanField.vue:438 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Are you sure you want to delete this record? This action cannot be undone." msgstr "确定要删除此记录吗?此操作无法撤销。" -#: web/components/field/ChoyOneToManyKanbanField.vue:389 web/components/field/ChoyOneToManyKanbanField.vue:93 +#: web/components/field/ChoyOneToManyKanbanField.vue:443 web/components/field/ChoyOneToManyKanbanField.vue:93 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Cancel" msgstr "取消" -#: web/components/field/ChoyOneToManyKanbanField.vue:385 +#: web/components/field/ChoyOneToManyKanbanField.vue:439 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Confirm delete" msgstr "确认删除" -#: web/components/field/ChoyOneToManyKanbanField.vue:20 web/components/field/ChoyOneToManyKanbanField.vue:388 web/components/field/ChoyOneToManyKanbanField.vue:90 +#: web/components/field/ChoyOneToManyKanbanField.vue:20 web/components/field/ChoyOneToManyKanbanField.vue:442 web/components/field/ChoyOneToManyKanbanField.vue:90 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Delete" msgstr "删除" -#: web/components/field/ChoyOneToManyKanbanField.vue:146 +#: web/components/field/ChoyOneToManyKanbanField.vue:192 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Edit record" msgstr "编辑记录" -#: web/components/field/ChoyOneToManyKanbanField.vue:145 +#: web/components/field/ChoyOneToManyKanbanField.vue:191 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "New record" msgstr "新建记录" -#: web/components/field/ChoyOneToManyKanbanField.vue:144 +#: web/components/field/ChoyOneToManyKanbanField.vue:190 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "No data" msgstr "暂无数据" @@ -816,87 +816,212 @@ msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Save" msgstr "保存" -#: web/components/field/ChoyOneToManyKanbanField.vue:323 +#: web/components/field/ChoyOneToManyKanbanField.vue:377 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Untitled" msgstr "未命名" -#: web/components/field/ChoyOneToManyKanbanField.vue:147 +#: web/components/field/ChoyOneToManyKanbanField.vue:193 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "View record" msgstr "查看记录" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:8 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "Add property" -msgstr "添加属性" +#: web/components/field/ChoyPropertiesDefinitionField.vue:369 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "%s properties" +msgstr "%s 个属性" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:33 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "Default" -msgstr "默认" +#: web/components/field/ChoyPropertiesDefinitionField.vue:371 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "%s properties (%s, …)" +msgstr "%s 个属性(%s,…)" + +#: web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "Add a line…" +msgstr "添加一行…" + +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "Edit" +msgstr "编辑" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:32 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" msgid "Label" msgstr "标签" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:23 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" msgid "Name" msgstr "名称" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:19 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "No properties defined" -msgstr "未定义属性" +#: web/components/field/ChoyPropertiesDefinitionField.vue:365 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "No properties" +msgstr "无属性" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:143 web/components/field/ChoyPropertiesDefinitionEditor.vue:89 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "PropertyDefinition store is unavailable" -msgstr "PropertyDefinition 存储不可用" +#: web/components/field/ChoyPropertiesDefinitionField.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "This row was changed while editing; please reopen it." +msgstr "该行在编辑期间已变更,请重新打开。" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:36 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "Readonly" -msgstr "只读" +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "No properties defined" +msgstr "尚未定义属性" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:41 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" msgid "Remove" msgstr "移除" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:5 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "Reorder" +msgstr "调整顺序" + +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "Type" +msgstr "类型" + +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "View" +msgstr "查看" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:59 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Cancel" +msgstr "取消" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:41 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Default" +msgstr "默认值" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:84 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Edit property: %s" +msgstr "编辑属性:%s" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:136 web/components/field/ChoyPropertiesDefinitionItemDialog.vue:32 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Help" +msgstr "帮助" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:126 web/components/field/ChoyPropertiesDefinitionItemDialog.vue:147 web/components/field/ChoyPropertiesDefinitionItemDialog.vue:23 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Label" +msgstr "标签" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:8 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Name" +msgstr "名称" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:182 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Name is required" +msgstr "名称为必填项" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:84 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "New property" +msgstr "新建属性" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:82 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Property" +msgstr "属性" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:82 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Property: %s" +msgstr "属性:%s" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:46 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Readonly" +msgstr "只读" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:62 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" msgid "Save" msgstr "保存" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:38 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "Selection JSON" -msgstr "选择项 JSON" +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:49 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Selection" +msgstr "选项" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:25 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:35 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Translate help" +msgstr "翻译帮助" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:26 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Translate label" +msgstr "翻译标签" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue:12 +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" msgid "Type" msgstr "类型" -#: web/components/field/ChoyPropertiesField.vue:239 +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue:47 +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "Add a line…" +msgstr "添加一行…" + +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue:37 +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "Delete" +msgstr "删除" + +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "Label" +msgstr "标签" + +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue:16 +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "No options" +msgstr "暂无选项" + +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue:21 +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "Reorder" +msgstr "调整顺序" + +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue:31 +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "Translate option" +msgstr "翻译选项" + +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue:8 +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "Value" +msgstr "值" + +#: web/components/field/ChoyPropertiesField.vue:292 msgctxt "web/components/field/ChoyPropertiesField" msgid "+%s more" msgstr "+%s 项" -#: web/components/field/ChoyPropertiesField.vue:223 +#: web/components/field/ChoyPropertiesField.vue:276 msgctxt "web/components/field/ChoyPropertiesField" msgid "No" msgstr "否" -#: web/components/field/ChoyPropertiesField.vue:10 web/components/field/ChoyPropertiesField.vue:46 +#: web/components/field/ChoyPropertiesField.vue:10 web/components/field/ChoyPropertiesField.vue:54 msgctxt "web/components/field/ChoyPropertiesField" msgid "No properties defined" msgstr "暂无属性定义" -#: web/components/field/ChoyPropertiesField.vue:223 +#: web/components/field/ChoyPropertiesField.vue:276 msgctxt "web/components/field/ChoyPropertiesField" msgid "Yes" msgstr "是" @@ -906,7 +1031,7 @@ msgctxt "web/components/field/ChoySelectionField" msgid "Clear" msgstr "清除" -#: web/components/field/ChoySelectionField.vue:343 +#: web/components/field/ChoySelectionField.vue:355 msgctxt "web/components/field/ChoySelectionField" msgid "Invalid option value: %s" msgstr "无效的选项值:%s" @@ -931,7 +1056,7 @@ msgctxt "web/components/field/ChoySelectionField" msgid "Search…" msgstr "搜索…" -#: web/components/field/ChoySelectionField.vue:341 +#: web/components/field/ChoySelectionField.vue:353 msgctxt "web/components/field/ChoySelectionField" msgid "Value must be a string" msgstr "值必须为字符串" @@ -971,32 +1096,32 @@ msgctxt "web/components/field/ChoyTimeField" msgid "Select time" msgstr "选择时间" -#: web/components/field/ChoyVarcharField.vue:154 +#: web/components/field/ChoyVarcharField.vue:170 msgctxt "web/components/field/ChoyVarcharField" msgid "Copied" msgstr "已复制" -#: web/components/field/ChoyVarcharField.vue:124 +#: web/components/field/ChoyVarcharField.vue:140 msgctxt "web/components/field/ChoyVarcharField" msgid "Copy" msgstr "复制" -#: web/components/field/ChoyVarcharField.vue:156 +#: web/components/field/ChoyVarcharField.vue:172 msgctxt "web/components/field/ChoyVarcharField" msgid "Copy failed" msgstr "复制失败" -#: web/components/field/ChoyVarcharField.vue:280 +#: web/components/field/ChoyVarcharField.vue:296 msgctxt "web/components/field/ChoyVarcharField" msgid "Length must not exceed %s" msgstr "长度不得超过 %s" -#: web/components/field/ChoyVarcharField.vue:274 +#: web/components/field/ChoyVarcharField.vue:290 msgctxt "web/components/field/ChoyVarcharField" msgid "Value is required" msgstr "值为必填项" -#: web/components/field/ChoyVarcharField.vue:276 +#: web/components/field/ChoyVarcharField.vue:292 msgctxt "web/components/field/ChoyVarcharField" msgid "Value must be a string" msgstr "值必须为字符串" @@ -1076,6 +1201,36 @@ msgctxt "web/components/field/FieldTranslationsDialog" msgid "Translations saved" msgstr "翻译已保存" +#: web/components/field/ChoyPropertyLabelTranslationsDialog.vue:12 +msgctxt "web/components/field/PropertyLabelTranslationsDialog" +msgid "Base language (cannot be deleted)" +msgstr "基础语言(不可删除)" + +#: web/components/field/ChoyPropertyLabelTranslationsDialog.vue:19 +msgctxt "web/components/field/PropertyLabelTranslationsDialog" +msgid "Cancel" +msgstr "取消" + +#: web/components/field/ChoyPropertyLabelTranslationsDialog.vue:96 +msgctxt "web/components/field/PropertyLabelTranslationsDialog" +msgid "Failed to load translations" +msgstr "加载翻译失败" + +#: web/components/field/ChoyPropertyLabelTranslationsDialog.vue:21 +msgctxt "web/components/field/PropertyLabelTranslationsDialog" +msgid "Save translations" +msgstr "保存翻译" + +#: web/components/field/ChoyPropertyLabelTranslationsDialog.vue:42 +msgctxt "web/components/field/PropertyLabelTranslationsDialog" +msgid "Translate label" +msgstr "翻译标签" + +#: web/components/field/ChoyPropertyLabelTranslationsDialog.vue:42 +msgctxt "web/components/field/PropertyLabelTranslationsDialog" +msgid "Translate: %s" +msgstr "翻译:%s" + #: web/components/field/relationCountSummary.ts:14 msgctxt "web/components/field/relationCountSummary@formatRelationCountSummary" msgid "%s record" @@ -2626,6 +2781,70 @@ msgstr "页面" #~ msgid "New" #~ msgstr "新建" +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#~ msgid "Add property" +#~ msgstr "添加属性" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#~ msgid "Default" +#~ msgstr "默认" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#~ msgid "Label" +#~ msgstr "标签" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#~ msgid "Name" +#~ msgstr "名称" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#~ msgid "No properties defined" +#~ msgstr "未定义属性" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#~ msgid "PropertyDefinition store is unavailable" +#~ msgstr "PropertyDefinition 存储不可用" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#~ msgid "Readonly" +#~ msgstr "只读" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#~ msgid "Remove" +#~ msgstr "移除" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#~ msgid "Save" +#~ msgstr "保存" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#~ msgid "Selection JSON" +#~ msgstr "选择项 JSON" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#~ msgid "Translate" +#~ msgstr "翻译" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#~ msgid "Translate %s" +#~ msgstr "翻译 %s" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#~ msgid "Type" +#~ msgstr "类型" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionField" +#~ msgid "Add property" +#~ msgstr "添加属性" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionField" +#~ msgid "Translate label" +#~ msgstr "翻译标签" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +#~ msgid "Translate" +#~ msgstr "翻译" + #~ msgctxt "web/components/field/ChoyTextField" #~ msgid "No content" #~ msgstr "无内容" @@ -2849,3 +3068,4 @@ msgstr "页面" #~ msgctxt "web/composables/useMenu" #~ msgid "No menus available" #~ msgstr "暂无可用菜单" + diff --git a/modules/web/web/components/field/ChoyFieldBase.test.ts b/modules/web/web/components/field/ChoyFieldBase.test.ts index 601ea5602..cebbc21dc 100644 --- a/modules/web/web/components/field/ChoyFieldBase.test.ts +++ b/modules/web/web/components/field/ChoyFieldBase.test.ts @@ -289,6 +289,19 @@ describe('ChoyFieldBase label and help', () => { m.unmount(); }); + test('prefers explicit help prop over meta.help', () => { + const m = mountBase({ + binding: makeBinding({ string: 'Code', help: 'From meta' }), + help: 'From prop', + renderMode: 'form', + }); + const help = m.q('[data-slot="form-help"]') as HTMLElement | null; + expect(help).toBeTruthy(); + expect(help!.getAttribute('aria-label') || '').toContain('From prop'); + expect(help!.getAttribute('aria-label') || '').not.toContain('From meta'); + m.unmount(); + }); + test('omits help tip when help is blank', () => { const m = mountBase({ binding: makeBinding({ string: 'Code', help: ' ' }), diff --git a/modules/web/web/components/field/ChoyFieldBase.vue b/modules/web/web/components/field/ChoyFieldBase.vue index ef64d3bc8..adca76a54 100644 --- a/modules/web/web/components/field/ChoyFieldBase.vue +++ b/modules/web/web/components/field/ChoyFieldBase.vue @@ -442,6 +442,11 @@ const props = withDefaults( defineProps<{ binding: UseField; label?: string; + /** + * Explicit help tip (translated). When non-empty, wins over FieldsGet / meta.help so + * form views can guarantee form-help without waiting on FieldsGet overlay. + */ + help?: string; rules?: RuleItem[]; /** Explicit control id; otherwise `fld-${prop}`. Label `for` and slot `inputId` share this. */ id?: string; @@ -541,6 +546,8 @@ const resolvedLabel = computed(() => { }); const effectiveHelp = computed(() => { + const propHelp = String(props.help || '').trim(); + if (propHelp) return propHelp; const leaf = leafFieldName.value; return resolveFieldHelp({ meta: effectiveFieldMeta.value as { help?: string; helpText?: TermReference } | undefined, @@ -556,15 +563,31 @@ const helpAccessibleLabel = computed(() => { return label ? `${label}: ${help}` : help; }); -onMounted(() => { - // Edit mode: ensure ACL/presentation overlay so deny-write → isReadonly is visible (T5.3). - if (!binding.env.isEditMode) return; +function ensurePresentationFieldsGet() { + // Presentation overlay (string/help + ACL isReadonly). Run in display and edit: + // ClientModel static meta may omit help; FieldsGet is the source for form-help tips. + // Also re-run when switching display → edit so deny-write → isReadonly is visible. const store = modelStore.value; const leaf = leafFieldName.value; if (!store?.ensureFieldsGet || !leaf) return; - void store.ensureFieldsGet([leaf], [...FIELD_PRESENTATION_FIELDS_GET_ATTRS]); + void store + .ensureFieldsGet([leaf], [...FIELD_PRESENTATION_FIELDS_GET_ATTRS]) + .catch(() => { + // Presentation overlay is best-effort; static meta remains in effect. + }); +} + +onMounted(() => { + ensurePresentationFieldsGet(); }); +watch( + () => binding.env.isEditMode, + (editing) => { + if (editing) ensurePresentationFieldsGet(); + } +); + const translationsOpen = ref(false); const companyValuesOpen = ref(false); diff --git a/modules/web/web/components/field/ChoyOneToManyKanbanField.mount.test.ts b/modules/web/web/components/field/ChoyOneToManyKanbanField.mount.test.ts index 52785f410..6b455b33e 100644 --- a/modules/web/web/components/field/ChoyOneToManyKanbanField.mount.test.ts +++ b/modules/web/web/components/field/ChoyOneToManyKanbanField.mount.test.ts @@ -256,4 +256,93 @@ describe('ChoyOneToManyKanbanField mount coverage', () => { expect(removeItemAt.calls.length).toBe(1); m.unmount(); }); + + test('kanbanCardDragClass paints overIndex ring; dragstart sets HTML5 drag image', async () => { + installStubs('edit'); + const { binding } = makeBinding({ + items: [ + { Id: '1', Name: 'Alpha', Sequence: 1 }, + { Id: '2', Name: 'Beta', Sequence: 2 }, + ], + }); + binding.relationStore = { + fieldsMetadata: { Sequence: { type: 'int' } }, + } as any; + const m = mountApp(ChoyOneToManyKanbanField as any, { + props: { binding, cardTitleField: 'Name', cardSubtitleField: '' }, + }); + await nextTick(); + await flushPromises(); + + const state = m.setupState() as { + kanbanCardDragClass?: (index: number) => Record; + kanbanReorder?: { + draggingIndex: { value: number | null }; + overIndex: { value: number | null }; + }; + sortableEnabled?: boolean; + }; + expect(state.sortableEnabled).toBe(true); + expect(state.kanbanCardDragClass?.(0)).toEqual({ + 'opacity-60': false, + 'bg-accent/40 ring-1 ring-inset ring-primary/30': false, + }); + + state.kanbanReorder!.draggingIndex.value = 0; + state.kanbanReorder!.overIndex.value = 1; + expect(state.kanbanCardDragClass?.(0)).toEqual({ + 'opacity-60': true, + 'bg-accent/40 ring-1 ring-inset ring-primary/30': false, + }); + expect(state.kanbanCardDragClass?.(1)).toEqual({ + 'opacity-60': false, + 'bg-accent/40 ring-1 ring-inset ring-primary/30': true, + }); + + const handle = m.q('[data-testid="choy-o2m-kanban-handle"]') as HTMLElement | null; + const card = m.q('[data-testid="choy-o2m-kanban-card"]') as HTMLElement | null; + expect(handle).toBeTruthy(); + expect(card).toBeTruthy(); + Object.defineProperty(card, 'getBoundingClientRect', { + value: () => ({ left: 10, top: 20, width: 120, height: 80, right: 130, bottom: 100, x: 10, y: 20, toJSON: () => ({}) }), + }); + const setData = fnRecorder(); + const setDragImage = fnRecorder(); + const onKanbanDragStart = (m.setupState() as { onKanbanDragStart?: (index: number, event: DragEvent) => void }) + .onKanbanDragStart; + expect(typeof onKanbanDragStart).toBe('function'); + onKanbanDragStart!(0, { + preventDefault: fnRecorder(), + stopPropagation: fnRecorder(), + clientX: 40, + clientY: 50, + currentTarget: handle, + dataTransfer: { effectAllowed: '', setData, setDragImage }, + } as unknown as DragEvent); + await nextTick(); + expect(setDragImage.calls.length).toBe(1); + expect(setDragImage.calls[0]?.[0]).toBe(card); + expect(state.kanbanReorder!.draggingIndex.value).toBe(0); + + // Non-sortable (showHandle=false) returns empty class map. + m.unmount(); + const hidden = makeBinding({ + items: [ + { Id: '1', Name: 'Alpha', Sequence: 1 }, + { Id: '2', Name: 'Beta', Sequence: 2 }, + ], + }); + hidden.binding.relationStore = { + fieldsMetadata: { Sequence: { type: 'int' } }, + } as any; + const hm = mountApp(ChoyOneToManyKanbanField as any, { + props: { binding: hidden.binding, showHandle: false }, + }); + await nextTick(); + const hiddenState = hm.setupState() as { + kanbanCardDragClass?: (index: number) => Record; + }; + expect(hiddenState.kanbanCardDragClass?.(0)).toEqual({}); + hm.unmount(); + }); }); diff --git a/modules/web/web/components/field/ChoyOneToManyKanbanField.vue b/modules/web/web/components/field/ChoyOneToManyKanbanField.vue index c2af6e6e2..44cd5e3f8 100644 --- a/modules/web/web/components/field/ChoyOneToManyKanbanField.vue +++ b/modules/web/web/components/field/ChoyOneToManyKanbanField.vue @@ -64,7 +64,7 @@ SPDX-License-Identifier: Apache-2.0