Compare commits

...

2 Commits

Author SHA1 Message Date
Francisco Gaona
baf3997fb6 WIP - fix displaying name for owner field 2026-04-10 22:19:11 +02:00
Francisco Gaona
a2d48f6a03 WIP - Fix options for picklist field, some progress on multi picklist. 2026-04-10 21:41:13 +02:00
9 changed files with 155 additions and 10 deletions

View File

@@ -0,0 +1,30 @@
/**
* Add 'alias' and virtual 'name' column to users table.
*
* - alias: a user-editable display name / nickname
* - name: a generated column that returns COALESCE(alias, CONCAT(firstName, ' ', lastName), email)
* so that lookup fields referencing User.name always resolve.
*/
exports.up = function (knex) {
return knex.schema.alterTable('users', (table) => {
table.string('alias', 255).nullable().after('lastName');
table.string('name', 512).nullable().after('alias');
}).then(() => {
// Backfill existing rows: name = alias, or firstName + lastName, or email
return knex.raw(`
UPDATE users
SET name = COALESCE(
NULLIF(alias, ''),
NULLIF(TRIM(CONCAT(COALESCE(firstName, ''), ' ', COALESCE(lastName, ''))), ''),
email
)
`);
});
};
exports.down = function (knex) {
return knex.schema.alterTable('users', (table) => {
table.dropColumn('name');
table.dropColumn('alias');
});
};

View File

@@ -20,6 +20,8 @@ model User {
password String
firstName String?
lastName String?
alias String?
name String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -1,4 +1,5 @@
import { BaseModel } from './base.model';
import { ModelOptions, QueryContext } from 'objection';
export class User extends BaseModel {
static tableName = 'users';
@@ -8,6 +9,8 @@ export class User extends BaseModel {
password: string;
firstName?: string;
lastName?: string;
alias?: string;
name?: string;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
@@ -22,11 +25,37 @@ export class User extends BaseModel {
password: { type: 'string' },
firstName: { type: 'string' },
lastName: { type: 'string' },
alias: { type: 'string' },
name: { type: 'string' },
isActive: { type: 'boolean' },
},
};
}
/**
* Compute the `name` column before insert/update so lookup fields
* referencing User.name always have a value.
*/
private computeName() {
if (this.alias) {
this.name = this.alias;
} else if (this.firstName || this.lastName) {
this.name = [this.firstName, this.lastName].filter(Boolean).join(' ');
} else if (this.email) {
this.name = this.email;
}
}
$beforeInsert(queryContext: QueryContext) {
super.$beforeInsert(queryContext);
this.computeName();
}
$beforeUpdate(opt: ModelOptions, queryContext: QueryContext) {
super.$beforeUpdate(opt, queryContext);
this.computeName();
}
static get relationMappings() {
const { UserRole } = require('./user-role.model');
const { Role } = require('./role.model');

View File

@@ -152,6 +152,7 @@ export class FieldMapperService {
'phone': 'text',
'picklist': 'select',
'multipicklist': 'multiSelect',
'multi_picklist': 'multiSelect',
'lookup': 'belongsTo',
'master-detail': 'belongsTo',
'currency': 'currency',

View File

@@ -336,13 +336,27 @@ export class ObjectService {
updated_at: knex.fn.now(),
};
// Store relationDisplayField in UI metadata if provided
if (data.relationDisplayField || data.relationObjects || data.relationTypeField) {
fieldData.ui_metadata = JSON.stringify({
relationDisplayField: data.relationDisplayField,
relationObjects: data.relationObjects,
relationTypeField: data.relationTypeField,
});
// Build UI metadata from all sources
const uiMetadataObj: any = {};
// Merge general uiMetadata (options, placeholder, helpText, etc.)
if (data.uiMetadata && typeof data.uiMetadata === 'object') {
Object.assign(uiMetadataObj, data.uiMetadata);
}
// Store relation-specific fields in UI metadata if provided
if (data.relationDisplayField) {
uiMetadataObj.relationDisplayField = data.relationDisplayField;
}
if (data.relationObjects) {
uiMetadataObj.relationObjects = data.relationObjects;
}
if (data.relationTypeField) {
uiMetadataObj.relationTypeField = data.relationTypeField;
}
if (Object.keys(uiMetadataObj).length > 0) {
fieldData.ui_metadata = JSON.stringify(uiMetadataObj);
}
await knex('field_definitions').insert(fieldData);

View File

@@ -39,7 +39,7 @@ export class SetupUsersController {
@Post()
async createUser(
@TenantId() tenantId: string,
@Body() data: { email: string; password: string; firstName?: string; lastName?: string },
@Body() data: { email: string; password: string; firstName?: string; lastName?: string; alias?: string },
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
@@ -52,6 +52,7 @@ export class SetupUsersController {
password: hashedPassword,
firstName: data.firstName,
lastName: data.lastName,
alias: data.alias,
isActive: true,
});
@@ -62,7 +63,7 @@ export class SetupUsersController {
async updateUser(
@TenantId() tenantId: string,
@Param('id') id: string,
@Body() data: { email?: string; password?: string; firstName?: string; lastName?: string },
@Body() data: { email?: string; password?: string; firstName?: string; lastName?: string; alias?: string },
) {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
@@ -72,6 +73,7 @@ export class SetupUsersController {
if (data.email) updateData.email = data.email;
if (data.firstName !== undefined) updateData.firstName = data.firstName;
if (data.lastName !== undefined) updateData.lastName = data.lastName;
if (data.alias !== undefined) updateData.alias = data.alias;
// Hash password if provided
if (data.password) {

View File

@@ -249,6 +249,51 @@ const handleRelationTypeUpdate = (value: string | null) => {
</SelectContent>
</Select>
<!-- Multi-Select -->
<div v-else-if="field.type === FieldType.MULTI_SELECT" class="space-y-2">
<div class="flex flex-wrap gap-1 min-h-[36px] rounded-md border border-input bg-background px-3 py-2">
<Badge
v-for="selectedVal in (Array.isArray(value) ? value : [])"
:key="String(selectedVal)"
variant="secondary"
class="gap-1 cursor-pointer"
@click="value = (value || []).filter((v: any) => v !== selectedVal)"
>
{{ field.options?.find(o => o.value === selectedVal)?.label || selectedVal }}
<span class="text-xs ml-1">&times;</span>
</Badge>
<span v-if="!value || (Array.isArray(value) && value.length === 0)" class="text-sm text-muted-foreground">
{{ field.placeholder || 'Select options...' }}
</span>
</div>
<div class="space-y-1">
<div
v-for="option in field.options"
:key="String(option.value)"
class="flex items-center gap-2"
>
<Checkbox
:id="`${field.id}-${option.value}`"
:checked="Array.isArray(value) && value.includes(option.value)"
@update:checked="(checked: boolean) => {
const current = Array.isArray(value) ? [...value] : []
if (checked) {
current.push(option.value)
} else {
const idx = current.indexOf(option.value)
if (idx > -1) current.splice(idx, 1)
}
value = current
}"
:disabled="field.isReadOnly"
/>
<Label :for="`${field.id}-${option.value}`" class="text-sm font-normal cursor-pointer">
{{ option.label }}
</Label>
</div>
</div>
</div>
<!-- Boolean - Checkbox -->
<div v-else-if="field.type === FieldType.BOOLEAN" class="flex items-center gap-2">
<Checkbox :id="field.id" v-model:checked="value" :disabled="field.isReadOnly" />

View File

@@ -33,6 +33,10 @@
<Label class="text-muted-foreground">Email</Label>
<p class="font-medium">{{ user?.email }}</p>
</div>
<div>
<Label class="text-muted-foreground">Alias</Label>
<p class="font-medium">{{ user?.alias || 'N/A' }}</p>
</div>
<div>
<Label class="text-muted-foreground">First Name</Label>
<p class="font-medium">{{ user?.firstName || 'N/A' }}</p>
@@ -210,6 +214,9 @@ const removeRole = async (roleId: string) => {
const getUserName = (user: any) => {
if (!user) return 'User';
if (user.alias) {
return user.alias;
}
if (user.firstName || user.lastName) {
return [user.firstName, user.lastName].filter(Boolean).join(' ');
}

View File

@@ -95,6 +95,10 @@
<Label for="lastName">Last Name (Optional)</Label>
<Input id="lastName" v-model="newUser.lastName" placeholder="Doe" />
</div>
<div class="space-y-2">
<Label for="alias">Alias (Optional)</Label>
<Input id="alias" v-model="newUser.alias" placeholder="Display name" />
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="showCreateDialog = false">Cancel</Button>
@@ -131,6 +135,10 @@
<Label for="edit-lastName">Last Name</Label>
<Input id="edit-lastName" v-model="editUser.lastName" placeholder="Doe" />
</div>
<div class="space-y-2">
<Label for="edit-alias">Alias</Label>
<Input id="edit-alias" v-model="editUser.alias" placeholder="Display name" />
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="showEditDialog = false">Cancel</Button>
@@ -187,6 +195,7 @@ const newUser = ref({
password: '',
firstName: '',
lastName: '',
alias: '',
});
const editUser = ref({
id: '',
@@ -194,6 +203,7 @@ const editUser = ref({
password: '',
firstName: '',
lastName: '',
alias: '',
});
const userToDelete = ref<any>(null);
@@ -215,7 +225,7 @@ const createUser = async () => {
await api.post('/setup/users', newUser.value);
toast.success('User created successfully');
showCreateDialog.value = false;
newUser.value = { email: '', password: '', firstName: '', lastName: '' };
newUser.value = { email: '', password: '', firstName: '', lastName: '', alias: '' };
await loadUsers();
} catch (error: any) {
console.error('Failed to create user:', error);
@@ -230,6 +240,7 @@ const openEditDialog = (user: any) => {
password: '',
firstName: user.firstName || '',
lastName: user.lastName || '',
alias: user.alias || '',
};
showEditDialog.value = true;
};
@@ -240,6 +251,7 @@ const updateUser = async () => {
email: editUser.value.email,
firstName: editUser.value.firstName,
lastName: editUser.value.lastName,
alias: editUser.value.alias,
};
if (editUser.value.password) {
payload.password = editUser.value.password;
@@ -273,6 +285,9 @@ const deleteUser = async () => {
};
const getUserName = (user: any) => {
if (user.alias) {
return user.alias;
}
if (user.firstName || user.lastName) {
return [user.firstName, user.lastName].filter(Boolean).join(' ');
}