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 password String
firstName String? firstName String?
lastName String? lastName String?
alias String?
name String?
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@@ -1,4 +1,5 @@
import { BaseModel } from './base.model'; import { BaseModel } from './base.model';
import { ModelOptions, QueryContext } from 'objection';
export class User extends BaseModel { export class User extends BaseModel {
static tableName = 'users'; static tableName = 'users';
@@ -8,6 +9,8 @@ export class User extends BaseModel {
password: string; password: string;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
alias?: string;
name?: string;
isActive: boolean; isActive: boolean;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
@@ -22,11 +25,37 @@ export class User extends BaseModel {
password: { type: 'string' }, password: { type: 'string' },
firstName: { type: 'string' }, firstName: { type: 'string' },
lastName: { type: 'string' }, lastName: { type: 'string' },
alias: { type: 'string' },
name: { type: 'string' },
isActive: { type: 'boolean' }, 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() { static get relationMappings() {
const { UserRole } = require('./user-role.model'); const { UserRole } = require('./user-role.model');
const { Role } = require('./role.model'); const { Role } = require('./role.model');

View File

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

View File

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

View File

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

View File

@@ -249,6 +249,51 @@ const handleRelationTypeUpdate = (value: string | null) => {
</SelectContent> </SelectContent>
</Select> </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 --> <!-- Boolean - Checkbox -->
<div v-else-if="field.type === FieldType.BOOLEAN" class="flex items-center gap-2"> <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" /> <Checkbox :id="field.id" v-model:checked="value" :disabled="field.isReadOnly" />

View File

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

View File

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