diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9ab25d6..a0b8f7a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,8 @@ on: push: branches: - main + - develop + - main-hotfix pull_request: {} jobs: tests: diff --git a/.github/workflows/make_release_pr.yml b/.github/workflows/make_release_pr.yml index 4c0890c1..9a4528b9 100644 --- a/.github/workflows/make_release_pr.yml +++ b/.github/workflows/make_release_pr.yml @@ -18,9 +18,9 @@ jobs: owner: frappe repo: lms title: |- - "chore: merge 'develop' into 'main'" + "chore: merge 'main-hotfix' into 'main'" body: "Automated weekly release" base: main - head: develop + head: main-hotfix env: GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index e053c9b8..111c5918 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -4,7 +4,10 @@ on: pull_request: workflow_dispatch: push: - branches: [ main ] + branches: + - main + - develop + - main-hotfix permissions: # Do not change this as GITHUB_TOKEN is being used by roulette diff --git a/.mergify.yml b/.mergify.yml new file mode 100644 index 00000000..098cfd0a --- /dev/null +++ b/.mergify.yml @@ -0,0 +1,30 @@ +pull_request_rules: + - name: backport to develop + conditions: + - label="backport develop" + actions: + backport: + branches: + - develop + assignees: + - "{{ author }}" + + - name: backport to main-hotfix + conditions: + - label="backport main-hotfix" + actions: + backport: + branches: + - main-hotfix + assignees: + - "{{ author }}" + + - name: backport to main + conditions: + - label="backport main" + actions: + backport: + branches: + - main + assignees: + - "{{ author }}" diff --git a/cypress/e2e/batch_creation.cy.js b/cypress/e2e/batch_creation.cy.js index 4f48bf70..653a70a2 100644 --- a/cypress/e2e/batch_creation.cy.js +++ b/cypress/e2e/batch_creation.cy.js @@ -162,8 +162,12 @@ describe("Batch Creation", () => { /* Add student to batch */ cy.get("button").contains("Students").click(); cy.get("button").contains("Add").click(); - cy.get('div[role="dialog"]').first().find("button").eq(1).click(); - cy.get("input[id^='headlessui-combobox-input-v-']").type(randomEmail); + cy.get('div[role="dialog"]') + .first() + .find("input[id^='headlessui-combobox-input-v-']") + .first() + .click(); + cy.get("input[placeholder='Search']").type(randomEmail); cy.get("div").contains(randomEmail).click(); cy.get("button").contains("Submit").click(); diff --git a/cypress/e2e/course_creation.cy.js b/cypress/e2e/course_creation.cy.js index 47a592d9..de0113fe 100644 --- a/cypress/e2e/course_creation.cy.js +++ b/cypress/e2e/course_creation.cy.js @@ -65,7 +65,7 @@ describe("Course Creation", () => { .contains("Category") .parent() .within(() => { - cy.get("button").click(); + cy.get("input").click(); }); cy.get("[id^=headlessui-combobox-option-") .should("be.visible") diff --git a/frontend/src/components/Controls/Autocomplete.vue b/frontend/src/components/Controls/Autocomplete.vue index 1280794f..79fbd5b7 100644 --- a/frontend/src/components/Controls/Autocomplete.vue +++ b/frontend/src/components/Controls/Autocomplete.vue @@ -1,142 +1,92 @@ @@ -147,15 +97,16 @@ import { ComboboxInput, ComboboxOptions, ComboboxOption, + ComboboxButton, } from '@headlessui/vue' -import { Popover } from 'frappe-ui' -import { ChevronDown, X } from 'lucide-vue-next' import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue' +import { ChevronDown, X } from 'lucide-vue-next' +import { watchDebounced } from '@vueuse/core' const props = defineProps({ modelValue: { - type: String, - default: '', + type: [String, Object], + default: null, }, options: { type: Array, @@ -186,107 +137,93 @@ const props = defineProps({ default: true, }, }) + const emit = defineEmits(['update:modelValue', 'update:query', 'change']) - -const query = ref('') -const showOptions = ref(false) +const trigger = ref(null) const search = ref(null) - const attrs = useAttrs() const slots = useSlots() - +const selectedValue = ref(props.modelValue) +const query = ref('') const valuePropPassed = computed(() => 'value' in attrs) -const selectedValue = computed({ - get() { - return valuePropPassed.value ? attrs.value : props.modelValue - }, - set(val) { - query.value = '' - if (val) { - showOptions.value = false - } - emit(valuePropPassed.value ? 'change' : 'update:modelValue', val) - }, +watch(selectedValue, (val) => { + query.value = '' + emit(valuePropPassed.value ? 'change' : 'update:modelValue', val) }) -function close() { - showOptions.value = false +function clearValue() { + emit('update:modelValue', null) } const groups = computed(() => { - if (!props.options || props.options.length == 0) return [] + if (!props.options?.length) return [] - let groups = props.options[0]?.group + const normalized = props.options[0]?.group ? props.options : [{ group: '', items: props.options }] - - return groups - .map((group, i) => { - return { - key: i, - group: group.group, - hideLabel: group.hideLabel || false, - items: props.filterable ? filterOptions(group.items) : group.items, - } - }) + return normalized + .map((group, i) => ({ + key: i, + group: group.group, + hideLabel: group.hideLabel || false, + items: props.filterable ? filterOptions(group.items) : group.items, + })) .filter((group) => group.items.length > 0) }) function filterOptions(options) { - if (!query.value) { - return options - } - return options.filter((option) => { - let searchTexts = [option.label, option.value] - return searchTexts.some((text) => - (text || '').toString().toLowerCase().includes(query.value.toLowerCase()) - ) + if (!query.value) return options + const q = query.value.toLowerCase() + return options.filter((option) => + [option.label, option.value] + .filter(Boolean) + .some((text) => text.toString().toLowerCase().includes(q)) + ) +} + +watchDebounced( + query, + (val) => { + emit('update:query', val) + }, + { debounce: 300 } +) + +const onFocus = () => { + trigger.value?.$el.click() + nextTick(() => { + search.value?.focus() }) } -function displayValue(option) { - if (typeof option === 'string') { - let allOptions = groups.value.flatMap((group) => group.items) - let selectedOption = allOptions.find((o) => o.value === option) - return selectedOption?.label || option - } - return option?.label +const close = () => { + selectedValue.value = null + trigger.value?.$el.click() } -watch(query, (q) => { - emit('update:query', q) -}) - -watch(showOptions, (val) => { - if (val) { - nextTick(() => { - search.value.el.focus() - }) - } -}) - -const textColor = computed(() => { - return props.disabled ? 'text-ink-gray-5' : 'text-ink-gray-8' -}) +const textColor = computed(() => + props.disabled ? 'text-ink-gray-5' : 'text-ink-gray-8' +) const inputClasses = computed(() => { - let sizeClasses = { + const sizeClasses = { sm: 'text-base rounded h-7', md: 'text-base rounded h-8', lg: 'text-lg rounded-md h-10', xl: 'text-xl rounded-md h-10', }[props.size] - let paddingClasses = { + const paddingClasses = { sm: 'py-1.5 px-2', md: 'py-1.5 px-2.5', lg: 'py-1.5 px-3', xl: 'py-1.5 px-3', }[props.size] - let variant = props.disabled ? 'disabled' : props.variant - let variantClasses = { + const variant = props.disabled ? 'disabled' : props.variant + + const variantClasses = { subtle: 'border border-gray-100 bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3', outline: @@ -307,6 +244,4 @@ const inputClasses = computed(() => { 'transition-colors w-full', ] }) - -defineExpose({ query }) diff --git a/frontend/src/components/Controls/Link.vue b/frontend/src/components/Controls/Link.vue index 5742a6fe..a84cea4f 100644 --- a/frontend/src/components/Controls/Link.vue +++ b/frontend/src/components/Controls/Link.vue @@ -11,7 +11,6 @@ :size="attrs.size || 'sm'" :variant="attrs.variant" :placeholder="attrs.placeholder" - :filterable="false" :readonly="attrs.readonly" >