Merge pull request #13924 from nocodb/nc-feat/alphasort-toggle

feat(nc-gui): persistent alphabetize toggle for select field options
This commit is contained in:
Ramesh Mane
2026-05-29 17:59:29 +05:30
committed by GitHub
5 changed files with 102 additions and 25 deletions

View File

@@ -63,6 +63,14 @@ const {
defaultFormState,
} = useColumnCreateStoreOrThrow()
// Patch colOptions into formState during setup so the child SelectOptions
// (whose onMounted runs before this parent's onMounted) initializes its
// local options from preload's pending edits rather than the stale column data.
// Full preload merge (others + meta) still happens in onMounted below.
if (props.preload?.colOptions) {
formState.value.colOptions = { ...props.preload.colOptions }
}
const { isAiFeaturesEnabled, isAiBetaFeaturesEnabled, aiIntegrationAvailable, aiLoading, aiError } = useNocoAi()
const {
@@ -405,6 +413,8 @@ const saving = ref(false)
const warningVisible = ref(false)
const selectOptionsRef = ref<{ flushSort: () => void } | null>(null)
const saveSubmitted = async () => {
if (readOnly.value) return
let saved, savedColumn
@@ -447,6 +457,9 @@ const saveSubmitted = async () => {
async function onSubmit() {
if (readOnly.value) return
selectOptionsRef.value?.flushSort()
await nextTick()
// Show warning message if user tries to change type of column
if (isEdit.value && formState.value.uidt !== column.value?.uidt) {
warningVisible.value = true
@@ -465,6 +478,7 @@ async function onSubmit() {
// focus and select the column name field
const antInput = ref()
watchEffect(() => {
if (antInput.value && formState.value && !readOnly.value) {
// todo: replace setTimeout
@@ -1411,6 +1425,7 @@ const unique = computed({
<SmartsheetColumnUserOptions v-if="formState.uidt === UITypes.User" v-model:value="formState" :is-edit="isEdit" />
<SmartsheetColumnSelectOptions
v-if="formState.uidt === UITypes.SingleSelect || formState.uidt === UITypes.MultiSelect"
ref="selectOptionsRef"
v-model:value="formState"
:from-table-explorer="props.fromTableExplorer || false"
/>

View File

@@ -674,10 +674,7 @@ const handleScrollIntoView = () => {
>
</span>
</div>
<GeneralSourceRestrictionTooltip
message="Field cannot be upgraded."
:enabled="!!isMetaReadOnly"
>
<GeneralSourceRestrictionTooltip message="Field cannot be upgraded." :enabled="!!isMetaReadOnly">
<NcButton size="xs" type="primary" :disabled="isMetaReadOnly" @click="emit('upgrade')">
{{ $t('general.upgrade') }}
</NcButton>

View File

@@ -69,6 +69,17 @@ const isColorCodeEnabled = computed({
},
})
const isAlphabetized = computed({
get: () => {
const metaObj = parseProp(vModel.value.meta)
return metaObj.isAlphabetized === true
},
set: (val: boolean) => {
const metaObj = parseProp(vModel.value.meta)
vModel.value.meta = { ...metaObj, isAlphabetized: val }
},
})
const isKanban = inject(IsKanbanInj, ref(false))
const { t } = useI18n()
@@ -180,6 +191,12 @@ const syncOptions = (saveChanges = false, submit = false, payload?: Option) => {
vModel.value.colOptions.options = options.value
.filter((op) => op.status !== 'remove')
.sort((a, b) => {
// On submit (e.g. saving a kanban stack) respect the Alphabetize toggle so the
// persisted option order is alphabetical rather than the current rendered order.
if (submit && isAlphabetized.value) {
return (a.title ?? '').localeCompare(b.title ?? '')
}
const renderA = renderedOptions.value.findIndex((el) => a.index !== undefined && el.index === a.index)
const renderB = renderedOptions.value.findIndex((el) => a.index !== undefined && el.index === b.index)
if (renderA === -1 || renderB === -1) return 0
@@ -383,7 +400,7 @@ const predictOptions = async () => {
}
}
const alphabetizeOptions = () => {
const sortOptionsInPlace = () => {
const activeOptions = options.value.filter((op) => op.status !== 'remove')
const alreadySorted = activeOptions.every(
@@ -409,7 +426,11 @@ const alphabetizeOptions = () => {
syncOptions()
}
onMounted(() => {
// Seed the local `options` list from the bound model. Runs on mount and again whenever
// the underlying field changes while this component stays mounted (AI auto-suggest field
// switch, where `colOptions` is swapped under us). Without re-seeding, the previously
// selected field's options would stay on screen even though every other prop updated.
function seedOptionsFromModel() {
if (!vModel.value.colOptions?.options) {
vModel.value.colOptions = {
options: [],
@@ -444,9 +465,26 @@ onMounted(() => {
}
const fndDefaultOption = options.value.filter((el) => el.title === vModel.value.cdf)
if (fndDefaultOption.length) {
defaultOption.value = vModel.value.uidt === UITypes.SingleSelect ? [fndDefaultOption[0]] : fndDefaultOption
}
defaultOption.value = fndDefaultOption.length
? vModel.value.uidt === UITypes.SingleSelect
? [fndDefaultOption[0]]
: fndDefaultOption
: []
}
// Re-seed when the bound field switches in place (identity changes), e.g. toggling
// between AI auto-suggested select fields. Local edits never change the identity key,
// so this never fires mid-edit.
watch(
() => vModel.value.ai_temp_id ?? vModel.value.id ?? vModel.value.temp_id,
() => {
seedOptionsFromModel()
},
)
onMounted(() => {
seedOptionsFromModel()
if (isKanbanStack.value && isNewStack.value) {
addNewOption()
} else if (isKanbanStack.value) {
@@ -498,6 +536,12 @@ if (!isKanbanStack.value) {
})
})
}
defineExpose({
flushSort: () => {
if (isAlphabetized.value) sortOptionsInPlace()
},
})
</script>
<template>
@@ -509,18 +553,16 @@ if (!isKanbanStack.value) {
</NcSwitch>
</div>
<NcButton
v-e="['c:field:select:alphabetize']"
type="text"
size="small"
:disabled="isSyncedField"
@click.stop="alphabetizeOptions"
>
<template #icon>
<GeneralIcon icon="ncArrowUpDown" class="h-4 w-4 opacity-80" />
</template>
{{ $t('labels.alphabetize') }}
</NcButton>
<div class="flex items-center">
<NcSwitch
v-model:checked="isAlphabetized"
size="xsmall"
:disabled="isSyncedField"
@change="(v) => $e('c:field:select:alphabetize:toggle', { enabled: v })"
>
{{ $t('labels.alphabetize') }}
</NcSwitch>
</div>
</div>
<div
@@ -607,7 +649,7 @@ if (!isKanbanStack.value) {
:list="renderedOptions"
item-key="id"
handle=".nc-child-draggable-icon"
:disabled="isSyncedField"
:disabled="isAlphabetized || isSyncedField"
@change="onDragReorder"
>
<template #item="{ element, index }">
@@ -619,7 +661,8 @@ if (!isKanbanStack.value) {
>
<div
v-if="!isKanban"
class="nc-child-draggable-icon p-2 flex cursor-pointer text-nc-content-gray-subtle"
class="nc-child-draggable-icon p-2 flex text-nc-content-gray-subtle"
:class="isAlphabetized ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'"
:data-testid="`select-option-column-handle-icon-${element.title}`"
>
<component :is="iconMap.dragVertical" small class="handle" />

View File

@@ -933,6 +933,27 @@ watch(
{ immediate: true },
)
// When the Alphabetize toggle is enabled, select option order is committed only at
// submit time (mirrors EditOrAdd.onSubmit → SelectOptions.flushSort used by the single
// column modal). The multi-field editor saves via ops instead of that submit path, so
// sort here. Backend assigns option order by array index (Column.ts), so reordering the
// array is sufficient.
const sortAlphabetizedSelectOptions = () => {
for (const op of ops.value) {
if (op.op !== 'add' && op.op !== 'update') continue
const col = op.column
if (![UITypes.SingleSelect, UITypes.MultiSelect].includes(col.uidt as UITypes)) continue
if (parseProp(col.meta).isAlphabetized !== true) continue
const colOptions = col.colOptions as SelectOptionsType | undefined
if (!colOptions?.options?.length) continue
colOptions.options = [...colOptions.options].sort((a, b) => (a.title ?? '').localeCompare(b.title ?? ''))
}
}
const saveChanges = async () => {
if (!isColumnsValid.value) {
message.error(t('msg.error.multiFieldSaveValidation'))
@@ -940,6 +961,8 @@ const saveChanges = async () => {
} else if (!loading.value && !hasUnsavedChanges.value) {
return
}
sortAlphabetizedSelectOptions()
try {
if (!meta.value?.id) return

View File

@@ -21,8 +21,7 @@ import {
*/
const syncSystemColumnTitles = new Set<string>(SYNC_SYSTEM_COLUMN_TITLES)
const isSyncSystemColumnTitle = (title?: string | null): boolean =>
!!title && syncSystemColumnTitles.has(title)
const isSyncSystemColumnTitle = (title?: string | null): boolean => !!title && syncSystemColumnTitles.has(title)
const getSyncFrequency = (trigger: SyncTrigger, cron?: string) => {
if (trigger === SyncTrigger.Manual) return 'Manual'