diff --git a/docs/components/table.md b/docs/components/table.md index 4c02ef8f9d..de46b665a1 100644 --- a/docs/components/table.md +++ b/docs/components/table.md @@ -438,6 +438,7 @@ Pass in an array of header objects for the table. | `key` | string | A unique key for the column | | `label` | string | The label displayed on the table for the column | | `sortable` | boolean | Enables or disables server-side sorting for the column (`false` by default) | +| `hidable` | boolean | Enable show/hide for this column | | `hideLabel` | boolean | Hides or displays the column label (useful for actions columns) | | `useSortHandlerFn` | boolean | Uses the function passed in the [sortHandlerFn](#sorthandlerfn) prop to sort the column data instead of the default client sorter function | @@ -465,7 +466,7 @@ Example headers array: { key: 'name', label: 'Name', sortable: true }, { key: 'email', label: 'Email', sortable: true }, { key: 'department', label: 'department', sortable: true }, - { key: 'start_date', label: 'Start Date', sortable: true }, + { key: 'start_date', label: 'Start Date', sortable: true, hidable: true }, { key: 'actions', label: '', sortable: false, hideLabel: true }, ] } @@ -474,6 +475,41 @@ Example headers array: ``` +#### hidable + +Using this modal will cause the column visibility menu to be displayed above the table after any toolbar content. Hovering over a column with `hidable: true` will trigger the display of a hide button in the header. An `@update:table-preferences` event is emitted whenever changes are applied. + + + +```html + + + +``` + ### initialFetcherParams Pass in an object of parameters for the initial fetcher function call. If not provided, will default to the following values: @@ -922,6 +958,8 @@ interface TablePreferences { sortColumnOrder?: 'asc' | 'desc' /** The customized column widths, if resizing is allowed */ columnWidths?: Record + /** Column visibility, if visibility is toggleable */ + columnVisibility?: Record } ``` @@ -1597,6 +1635,7 @@ export default defineComponent({ enableRowClick: true, offsetPaginationPageSize: 15, offsetPaginationData: {}, + hidablePreferences: {}, headers: [ { label: 'Title', key: 'title', sortable: true }, { label: 'Description', key: 'description', sortable: true }, @@ -1613,6 +1652,13 @@ export default defineComponent({ { label: 'Enabled', key: 'enabled' }, { key: 'actions', hideLabel: true } ], + hideColumnHeaders: [ + { label: 'Host', key: 'hostname' }, + { label: 'Version', key: 'version' }, + { label: 'Connected', key: 'connected' }, + { label: 'Last Ping', key: 'last_ping', hidable: true }, + { label: 'Last Seen', key: 'last_seen', hidable: true }, + ], tableOptionsRowAttrsHeaders: [ { label: 'Type', key: 'type' }, { label: 'Value', key: 'value' }, @@ -1708,6 +1754,42 @@ export default defineComponent({ emptyFetcher () { return { data: [] } }, + tableHideColumnFetcher () { + return { + data: [{ + id: '08cc7d81-a9d8-4ae1-a42f-8d4e5a919d07', + version: '2.8.0.0-enterprise-edition', + hostname: '99e591ae3776', + last_ping: 1648855072, + connected: 'Disconnected', + last_seen: '6 days ago' + }, + { + id: '08cc7d81-a9d8-4ae1-a42f-8d4e5a919d07', + version: '2.7.0.0-enterprise-edition', + hostname: '19e591ae3776', + last_ping: 1649362660, + connected: 'Connected', + last_seen: '3 hours ago', + }, + { + id: '08cc7d81-a9d8-4ae1-a42f-8d4e5a919d07', + version: '2.8.1.0-enterprise-edition', + hostname: '79e591ae3776', + last_ping: 1649355460, + connected: 'Connected', + last_seen: '5 hours ago', + }, + { + id: '08cc7d81-a9d8-4ae1-a42f-8d4e5a919d07', + version: '2.6.0.0-enterprise-edition', + hostname: '89e591ae3776', + last_ping: 1648155072, + connected: 'Disconnected', + last_seen: '14 days ago' + }] + } + }, tableOptionsLinkFetcher () { return { data: [ diff --git a/src/components/KCheckbox/KCheckbox.vue b/src/components/KCheckbox/KCheckbox.vue index e56906deeb..08174937f7 100644 --- a/src/components/KCheckbox/KCheckbox.vue +++ b/src/components/KCheckbox/KCheckbox.vue @@ -3,7 +3,10 @@ class="k-checkbox" :class="[$attrs.class, kCheckboxClasses ]" > -
+
+
+ + + + + + + + + +
+ + + + + diff --git a/src/components/KTable/KTable.cy.ts b/src/components/KTable/KTable.cy.ts index 380beaf603..1ba1f32a42 100644 --- a/src/components/KTable/KTable.cy.ts +++ b/src/components/KTable/KTable.cy.ts @@ -251,6 +251,37 @@ describe('KTable', () => { cy.get('.k-table').find('th.resizable').should('be.visible') cy.get('.resize-handle').should('exist') }) + + it('renders column show/hide when headers.hidable is set', () => { + // make ID column hidable + options.headers[1].hidable = true + const modifiedHeaderKey = options.headers[1].key + + mount(KTable, { + props: { + testMode: 'true', + headers: options.headers, + fetcher: () => { return { data: options.data } }, + }, + }) + + cy.get('.k-table').should('be.visible') + // menu button is visible + cy.getTestId('column-visibility-menu-button').should('be.visible') + cy.getTestId('column-visibility-menu-button').click() + + // only columns with hidable set to true should be visible and checked by default + cy.getTestId(`column-visibility-menu-item-${modifiedHeaderKey}`).should('be.visible') + cy.getTestId(`column-visibility-menu-item-${options.headers[0].key}`).should('not.exist') + cy.getTestId(`column-visibility-checkbox-${modifiedHeaderKey}`).should('be.visible') + cy.getTestId(`column-visibility-checkbox-${modifiedHeaderKey}`).should('be.checked') + + // changes are applied only when Apply button is clicked + cy.getTestId(`column-visibility-checkbox-${modifiedHeaderKey}`).click() + cy.getTestId(`k-table-header-${modifiedHeaderKey}`).should('be.visible') + cy.getTestId('apply-button').click() + cy.getTestId(`k-table-header-${modifiedHeaderKey}`).should('not.exist') + }) }) describe('data revalidates and changes as expected', () => { diff --git a/src/components/KTable/KTable.vue b/src/components/KTable/KTable.vue index 75b66749c1..2a8aad3508 100644 --- a/src/components/KTable/KTable.vue +++ b/src/components/KTable/KTable.vue @@ -9,6 +9,13 @@ name="toolbar" :state="stateData" /> +
import type { Ref, PropType } from 'vue' import { ref, watch, computed, onMounted, useAttrs, useSlots } from 'vue' -import { v1 as uuidv1 } from 'uuid' +import { v4 as uuidv4 } from 'uuid' import KButton from '@/components/KButton/KButton.vue' import KEmptyState from '@/components/KEmptyState/KEmptyState.vue' import KSkeleton from '@/components/KSkeleton/KSkeleton.vue' @@ -250,6 +257,7 @@ import { EmptyStateIconVariants, } from '@/types' import { KUI_COLOR_TEXT, KUI_ICON_SIZE_20 } from '@kong/design-tokens' +import ColumnVisibilityMenu from './ColumnVisibilityMenu.vue' const { useDebounce, useRequest, useSwrvState } = useUtilities() @@ -273,6 +281,14 @@ const props = defineProps({ type: Boolean, default: false, }, + /** + * Used to customize the initial state of the table. + * Column visibility/width. + */ + tablePreferences: { + type: Object as PropType, + default: () => ({}), + }, /** * Enable client side sort - only do this if using a fetcher * that returns static data @@ -541,7 +557,7 @@ const emit = defineEmits<{ const attrs = useAttrs() const slots = useSlots() -const tableId = computed((): string => props.testMode ? 'test-table-id-1234' : uuidv1()) +const tableId = computed((): string => props.testMode ? 'test-table-id-1234' : uuidv4()) const defaultFetcherProps = { pageSize: 15, page: 1, @@ -552,13 +568,23 @@ const defaultFetcherProps = { } const data = ref[]>([]) const headerRow = ref() -const tableHeaders: Ref = ref([]) +// all headers +const tableHeaders = ref([]) +// currently visible headers +const visibleHeaders = ref([]) // highest priority - column currently being resized (mouse may be completely outside the column) const resizingColumn = ref('') // column the user is currently hovering over the resize handle for (may be hovered on the adjacent column to what we want to resize) const resizerHoveredColumn = ref('') // lowest priority - currently hovered resizable column (mouse is somewhere in the ) const currentHoveredColumn = ref('') +const hasColumnVisibilityMenu = computed((): boolean => tableHeaders.value.filter((header: TableHeader) => header.hidable).length > 0) +// columns whose visibility can be toggled +const visibilityColumns = computed((): TableHeader[] => tableHeaders.value.filter((header: TableHeader) => header.hidable)) +// visibility preferences from the host app (initialized by app) +const visibilityPreferences = computed((): Record => props.tablePreferences.columnVisibility || {}) +// current column visibility state +const columnVisibility = ref>({}) const total = ref(0) const isScrolled = ref(false) const page = ref(1) @@ -572,7 +598,7 @@ const hasNextPage = ref(true) const isClickable = ref(false) const hasInitialized = ref(false) const nextPageClicked = ref(false) -const hasToolbarSlot = computed((): boolean => !!slots.toolbar) +const hasToolbarSlot = computed((): boolean => !!slots.toolbar || hasColumnVisibilityMenu.value) /** * Utilize a helper function to generate the column slot name. @@ -702,7 +728,7 @@ const columnStyles = computed(() => { const getHeaderClasses = (column: TableHeader, index: number): Record => { return { // display the resize handle on the right side of the column if resizeColumns is enabled, hovering current column, and not the last column - 'resize-hover': resizeHoverColumn.value === column.key && props.resizeColumns && index !== tableHeaders.value.length - 1, + 'resize-hover': resizeHoverColumn.value === column.key && props.resizeColumns && index !== visibleHeaders.value.length - 1, 'truncated-column resizable': props.resizeColumns, // display sort control if column is sortable, label is visible, and sorting is not disabled sortable: !props.disableSorting && !column.hideLabel && !!column.sortable, @@ -1007,6 +1033,7 @@ const tablePreferences = computed((): TablePreferences => ({ sortColumnKey: sortColumnKey.value, sortColumnOrder: sortColumnOrder.value as 'asc' | 'desc', ...(props.resizeColumns ? { columnWidths: columnWidths.value } : {}), + ...(hasColumnVisibilityMenu.value ? { columnVisibility: columnVisibility.value } : {}), })) const emitTablePreferences = (): void => { @@ -1040,6 +1067,17 @@ const getTestIdString = (message: string): string => { return msg } +watch([columnVisibility, tableHeaders], (newVals) => { + const newVisibility = newVals[0] + const newHeaders = newVals[1] + const newVisibleHeaders = newHeaders.filter((header: TableHeader) => newVisibility[header.key] !== false) + + if (JSON.stringify(newVisibleHeaders) !== JSON.stringify(visibleHeaders.value)) { + visibleHeaders.value = newVisibleHeaders + emitTablePreferences() + } +}, { deep: true, immediate: true }) + watch(fetcherData, (fetchedData: any) => { if (fetchedData?.length && !data.value.length) { data.value = fetchedData @@ -1138,7 +1176,10 @@ export const defaultSorter = (key: string, previousKey: string, sortOrder: strin } .k-table-toolbar { + column-gap: var(--kui-space-50, $kui-space-50); + display: flex; margin-bottom: var(--kui-space-80, $kui-space-80) !important; + width: 100%; & > :deep(*) { display: flex; diff --git a/src/types/table.ts b/src/types/table.ts index dfa1830f08..fb5c0fa63e 100644 --- a/src/types/table.ts +++ b/src/types/table.ts @@ -11,6 +11,8 @@ export interface TablePreferences { sortColumnOrder?: SortColumnOrder /** The customized column widths, if resizing is allowed */ columnWidths?: Record + /** Column visibility, if visibility is toggleable */ + columnVisibility?: Record } export const TablePaginationTypeArray = ['default', 'offset'] as const @@ -21,6 +23,7 @@ export interface TableHeader { key: string label: string sortable?: boolean + hidable?: boolean hideLabel?: boolean useSortHandlerFn?: boolean }