Skip to content

Commit

Permalink
[full-ci] Introduce read-only options to OcSelect (#8727)
Browse files Browse the repository at this point in the history
  • Loading branch information
JammingBen authored Mar 30, 2023
1 parent c47e18e commit 9f87057
Show file tree
Hide file tree
Showing 9 changed files with 231 additions and 64 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Enhancement: Read-only select options

The `OcSelect` component now supports read-only select options which can't be removed if selected.

https://github.com/owncloud/web/issues/8729
https://github.com/owncloud/web/pull/8727
94 changes: 94 additions & 0 deletions packages/design-system/src/components/OcSelect/OcSelect.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { defaultPlugins, mount } from 'web-test-helpers'
import OcSelect from './OcSelect.vue'

const selectors = {
ocSelect: '.oc-select',
selectedOptions: '.vs__selected-options .vs__selected',
deselectBtn: '.vs__selected-options .vs__deselect',
deselectLockIcon: '.vs__deselect-lock',
clearBtn: '.vs__clear',
searchInput: '.vs__search',
ocSpinner: '.oc-spinner',
warningMessage: '.oc-text-input-warning',
errorMessage: '.oc-text-input-danger',
descriptionMessage: '.oc-text-input-description'
}

describe('OcSelect', () => {
it('passes the options to the vue-select component', () => {
const options = [{ label: 'label1' }, { label: 'label2' }]
const wrapper = getWrapper({ options })
expect(wrapper.findComponent<any>(selectors.ocSelect).props('options')).toEqual(options)
})
it('shows ocSpinner component when loading', () => {
const wrapper = getWrapper({ loading: true })
expect(wrapper.find(selectors.ocSpinner).exists()).toBeTruthy()
})
it('triggers the "search:input"-event on search input', async () => {
const wrapper = getWrapper()
await wrapper.find(selectors.searchInput).trigger('input')
expect(wrapper.emitted('search:input')).toBeDefined()
})
describe('clear button', () => {
it('is hidden by default', () => {
const options = [{ label: 'label1' }, { label: 'label2' }]
const wrapper = getWrapper({ options, modelValue: options[0] })
expect(wrapper.find(selectors.clearBtn).attributes('style')).toEqual('display: none;')
})
it('is visible if "clearable" is set to true', () => {
const options = [{ label: 'label1' }, { label: 'label2' }]
const wrapper = getWrapper({ options, modelValue: options[0], clearable: true })
expect(wrapper.find(selectors.clearBtn).attributes('style')).toBeUndefined()
})
})
describe('selected option', () => {
it('displays', () => {
const options = [{ label: 'label1' }, { label: 'label2' }]
const wrapper = getWrapper({ options, modelValue: options[0] })
expect(wrapper.findAll(selectors.selectedOptions).length).toBe(1)
expect(wrapper.findAll(selectors.selectedOptions).at(0).text()).toEqual(options[0].label)
})
it('displays with a custom label property', () => {
const options = [{ customLabel: 'label1' }, { customLabel: 'label2' }]
const wrapper = getWrapper({ options, modelValue: options[0], optionLabel: 'customLabel' })
expect(wrapper.findAll(selectors.selectedOptions).at(0).text()).toEqual(
options[0].customLabel
)
})
it('can be cleared if multi-select is allowed', () => {
const options = [{ label: 'label1' }, { label: 'label2' }]
const wrapper = getWrapper({ options, modelValue: options[0], multiple: true })
expect(wrapper.find(selectors.deselectBtn).exists()).toBeTruthy()
expect(wrapper.find(selectors.deselectLockIcon).exists()).toBeFalsy()
})
it('can not be cleared if readonly', () => {
const options = [{ label: 'label1', readonly: true }, { label: 'label2' }]
const wrapper = getWrapper({ options, modelValue: options[0], multiple: true })
expect(wrapper.find(selectors.deselectBtn).exists()).toBeFalsy()
expect(wrapper.find(selectors.deselectLockIcon).exists()).toBeTruthy()
})
})
describe('message', () => {
it('displays a warning message', () => {
const wrapper = getWrapper({ warningMessage: 'foo' })
expect(wrapper.find(selectors.warningMessage).exists()).toBeTruthy()
})
it('displays an error message', () => {
const wrapper = getWrapper({ errorMessage: 'foo' })
expect(wrapper.find(selectors.errorMessage).exists()).toBeTruthy()
})
it('displays a description message', () => {
const wrapper = getWrapper({ descriptionMessage: 'foo' })
expect(wrapper.find(selectors.descriptionMessage).exists()).toBeTruthy()
})
})
})

function getWrapper(props = {}) {
return mount(OcSelect, {
props,
global: {
plugins: [...defaultPlugins()]
}
})
}
100 changes: 76 additions & 24 deletions packages/design-system/src/components/OcSelect/OcSelect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
:loading="loading"
:searchable="searchable"
:clearable="clearable"
:multiple="multiple"
class="oc-select"
style="background: transparent"
v-bind="additionalAttributes"
Expand All @@ -19,10 +20,31 @@
<template v-for="(index, name) in $slots" #[name]="data">
<slot v-if="name !== 'search'" :name="name" v-bind="data" />
</template>
<template #no-options><div v-translate>No options available.</div></template>
<template #no-options><div v-text="$gettext('No options available.')" /></template>
<template #spinner="{ loading }">
<oc-spinner v-if="loading" />
</template>
<template #selected-option-container="{ option, deselect }">
<span class="vs__selected" :class="{ 'vs__selected-readonly': option.readonly }">
<slot name="selected-option" v-bind="option">
{{ getOptionLabel(option) }}
</slot>
<span v-if="multiple" class="oc-flex oc-flex-middle oc-ml-s oc-mr-xs">
<oc-icon v-if="option.readonly" class="vs__deselect-lock" name="lock" size="small" />
<oc-button
v-else
appearance="raw"
:title="$gettext('Deselect %{label}', { label: getOptionLabel(option) })"
:aria-label="$gettext('Deselect %{label}', { label: getOptionLabel(option) })"
class="vs__deselect oc-mx-rm"
@mousedown.stop.prevent
@click="deselect(option)"
>
<oc-icon name="close" size="small" />
</oc-button>
</span>
</span>
</template>
</vue-select>

<div
Expand Down Expand Up @@ -58,8 +80,9 @@
import Fuse from 'fuse.js'
import uniqueId from '../../utils/uniqueId'
import VueSelect from 'vue-select'
import { defineComponent, ComponentPublicInstance, onMounted, ref, unref, VNodeRef } from 'vue'
import { useGettext } from 'vue3-gettext'
import 'vue-select/dist/vue-select.css'
import { defineComponent, ComponentPublicInstance } from 'vue'
/**
* Select component with a trigger and dropdown based on [Vue Select](https://vue-select.org/)
Expand Down Expand Up @@ -126,7 +149,7 @@ export default defineComponent({
*/
optionLabel: {
type: String,
default: null
default: 'label'
},
/**
* Determines if the select field is searchable
Expand Down Expand Up @@ -181,10 +204,56 @@ export default defineComponent({
descriptionMessage: {
type: String,
default: null
},
/**
* Determines if multiple options can be selected.
*/
multiple: {
type: Boolean,
default: false
}
},
emits: ['search:input', 'update:modelValue'],
setup(props, { emit }) {
const { $gettext } = useGettext()
const select: VNodeRef = ref()
const getOptionLabel = (option) => {
if (typeof option === 'object') {
if (!option.hasOwnProperty(props.optionLabel)) {
return console.warn(
`[vue-select warn]: Label key "option.${props.optionLabel}" does not` +
` exist in options object ${JSON.stringify(option)}.\n` +
'https://vue-select.org/api/props.html#getoptionlabel'
)
}
return option[props.optionLabel]
}
return option
}
const setComboBoxAriaLabel = () => {
const comboBoxElement = (unref(select) as ComponentPublicInstance).$el.querySelector(
'div:first-child'
)
comboBoxElement?.setAttribute('aria-label', $gettext('Search for option'))
}
const userInput = (event) => {
/**
* Triggers when a value of search input is changed
*
* @property {string} query search query
*/
emit('search:input', event.target.value)
}
onMounted(() => {
setComboBoxAriaLabel()
})
return { select, getOptionLabel, userInput }
},
computed: {
additionalAttributes() {
const additionalAttrs = {}
Expand Down Expand Up @@ -216,27 +285,6 @@ export default defineComponent({
messageId() {
return `${this.id}-message`
}
},
mounted() {
this.setComboBoxAriaLabel()
},
methods: {
setComboBoxAriaLabel() {
const comboBoxElement = (this.$refs.select as ComponentPublicInstance).$el.querySelector(
'div:first-child'
)
comboBoxElement.setAttribute('aria-label', this.$gettext('Search for option'))
},
userInput(event) {
/**
* Triggers when a value of search input is changed
*
* @property {string} query search query
*/
this.$emit('search:input', event.target.value)
}
}
})
</script>
Expand Down Expand Up @@ -292,6 +340,10 @@ export default defineComponent({
width: 100%;
}
&__selected-readonly {
background-color: var(--oc-color-background-muted) !important;
}
&__search,
&__search:focus {
padding: 0 5px;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<oc-select
:model-value="selectedOption"
class="oc-mb-s"
multiple
:multiple="true"
:options="groupOptions"
option-label="displayName"
:label="$gettext('Groups')"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ exports[`EditPanel renders all available inputs 1`] = `
<oc-text-input-stub class="oc-mb-s" clearbuttonaccessiblelabel="" clearbuttonenabled="false" disabled="false" errormessage="" fixmessageline="true" id="email-input" label="Email" modelvalue="jan@owncloud.com" type="email"></oc-text-input-stub>
<oc-text-input-stub class="oc-mb-s" clearbuttonaccessiblelabel="" clearbuttonenabled="false" disabled="false" fixmessageline="true" id="password-input" label="Password" modelvalue="" placeholder="●●●●●●●●" type="password"></oc-text-input-stub>
<div class="oc-mb-s">
<oc-select-stub clearable="false" disabled="false" filter="[Function]" fixmessageline="false" id="role-input" label="Role" loading="false" model-value="[object Object]" optionlabel="displayName" options="[object Object]" searchable="true"></oc-select-stub>
<oc-select-stub clearable="false" disabled="false" filter="[Function]" fixmessageline="false" id="role-input" label="Role" loading="false" model-value="[object Object]" multiple="false" optionlabel="displayName" options="[object Object]" searchable="true"></oc-select-stub>
<div class="oc-text-input-message"></div>
</div>
<div class="oc-mb-s">
<oc-select-stub clearable="false" disabled="false" filter="[Function]" fixmessageline="false" id="login-input" label="Login" loading="false" model-value="[object Object]" options="[object Object],[object Object]" searchable="true"></oc-select-stub>
<oc-select-stub clearable="false" disabled="false" filter="[Function]" fixmessageline="false" id="login-input" label="Login" loading="false" model-value="[object Object]" multiple="false" optionlabel="label" options="[object Object],[object Object]" searchable="true"></oc-select-stub>
<div class="oc-text-input-message"></div>
</div>
<quota-select-stub class="oc-mb-s" descriptionmessage="" disabled="false" fixmessageline="true" id="quota-select-form" maxquota="0" title="Personal quota" totalquota="0"></quota-select-stub>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

exports[`GroupSelect renders the select input 1`] = `
<div id="user-group-select-form">
<oc-select-stub class="oc-mb-s" clearable="false" disabled="false" filter="[Function]" fixmessageline="true" id="oc-select-1" label="Groups" loading="false" model-value="undefined" multiple="" optionlabel="displayName" options="undefined" searchable="true"></oc-select-stub>
<oc-select-stub class="oc-mb-s" clearable="false" disabled="false" filter="[Function]" fixmessageline="true" id="oc-select-1" label="Groups" loading="false" model-value="undefined" multiple="true" optionlabel="displayName" options="undefined" searchable="true"></oc-select-stub>
</div>
`;
Loading

0 comments on commit 9f87057

Please sign in to comment.