Browse Source

Merge pull request #7218 from nocodb/fix/ui-ux-2

UI/UX fixes - 2
pull/7269/head
Raju Udava 9 months ago committed by GitHub
parent
commit
ff61e9f7f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 7
      packages/nc-gui/assets/style.scss
  2. 43
      packages/nc-gui/components/cell/MultiSelect.vue
  3. 45
      packages/nc-gui/components/cell/SingleSelect.vue
  4. 2
      packages/nc-gui/components/cell/TextArea.vue
  5. 6
      packages/nc-gui/components/smartsheet/Kanban.vue
  6. 20
      packages/nc-gui/components/smartsheet/grid/GroupBy.vue
  7. 5
      packages/nc-gui/components/smartsheet/grid/GroupByTable.vue
  8. 17
      packages/nc-gui/components/smartsheet/grid/Table.vue
  9. 2
      packages/nc-gui/components/smartsheet/toolbar/ViewInfo.vue
  10. 4
      packages/nc-gui/components/webhook/Editor.vue
  11. 5
      packages/nc-gui/composables/useData.ts
  12. 22
      packages/nc-gui/utils/dataUtils.ts
  13. 11
      tests/playwright/pages/Dashboard/Grid/index.ts
  14. 14
      tests/playwright/tests/db/features/timezone.spec.ts
  15. 11
      tests/playwright/tests/db/features/undo-redo.spec.ts
  16. 37
      tests/playwright/tests/db/features/webhook.spec.ts

7
packages/nc-gui/assets/style.scss

@ -43,10 +43,6 @@ body {
height: var(--topbar-height) !important;
}
.anticon-check-circle {
@apply !relative top-[-1px] left-0;
}
html,
body,
#__nuxt,
@ -701,4 +697,7 @@ input[type='number'] {
.ant-message-notice-content {
@apply !rounded-md;
.ant-message-custom-content{
@apply flex items-center
}
}

43
packages/nc-gui/components/cell/MultiSelect.vue

@ -357,7 +357,7 @@ const selectedOpts = computed(() => {
}"
>
<template v-for="selectedOpt of selectedOpts" :key="selectedOpt.value">
<a-tag class="rounded-tag" :color="selectedOpt.color">
<a-tag class="rounded-tag max-w-full" :color="selectedOpt.color">
<span
:style="{
'color': tinycolor.isReadable(selectedOpt.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
@ -367,7 +367,21 @@ const selectedOpts = computed(() => {
}"
:class="{ 'text-sm': isKanban }"
>
{{ selectedOpt.title }}
<NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
{{ selectedOpt.title }}
</template>
<span
class="text-ellipsis overflow-hidden"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{ selectedOpt.title }}
</span>
</NcTooltip>
</span>
</a-tag>
</template>
@ -402,7 +416,7 @@ const selectedOpts = computed(() => {
:class="`nc-select-option-${column.title}-${op.title}`"
@click.stop
>
<a-tag class="rounded-tag" :color="op.color">
<a-tag class="rounded-tag max-w-full" :color="op.color">
<span
:style="{
'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
@ -412,7 +426,21 @@ const selectedOpts = computed(() => {
}"
:class="{ 'text-sm': isKanban }"
>
{{ op.title }}
<NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
{{ op.title }}
</template>
<span
class="text-ellipsis overflow-hidden"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{ op.title }}
</span>
</NcTooltip>
</span>
</a-tag>
</a-select-option>
@ -530,3 +558,10 @@ const selectedOpts = computed(() => {
@apply !text-xs;
}
</style>
<style lang="scss">
.ant-select-item-option-content,
.ant-select-item-option-state {
@apply !flex !items-center;
}
</style>

45
packages/nc-gui/components/cell/SingleSelect.vue

@ -263,8 +263,8 @@ const selectedOpt = computed(() => {
<template>
<div class="h-full w-full flex items-center nc-single-select" :class="{ 'read-only': readOnly }" @click="toggleMenu">
<div v-if="!(active || isEditable)">
<a-tag v-if="selectedOpt" class="rounded-tag" :color="selectedOpt.color">
<div v-if="!(active || isEditable)" class="w-full">
<a-tag v-if="selectedOpt" class="rounded-tag max-w-full" :color="selectedOpt.color">
<span
:style="{
'color': tinycolor.isReadable(selectedOpt.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
@ -274,7 +274,21 @@ const selectedOpt = computed(() => {
}"
:class="{ 'text-sm': isKanban }"
>
{{ selectedOpt.title }}
<NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
{{ selectedOpt.title }}
</template>
<span
class="text-ellipsis overflow-hidden"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{ selectedOpt.title }}
</span>
</NcTooltip>
</span>
</a-tag>
</div>
@ -305,7 +319,7 @@ const selectedOpt = computed(() => {
:class="`nc-select-option-${column.title}-${op.title}`"
@click.stop
>
<a-tag class="rounded-tag" :color="op.color">
<a-tag class="rounded-tag max-w-full" :color="op.color">
<span
:style="{
'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
@ -315,7 +329,21 @@ const selectedOpt = computed(() => {
}"
:class="{ 'text-sm': isKanban }"
>
{{ op.title }}
<NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
{{ op.title }}
</template>
<span
class="text-ellipsis overflow-hidden"
:style="{
wordBreak: 'keep-all',
whiteSpace: 'nowrap',
display: 'inline',
}"
>
{{ op.title }}
</span>
</NcTooltip>
</span>
</a-tag>
</a-select-option>
@ -342,6 +370,7 @@ const selectedOpt = computed(() => {
:deep(.ant-select-clear) {
opacity: 1;
border-radius: 100%;
}
.nc-single-select:not(.read-only) {
@ -363,3 +392,9 @@ const selectedOpt = computed(() => {
@apply block;
}
</style>
<style lang="scss">
.ant-select-item-option-content {
@apply !flex !items-center;
}
</style>

2
packages/nc-gui/components/cell/TextArea.vue

@ -239,7 +239,7 @@ watch(editEnabled, () => {
<NcTooltip
v-if="!isVisible"
placement="bottom"
class="!absolute right-0 bottom-1 nc-text-area-expand-btn"
class="!absolute right-0 bottom-1 hidden nc-text-area-expand-btn"
:class="{ 'right-0 bottom-1': editEnabled, '!bottom-0': !isRichMode }"
>
<template #title>{{ $t('title.expand') }}</template>

6
packages/nc-gui/components/smartsheet/Kanban.vue

@ -402,10 +402,10 @@ const getRowId = (row: RowType) => {
>
<div
ref="kanbanContainerRef"
class="nc-kanban-container flex mt-4 pb-4 px-4 overflow-y-hidden w-full nc-scrollbar-x-md"
class="nc-kanban-container flex mt-4 pb-4 px-4 overflow-y-hidden w-full nc-scrollbar-x-lg"
:style="{
minHeight: 'calc(100vh - var(--topbar-height) - 3.5rem)',
maxHeight: 'calc(100vh - var(--topbar-height) - 3.5rem)',
minHeight: 'calc(100vh - var(--topbar-height) - 4.1rem)',
maxHeight: 'calc(100vh - var(--topbar-height) - 4.1rem)',
}"
>
<div v-if="isViewDataLoading" class="flex flex-row min-h-full gap-x-2">

20
packages/nc-gui/components/smartsheet/grid/GroupBy.vue

@ -1,6 +1,7 @@
<script lang="ts" setup>
import tinycolor from 'tinycolor2'
import { UITypes } from 'nocodb-sdk'
import dayjs from 'dayjs'
import { UITypes, dateFormats, timeFormats } from 'nocodb-sdk'
import Table from './Table.vue'
import GroupBy from './GroupBy.vue'
import GroupByTable from './GroupByTable.vue'
@ -139,7 +140,7 @@ const onScroll = (e: Event) => {
// a method to parse group key if grouped column type is LTAR or Lookup
// in these 2 scenario it will return json array or `___` separated value
const parseKey = (group) => {
const parseKey = (group: Group) => {
let key = group.key.toString()
// parse json array key if it's a lookup or link to another record
@ -151,6 +152,21 @@ const parseKey = (group) => {
return key.split('___')
}
}
// show the groupBy dateTime field title format as like cell format
if (key && group.column?.uidt === UITypes.DateTime && dayjs(key).isValid()) {
const dateFormat = parseProp(group.column?.meta)?.date_format ?? dateFormats[0]
const timeFormat = parseProp(group.column?.meta)?.time_format ?? timeFormats[0]
const dateTimeFormat = `${dateFormat} ${timeFormat}`
return [dayjs(key).utc().local().format(dateTimeFormat)]
}
// show the groupBy time field title format as like cell format
if (key && group.column?.uidt === UITypes.Time && dayjs(key).isValid()) {
return [dayjs(key).format(timeFormats[0])]
}
return [key]
}

5
packages/nc-gui/components/smartsheet/grid/GroupByTable.vue

@ -1,7 +1,7 @@
<script lang="ts" setup>
import { UITypes, isLinksOrLTAR } from 'nocodb-sdk'
import Table from './Table.vue'
import { IsGroupByInj, computed, ref } from '#imports'
import { IsGroupByInj, computed, ref, rowDefaultData } from '#imports'
import type { Group, Row } from '#imports'
const props = defineProps<{
@ -38,7 +38,7 @@ const view = inject(ActiveViewInj, ref())
const reloadViewDataHook = inject(ReloadViewDataHookInj, createEventHook())
function addEmptyRow(group: Group, addAfter?: number) {
function addEmptyRow(group: Group, addAfter?: number, metaValue = meta.value) {
if (group.nested || !group.rows) return
addAfter = addAfter ?? group.rows.length
@ -57,6 +57,7 @@ function addEmptyRow(group: Group, addAfter?: number) {
group.rows.splice(addAfter, 0, {
row: {
...rowDefaultData(metaValue?.columns),
...setGroup,
},
oldRow: {},

17
packages/nc-gui/components/smartsheet/grid/Table.vue

@ -448,7 +448,8 @@ const closeAddColumnDropdown = (scrollToLastCol = false) => {
}
async function openNewRecordHandler() {
const newRow = addEmptyRow()
// skip update row when it is `New record form`
const newRow = addEmptyRow(dataRef.value.length, true)
if (newRow) expandForm?.(newRow, undefined, true)
}
@ -697,8 +698,17 @@ function scrollToRow(row?: number) {
scrollToCell?.()
}
function addEmptyRow(row?: number) {
async function saveEmptyRow(rowObj: Row) {
await updateOrSaveRow?.(rowObj)
}
function addEmptyRow(row?: number, skipUpdate: boolean = false) {
const rowObj = callAddEmptyRow?.(row)
if (!skipUpdate && rowObj) {
saveEmptyRow(rowObj)
}
nextTick().then(() => {
scrollToRow(row ?? dataRef.value.length - 1)
})
@ -1484,7 +1494,6 @@ onKeyStroke('ArrowDown', onDown)
>
<div class="items-center flex gap-1 min-w-[60px]">
<div
v-if="!readOnly || isMobileMode"
class="nc-row-no sm:min-w-4 text-xs text-gray-500"
:class="{ toggle: !readOnly, hidden: row.rowMeta.selected }"
>
@ -1696,7 +1705,7 @@ onKeyStroke('ArrowDown', onDown)
</NcMenuItem>
<NcMenuItem
v-if="contextMenuTarget"
v-if="contextMenuTarget && hasEditPermission"
class="nc-base-menu-item"
data-testid="context-menu-item-paste"
:disabled="isSystemColumn(fields[contextMenuTarget.col])"

2
packages/nc-gui/components/smartsheet/toolbar/ViewInfo.vue

@ -90,7 +90,7 @@ const openedBaseUrl = computed(() => {
'max-w-none': isSharedBase && !isMobileMode,
}"
>
<NcTooltip class="truncate nc-active-table-title max-w-full">
<NcTooltip class="truncate nc-active-table-title max-w-full" show-on-truncate-only>
<template #title>
{{ activeTable?.title }}
</template>

4
packages/nc-gui/components/webhook/Editor.vue

@ -500,6 +500,9 @@ watch(
if (props.hook) {
setHook(props.hook)
onEventChange()
} else {
// Set the default hook title only when creating a new hook.
hookRef.title = getDefaultHookName(hooks.value)
}
},
{ immediate: true },
@ -513,7 +516,6 @@ onMounted(async () => {
} else {
hookRef.eventOperation = eventList.value[0].value.join(' ')
}
hookRef.title = getDefaultHookName(hooks.value)
onNotificationTypeChange()

5
packages/nc-gui/composables/useData.ts

@ -9,6 +9,7 @@ import {
findIndexByPk,
message,
populateInsertObject,
rowDefaultData,
rowPkData,
storeToRefs,
until,
@ -53,9 +54,9 @@ export function useData(args: {
},
})
function addEmptyRow(addAfter = formattedData.value.length) {
function addEmptyRow(addAfter = formattedData.value.length, metaValue = meta.value) {
formattedData.value.splice(addAfter, 0, {
row: {},
row: { ...rowDefaultData(metaValue?.columns) },
oldRow: {},
rowMeta: { new: true },
})

22
packages/nc-gui/utils/dataUtils.ts

@ -1,4 +1,4 @@
import { RelationTypes, UITypes } from 'nocodb-sdk'
import { RelationTypes, UITypes, isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import type { Row } from 'lib'
import { isColumnRequiredAndNull } from './columnUtils'
@ -86,3 +86,23 @@ export async function populateInsertObject({
return { missingRequiredColumns, insertObj }
}
// a function to get default values of row
export const rowDefaultData = (columns: ColumnType[] = []) => {
const defaultData: Record<string, string> = columns.reduce<Record<string, any>>((acc: Record<string, any>, col: ColumnType) => {
// avoid setting default value for system col, virtual col, rollup, formula, barcode, qrcode, links, ltar
if (
!isSystemColumn(col) &&
!isVirtualCol(col) &&
!isLinksOrLTAR({ uidt: col.uidt! }) &&
![UITypes.Rollup, UITypes.Lookup, UITypes.Formula, UITypes.Barcode, UITypes.QrCode].includes(col.uidt) &&
col?.cdf
) {
const defaultValue = col.cdf
acc[col.title!] = typeof defaultValue === 'string' ? defaultValue.replace(/^'/, '').replace(/'$/, '') : defaultValue
}
return acc
}, {} as Record<string, any>)
return defaultData
}

11
tests/playwright/pages/Dashboard/Grid/index.ts

@ -123,6 +123,9 @@ export class GridPage extends BasePage {
await this.get().locator('.nc-grid-add-new-cell').click();
// wait for insert row response
await this.rootPage.waitForTimeout(400);
const rowCount = index + 1;
await expect(this.get().locator('.nc-grid-row')).toHaveCount(rowCount);
@ -135,9 +138,13 @@ export class GridPage extends BasePage {
await this.waitForResponse({
uiAction: clickOnColumnHeaderToSave,
requestUrlPathToMatch: 'api/v1/db/data/noco',
httpMethodsToMatch: ['POST'],
httpMethodsToMatch: [
// if the row does not contain the required cell, editing the row cell will emit a PATCH request; otherwise, it will emit a POST request.
'PATCH',
'POST',
],
// numerical types are returned in number format from the server
responseJsonMatcher: resJson => String(resJson?.[columnHeader]) === String(rowValue),
responseJsonMatcher: resJson => String(resJson?.[columnHeader]) === String(value),
});
} else {
await clickOnColumnHeaderToSave();

14
tests/playwright/tests/db/features/timezone.spec.ts

@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test';
import { DashboardPage } from '../../../pages/Dashboard';
import setup, { NcContext, unsetup } from '../../../setup';
import { Api, ProjectListType, UITypes } from 'nocodb-sdk';
import { Api, PaginatedType, ProjectListType, UITypes } from 'nocodb-sdk';
import { enableQuickRun, isEE, isMysql, isPg, isSqlite } from '../../../setup/db';
import { getKnexConfig } from '../../utils/config';
import { getBrowserTimezoneOffset } from '../../utils/general';
@ -778,7 +778,11 @@ test.describe.serial('Timezone- ExtDB : DateTime column, Browser Timezone same a
// Hence, we skip seconds from API response
//
const records = await api.dbTableRow.list('noco', context.base.id, 'MyTable', { limit: 10 });
const records = (await api.dbTableRow.list('noco', context.base.id, 'MyTable', { limit: 10 })) as {
list: Record<string, any>[];
pageInfo: PaginatedType;
};
records.list = records.list.filter(record => record.DatetimeWithoutTz && record.DatetimeWithTz);
let dateTimeWithoutTz = records.list.map(record => record.DatetimeWithoutTz);
let dateTimeWithTz = records.list.map(record => record.DatetimeWithTz);
@ -1082,7 +1086,11 @@ test.describe.serial('Timezone- ExtDB (MySQL Only) : DB Timezone configured as H
// Hence, we skip seconds from API response
//
const records = await api.dbTableRow.list('sakila', context.base.id, 'MyTable', { limit: 10 });
const records = (await api.dbTableRow.list('sakila', context.base.id, 'MyTable', { limit: 10 })) as {
list: Record<string, any>[];
pageInfo: PaginatedType;
};
records.list = records.list.filter(record => record.DatetimeWithoutTz && record.DatetimeWithTz);
let dateTimeWithoutTz = records.list.map(record => record.DatetimeWithoutTz);
let dateTimeWithTz = records.list.map(record => record.DatetimeWithTz);

11
tests/playwright/tests/db/features/undo-redo.spec.ts

@ -134,13 +134,13 @@ test.describe('Undo Redo', () => {
await dashboard.treeView.openTable({ title: 'numberBased' });
// Row.Create
await grid.addNewRow({ index: 10, value: '333', columnHeader: 'Number', networkValidation: true });
await grid.addNewRow({ index: 11, value: '444', columnHeader: 'Number', networkValidation: true });
await grid.addNewRow({ index: 10, value: '333', columnHeader: 'Number' });
await grid.addNewRow({ index: 11, value: '444', columnHeader: 'Number' });
await verifyRecords([333, 444]);
// Row.Update
await grid.editRow({ index: 10, value: '555', columnHeader: 'Number', networkValidation: true });
await grid.editRow({ index: 11, value: '666', columnHeader: 'Number', networkValidation: true });
await grid.editRow({ index: 10, value: '555', columnHeader: 'Number' });
await grid.editRow({ index: 11, value: '666', columnHeader: 'Number' });
await verifyRecords([555, 666]);
// Row.Delete
@ -162,8 +162,11 @@ test.describe('Undo Redo', () => {
// Undo : Row.Create
await undo({ page, dashboard });
await verifyRecords([333, NaN]);
await undo({ page, dashboard });
await verifyRecords([333]);
await undo({ page, dashboard });
await undo({ page, dashboard });
await verifyRecords([]);
});

37
tests/playwright/tests/db/features/webhook.spec.ts

@ -9,6 +9,12 @@ import { enableQuickRun, isEE, isMysql, isSqlite } from '../../../setup/db';
const hookPath = 'http://localhost:9090/hook';
/**
* @note AddNewRow function makes two requests:
* 1. Creates a blank row with default values (POST) if current cell is not required cell.
* 2. Fills cell values (PATCH) if current cell is not required cell else (POST).
*/
// clear server data
async function clearServerData({ request }) {
// clear stored data in server
@ -39,7 +45,7 @@ async function getWebhookResponses({ request, count = 1 }) {
return await response.json();
}
async function verifyHookTrigger(count: number, value: string, request, expectedData?: any) {
async function verifyHookTrigger(count: number, value: string | null, request, expectedData?: any) {
// Retry since there can be lag between the time the hook is triggered and the time the server receives the request
let response: { json: () => any };
@ -160,14 +166,15 @@ test.describe.serial('Webhook', () => {
columnHeader: 'Title',
value: 'Poole',
});
await verifyHookTrigger(1, 'Poole', request, buildExpectedResponseData('records.after.insert', 'Poole'));
await verifyHookTrigger(1, null, request, buildExpectedResponseData('records.after.insert', null));
// trigger edit row & delete row
// verify that the hook is not triggered (count doesn't change in this case)
await dashboard.grid.editRow({ index: 0, value: 'Delaware' });
await verifyHookTrigger(1, 'Poole', request);
await verifyHookTrigger(1, null, request);
await dashboard.grid.deleteRow(0);
await verifyHookTrigger(1, 'Poole', request);
await verifyHookTrigger(1, null, request);
///////////////////////////////////////////////////////////////////////////
@ -192,16 +199,17 @@ test.describe.serial('Webhook', () => {
columnHeader: 'Title',
value: 'Poole',
});
await verifyHookTrigger(1, 'Poole', request, buildExpectedResponseData('records.after.insert', 'Poole'));
await verifyHookTrigger(2, 'Poole', request, buildExpectedResponseData('records.after.update', 'Poole'));
await dashboard.grid.editRow({ index: 0, value: 'Delaware' });
await verifyHookTrigger(
2,
3,
'Delaware',
request,
buildExpectedResponseData('records.after.update', 'Delaware', 'Poole')
);
await dashboard.grid.deleteRow(0);
await verifyHookTrigger(2, 'Delaware', request);
await verifyHookTrigger(3, 'Delaware', request);
///////////////////////////////////////////////////////////////////////////
@ -224,16 +232,17 @@ test.describe.serial('Webhook', () => {
columnHeader: 'Title',
value: 'Poole',
});
await verifyHookTrigger(1, 'Poole', request, buildExpectedResponseData('records.after.insert', 'Poole'));
await verifyHookTrigger(2, 'Poole', request, buildExpectedResponseData('records.after.update', 'Poole'));
await dashboard.grid.editRow({ index: 0, value: 'Delaware' });
await verifyHookTrigger(
2,
3,
'Delaware',
request,
buildExpectedResponseData('records.after.update', 'Delaware', 'Poole')
);
await dashboard.grid.deleteRow(0);
await verifyHookTrigger(3, 'Delaware', request, buildExpectedResponseData('records.after.delete', 'Delaware'));
await verifyHookTrigger(4, 'Delaware', request, buildExpectedResponseData('records.after.delete', 'Delaware'));
///////////////////////////////////////////////////////////////////////////
@ -379,7 +388,7 @@ test.describe.serial('Webhook', () => {
columnHeader: 'Title',
value: 'Delaware',
});
await verifyHookTrigger(1, 'Poole', request, buildExpectedResponseData('records.after.insert', 'Poole'));
await verifyHookTrigger(1, 'Poole', request, buildExpectedResponseData('records.after.update', 'Poole'));
await dashboard.grid.editRow({ index: 0, value: 'Delaware' });
await dashboard.grid.editRow({ index: 1, value: 'Poole' });
await verifyHookTrigger(
@ -421,18 +430,18 @@ test.describe.serial('Webhook', () => {
columnHeader: 'Title',
value: 'Delaware',
});
await verifyHookTrigger(2, 'Delaware', request, buildExpectedResponseData('records.after.insert', 'Delaware'));
await verifyHookTrigger(4, 'Delaware', request, buildExpectedResponseData('records.after.insert', 'Delaware'));
await dashboard.grid.editRow({ index: 0, value: 'Delaware' });
await dashboard.grid.editRow({ index: 1, value: 'Poole' });
await verifyHookTrigger(
4,
6,
'Poole',
request,
buildExpectedResponseData('records.after.update', 'Poole', 'Delaware')
);
await dashboard.grid.deleteRow(1);
await dashboard.grid.deleteRow(0);
await verifyHookTrigger(6, 'Delaware', request, buildExpectedResponseData('records.after.delete', 'Delaware'));
await verifyHookTrigger(8, 'Delaware', request, buildExpectedResponseData('records.after.delete', 'Delaware'));
});
test('Bulk operations', async ({ request, page }) => {

Loading…
Cancel
Save