Browse Source

Feature: Mobile - Add modal to edit users

Vladimir Sheremet 2 years ago
parent
commit
748bd534e2

+ 1 - 1
app/frontend/apps/mobile/components/CommonConfirmation/CommonConfirmation.vue

@@ -41,7 +41,7 @@ const callCancelCallback = (isCancel: boolean) => {
   >
     <template #header>
       <div
-        class="flex h-14 items-center justify-center border-b border-gray-300 text-white"
+        class="flex h-14 items-center justify-center border-b border-gray-300 text-center text-white"
       >
         {{ $t(confirmationDialog?.heading) }}
       </div>

+ 145 - 0
app/frontend/apps/mobile/components/CommonDialogObjectForm/CommonDialogObjectForm.vue

@@ -0,0 +1,145 @@
+<!-- Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import type { UseMutationReturn } from '@vue/apollo-composable'
+import CommonDialog from '@mobile/components/CommonDialog/CommonDialog.vue'
+import {
+  type FormSchemaNode,
+  type FormData,
+  useForm,
+} from '@shared/components/Form'
+import { closeDialog } from '@shared/composables/useDialog'
+import type {
+  EnumFormUpdaterId,
+  EnumObjectManagerObjects,
+  ObjectAttributeValue,
+} from '@shared/graphql/types'
+import { MutationHandler } from '@shared/server/apollo/handler'
+import type { ObjectLike } from '@shared/types/utils'
+import Form from '@shared/components/Form/Form.vue'
+import { camelize } from '@shared/utils/formatter'
+import { useObjectAttributes } from '@shared/entities/object-attributes/composables/useObjectAttributes'
+import useConfirmation from '../CommonConfirmation/composable'
+
+export interface Props {
+  name: string
+  object?: ObjectLike
+  type: EnumObjectManagerObjects
+  formUpdaterId?: EnumFormUpdaterId
+  errorNotificationMessage?: string
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  mutation: UseMutationReturn<any, any>
+  schema: FormSchemaNode[]
+}
+
+const props = defineProps<Props>()
+const emit = defineEmits<{
+  (e: 'success', data: unknown): void
+  (e: 'error'): void
+}>()
+
+const updateQuery = new MutationHandler(props.mutation, {
+  errorNotificationMessage: props.errorNotificationMessage,
+})
+const { form, isDirty, isDisabled, formSubmit } = useForm()
+
+const objectAtrributes: Record<string, string> =
+  props.object?.objectAttributeValues?.reduce(
+    (acc: Record<string, string>, cur: ObjectAttributeValue) => {
+      acc[cur.attribute.name] = cur.value
+      return acc
+    },
+    {},
+  ) || {}
+
+const initialValue = {
+  ...props.object,
+  ...objectAtrributes,
+}
+
+const { attributes: objectAttributes } = useObjectAttributes(props.type)
+const { waitForConfirmation } = useConfirmation()
+
+const cancelDialog = async () => {
+  if (isDirty.value) {
+    const confirmed = await waitForConfirmation(
+      __('Are you sure? You have unsaved changes that will get lost.'),
+    )
+
+    if (!confirmed) return
+  }
+
+  closeDialog(props.name)
+}
+
+const saveObject = async (formData: FormData) => {
+  const objectAttributeValues = objectAttributes.value
+    .filter(({ isInternal }) => !isInternal)
+    .map(({ name }) => {
+      return {
+        name,
+        value: formData[name],
+      }
+    })
+
+  const skip = new Set(['id', 'formId', ...Object.keys(objectAtrributes)])
+  const input: Record<string, unknown> = {}
+
+  // eslint-disable-next-line no-restricted-syntax
+  for (const key in formData) {
+    if (!skip.has(key)) {
+      input[camelize(key)] = formData[key]
+    }
+  }
+
+  const result = await updateQuery.send({
+    id: props.object?.id,
+    input: {
+      ...input,
+      objectAttributeValues,
+    },
+  })
+
+  if (result) {
+    emit('success', result)
+    closeDialog(props.name)
+  } else {
+    emit('error')
+  }
+}
+</script>
+
+<template>
+  <CommonDialog :name="name">
+    <template #before-label>
+      <button
+        class="text-blue"
+        :disabled="isDisabled"
+        :class="{ 'opacity-50': isDisabled }"
+        @click="cancelDialog()"
+      >
+        {{ $t('Cancel') }}
+      </button>
+    </template>
+    <template #after-label>
+      <button
+        class="text-blue"
+        :disabled="isDisabled"
+        :class="{ 'opacity-50': isDisabled }"
+        @click="formSubmit()"
+      >
+        {{ $t('Save') }}
+      </button>
+    </template>
+    <Form
+      :id="name"
+      ref="form"
+      class="w-full p-4"
+      :schema="schema"
+      :initial-values="initialValue"
+      use-object-attributes
+      :form-updater-id="formUpdaterId"
+      @submit="saveObject($event)"
+    />
+  </CommonDialog>
+</template>

+ 151 - 0
app/frontend/apps/mobile/components/CommonDialogObjectForm/__tests__/CommonDialogObjectForm.spec.ts

@@ -0,0 +1,151 @@
+// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+import { closeDialog } from '@shared/composables/useDialog'
+import {
+  mockOrganizationObjectAttributes,
+  organizationObjectAttributes,
+} from '@mobile/entities/organization/__tests__/mocks/organization-mocks'
+import { defineFormSchema } from '@mobile/form/composable'
+import { EnumObjectManagerObjects } from '@shared/graphql/types'
+import { renderComponent } from '@tests/support/components'
+import { MutationHandler } from '@shared/server/apollo/handler'
+import { useMutation } from '@vue/apollo-composable'
+import gql from 'graphql-tag'
+import { waitUntilApisResolved } from '@tests/support/utils'
+import { keyBy } from 'lodash-es'
+import CommonDialogForm from '../CommonDialogObjectForm.vue'
+
+vi.mock('@/shared/composables/useDialog')
+
+const renderForm = () => {
+  const attributesResult = organizationObjectAttributes()
+  const attributes = keyBy(attributesResult.attributes, 'name')
+  const attributesApi = mockOrganizationObjectAttributes(attributesResult)
+  const organization = {
+    id: 'faked-id',
+    name: 'Some Organization',
+    shared: true,
+    domainAssignment: false,
+    domain: '',
+    note: '',
+    active: false,
+    objectAttributeValues: [
+      { attribute: attributes.textarea, value: 'old value' },
+      { attribute: attributes.test, value: 'some test' },
+    ],
+  }
+  const sendMock = vi.fn().mockResolvedValue(organization)
+  MutationHandler.prototype.send = sendMock
+  const view = renderComponent(CommonDialogForm, {
+    props: {
+      name: 'organization',
+      object: organization,
+      type: EnumObjectManagerObjects.Organization,
+      schema: defineFormSchema([
+        {
+          screen: 'edit',
+          object: EnumObjectManagerObjects.Organization,
+        },
+      ]),
+      mutation: useMutation(
+        gql`
+          mutation {
+            organizationUpdate
+          }
+        `,
+      ),
+    },
+    form: true,
+    formField: true,
+    conformation: true,
+  })
+  return {
+    attributesApi,
+    sendMock,
+    organization,
+    attributes,
+    view,
+  }
+}
+
+test('can update default object', async () => {
+  const { attributesApi, view, sendMock, organization } = renderForm()
+
+  await waitUntilApisResolved(attributesApi)
+
+  const attributeValues = keyBy(
+    organization.objectAttributeValues,
+    'attribute.name',
+  )
+
+  const name = view.getByLabelText('Name')
+  const shared = view.getByLabelText('Shared organization')
+  const domainAssignment = view.getByLabelText('Domain based assignment')
+  const domain = view.getByLabelText('Domain')
+  const active = view.getByLabelText('Active')
+  const textarea = view.getByLabelText('Textarea Field')
+  const test = view.getByLabelText('Test Field')
+
+  expect(name).toHaveValue(organization.name)
+  expect(shared).toBeChecked()
+  expect(domainAssignment).not.toBeChecked()
+  expect(domain).toHaveValue(organization.domain)
+  expect(active).not.toBeChecked()
+  expect(textarea).toHaveValue(attributeValues.textarea.value)
+  expect(test).toHaveValue(attributeValues.test.value)
+
+  await view.events.type(name, ' 2')
+  await view.events.click(shared)
+  await view.events.click(domainAssignment)
+  await view.events.type(domain, 'some-domain@domain.me')
+  await view.events.click(active)
+
+  await view.events.clear(textarea)
+  await view.events.type(textarea, 'new value')
+
+  await view.events.click(view.getByRole('button', { name: 'Save' }))
+
+  expect(sendMock).toHaveBeenCalledOnce()
+  expect(sendMock).toHaveBeenCalledWith({
+    id: organization.id,
+    input: {
+      name: 'Some Organization 2',
+      shared: false,
+      domain: 'some-domain@domain.me',
+      domainAssignment: true,
+      active: true,
+      note: '',
+      objectAttributeValues: [
+        { name: 'test', value: 'some test' },
+        { name: 'textarea', value: 'new value' },
+      ],
+    },
+  })
+  expect(closeDialog).toHaveBeenCalled()
+})
+
+it('doesnt close dialog, if result is unsuccessfull', async () => {
+  const { attributesApi, view, sendMock } = renderForm()
+
+  await waitUntilApisResolved(attributesApi)
+  sendMock.mockResolvedValue(null)
+
+  await view.events.click(view.getByRole('button', { name: 'Save' }))
+
+  expect(sendMock).toHaveBeenCalledOnce()
+  expect(closeDialog).not.toHaveBeenCalled()
+})
+
+it('doesnt call api, if dialog is closed', async () => {
+  const { attributesApi, view, sendMock } = renderForm()
+
+  await waitUntilApisResolved(attributesApi)
+
+  await view.events.type(view.getByLabelText('Name'), ' 2')
+  await view.events.click(view.getByRole('button', { name: 'Cancel' }))
+
+  await view.events.click(await view.findByText('OK'))
+
+  expect(sendMock).not.toHaveBeenCalledOnce()
+  expect(closeDialog).toHaveBeenCalled()
+})

+ 30 - 0
app/frontend/apps/mobile/components/CommonDialogObjectForm/useDialogObjectForm.ts

@@ -0,0 +1,30 @@
+// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+import { useDialog } from '@shared/composables/useDialog'
+import type { EnumObjectManagerObjects } from '@shared/graphql/types'
+import type { Props } from './CommonDialogObjectForm.vue'
+
+interface ObjectDescription extends Omit<Props, 'name' | 'type'> {
+  onSuccess?(data: unknown): void
+  onError?(): void
+}
+
+export const useDialogObjectForm = (
+  name: string,
+  type: EnumObjectManagerObjects,
+) => {
+  const dialog = useDialog({
+    name,
+    component: () => import('./CommonDialogObjectForm.vue'),
+  })
+
+  const openDialog = async (props: ObjectDescription) => {
+    dialog.open({
+      name,
+      type,
+      ...props,
+    })
+  }
+
+  return { openDialog }
+}

+ 4 - 4
app/frontend/apps/mobile/components/CommonObjectAttributes/CommonObjectAttributes.vue

@@ -11,13 +11,15 @@ import type {
   ObjectManagerFrontendAttribute,
 } from '@shared/graphql/types'
 import { useSessionStore } from '@shared/stores/session'
-import type { AttributeDeclaration, ObjectLike } from './types'
+import type { ObjectLike } from '@shared/types/utils'
+import type { AttributeDeclaration } from './types'
 import CommonSectionMenu from '../CommonSectionMenu/CommonSectionMenu.vue'
 import CommonSectionMenuItem from '../CommonSectionMenu/CommonSectionMenuItem.vue'
 
 export interface Props {
   object: ObjectLike
   attributes: ObjectManagerFrontendAttribute[]
+  skipAttributes?: string[]
 }
 
 const props = defineProps<Props>()
@@ -61,8 +63,6 @@ const isEmpty = (value: unknown) => {
 
 const session = useSessionStore()
 
-const skipAttributes = ['name']
-
 interface AttributeField {
   attribute: ObjectManagerFrontendAttribute
   component: Component
@@ -100,7 +100,7 @@ const fields = computed<AttributeField[]>(() => {
         return false
       }
 
-      return !skipAttributes.includes(attribute.name)
+      return !props.skipAttributes?.includes(attribute.name)
     })
 })
 </script>

+ 24 - 14
app/frontend/apps/mobile/components/CommonObjectAttributes/__tests__/CommonObjectAttributes.spec.ts

@@ -147,20 +147,6 @@ describe('common object attributes interface', () => {
     expect(view.queryAllByRole('region')).toHaveLength(0)
   })
 
-  test("don't show name", () => {
-    const object = {
-      name: 'some_object',
-    }
-    const view = renderComponent(CommonObjectAttributes, {
-      props: {
-        object,
-        attributes: [{ ...attributesByKey.login, name: 'name' }],
-      },
-    })
-
-    expect(view.queryAllByRole('region')).toHaveLength(0)
-  })
-
   test("don't show empty fields", () => {
     const object = {
       login: '',
@@ -337,4 +323,28 @@ describe('common object attributes interface', () => {
     expect(getRegion('past')).toHaveTextContent('1 month ago')
     expect(getRegion('future')).toHaveTextContent('in 6 months')
   })
+
+  it('doesnt render skipped attributes', () => {
+    const object = {
+      skip: 'skip',
+      show: 'show',
+    }
+
+    const attributes = [
+      { ...attributesByKey.address, name: 'skip', display: 'skip' },
+      { ...attributesByKey.address, name: 'show', display: 'show' },
+    ]
+
+    const view = renderComponent(CommonObjectAttributes, {
+      props: {
+        object,
+        attributes,
+        skipAttributes: ['skip'],
+      },
+      router: true,
+    })
+
+    expect(view.getByRole('region', { name: 'show' })).toBeInTheDocument()
+    expect(view.queryByRole('region', { name: 'skip' })).not.toBeInTheDocument()
+  })
 })

+ 0 - 2
app/frontend/apps/mobile/components/CommonObjectAttributes/types.ts

@@ -2,8 +2,6 @@
 
 import type { Component } from 'vue'
 
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export type ObjectLike = Record<string, any>
 export interface AttributeDeclaration {
   component: Component
   dataTypes: string[]

+ 1 - 1
app/frontend/apps/mobile/components/CommonSectionPopup/CommonSectionPopup.vue

@@ -61,7 +61,7 @@ onKeyUp(['Escape', 'Spacebar', ' '], (e) => {
               v-for="item in items"
               :key="item.label"
               :link="item.link"
-              class="flex h-14 w-full cursor-pointer items-center justify-center border-b border-gray-300 last:border-0"
+              class="flex h-14 w-full cursor-pointer items-center justify-center border-b border-gray-300 text-center last:border-0"
               :class="item.class"
               @click="onItemClick(item.onAction)"
             >

+ 0 - 112
app/frontend/apps/mobile/components/Organization/OrganizationEditDialog.vue

@@ -1,112 +0,0 @@
-<!-- Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ -->
-
-<script setup lang="ts">
-import { defineFormSchema } from '@mobile/form/composable'
-import Form from '@shared/components/Form/Form.vue'
-import CommonDialog from '@mobile/components/CommonDialog/CommonDialog.vue'
-import type { ConfidentTake } from '@shared/types/utils'
-import { EnumObjectManagerObjects } from '@shared/graphql/types'
-import type { OrganizationQuery } from '@shared/graphql/types'
-import { closeDialog } from '@shared/composables/useDialog'
-import { MutationHandler } from '@shared/server/apollo/handler'
-import { type FormData } from '@shared/components/Form/types'
-import { useOrganizationUpdateMutation } from '@mobile/entities/organization/graphql/mutations/update.api'
-import { useForm } from '@shared/components/Form'
-
-interface Props {
-  name: string
-  organization: ConfidentTake<OrganizationQuery, 'organization'>
-}
-
-const props = defineProps<Props>()
-
-const schema = defineFormSchema([
-  {
-    name: 'name',
-    required: true,
-    object: EnumObjectManagerObjects.Organization,
-  },
-  {
-    screen: 'edit',
-    object: EnumObjectManagerObjects.Organization,
-  },
-  {
-    name: 'active',
-    required: true,
-    object: EnumObjectManagerObjects.Organization,
-  },
-])
-
-const updateQuery = new MutationHandler(useOrganizationUpdateMutation({}))
-
-const { form, formSubmit, isDisabled } = useForm()
-
-interface OrganizationForm {
-  domain: string
-  domain_assignment: boolean
-  note: string
-  name: string
-  shared: boolean
-  active: boolean
-}
-
-const initialValue = {
-  ...props.organization,
-  ...props.organization.objectAttributeValues?.reduce(
-    (acc, { attribute, value }) => ({ ...acc, [attribute.name]: value }),
-    {},
-  ),
-}
-
-const saveOrganization = async (formData: FormData<OrganizationForm>) => {
-  const objectAttributeValues = props.organization.objectAttributeValues?.map(
-    ({ attribute }) => {
-      return {
-        name: attribute.name,
-        value: formData[attribute.name],
-      }
-    },
-  )
-
-  const result = await updateQuery.send({
-    id: props.organization.id,
-    input: {
-      name: formData.name,
-      domain: formData.domain,
-      domainAssignment: formData.domain_assignment,
-      note: formData.note,
-      shared: formData.shared,
-      active: formData.active,
-      objectAttributeValues,
-    },
-  })
-  // close only if there are no errors
-  if (result) {
-    closeDialog(props.name)
-  }
-}
-</script>
-
-<template>
-  <CommonDialog :label="__('Edit')" :name="name">
-    <template #before-label>
-      <button class="text-blue" @click="closeDialog(name)">
-        {{ i18n.t('Cancel') }}
-      </button>
-    </template>
-    <template #after-label>
-      <button class="text-blue" :disabled="isDisabled" @click="formSubmit()">
-        {{ i18n.t('Save') }}
-      </button>
-    </template>
-    <Form
-      id="edit-organization"
-      ref="form"
-      class="w-full p-4"
-      :schema="schema"
-      :initial-values="initialValue"
-      use-object-attributes
-      @submit="saveOrganization($event as FormData<OrganizationForm>)"
-    />
-  </CommonDialog>
-</template>

+ 0 - 121
app/frontend/apps/mobile/components/Organization/__tests__/OrganizationEditDialog.spec.ts

@@ -1,121 +0,0 @@
-// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
-
-import { closeDialog } from '@shared/composables/useDialog'
-import { renderComponent } from '@tests/support/components'
-import { mockGraphQLApi } from '@tests/support/mock-graphql-api'
-import { waitUntil } from '@tests/support/utils'
-import { OrganizationUpdateDocument } from '@mobile/entities/organization/graphql/mutations/update.api'
-import { mockOrganizationObjectAttributes } from '@mobile/entities/organization/__tests__/mocks/organization-mocks'
-import OrganizationEditDialog from '../OrganizationEditDialog.vue'
-
-vi.mock('@shared/composables/useDialog')
-
-const textareaAttribute = {
-  name: 'textarea',
-  display: 'Textarea Field',
-  dataType: 'textarea',
-  dataOption: {
-    default: '',
-    maxlength: 500,
-    rows: 4,
-    null: true,
-    options: {},
-    relation: '',
-  },
-  __typename: 'ObjectManagerFrontendAttribute',
-}
-
-const createUpdateMock = () =>
-  mockGraphQLApi(OrganizationUpdateDocument).willResolve({
-    organizationUpdate: {
-      organization: {
-        id: 'faked-id',
-        name: 'Some Organization',
-        shared: false,
-        domain: 'some-domain@domain.me',
-        domainAssignment: true,
-        active: true,
-        note: 'Save something as this note',
-        objectAttributeValues: [
-          { attribute: textareaAttribute, value: 'new value' },
-        ],
-      },
-      errors: null,
-    },
-  })
-
-const renderEditDialog = () =>
-  renderComponent(OrganizationEditDialog, {
-    props: {
-      name: 'some-name',
-      organization: {
-        id: 'faked-id',
-        name: 'Some Organization',
-        shared: true,
-        domainAssignment: false,
-        domain: '',
-        note: '',
-        active: false,
-        objectAttributeValues: [
-          { attribute: textareaAttribute, value: 'old value' },
-        ],
-      },
-    },
-    form: true,
-    router: true,
-    store: true,
-  })
-
-describe('editing organization', () => {
-  test('can edit organization', async () => {
-    const attributesApi = mockOrganizationObjectAttributes()
-
-    const mockApi = createUpdateMock()
-
-    const view = renderEditDialog()
-
-    await waitUntil(() => attributesApi.calls.resolve)
-
-    await view.events.type(view.getByLabelText('Name'), ' 2')
-    await view.events.click(view.getByLabelText('Shared organization'))
-    await view.events.click(view.getByLabelText('Domain based assignment'))
-    await view.events.type(
-      view.getByLabelText('Domain'),
-      'some-domain@domain.me',
-    )
-    await view.events.click(view.getByLabelText('Active'))
-
-    const textarea = view.getByLabelText('Textarea Field')
-    await view.events.clear(textarea)
-    await view.events.type(textarea, 'new value')
-
-    await view.events.click(view.getByRole('button', { name: 'Save' }))
-
-    await waitUntil(() => mockApi.calls.resolve)
-
-    expect(mockApi.spies.resolve).toHaveBeenCalledWith({
-      id: 'faked-id',
-      input: {
-        name: 'Some Organization 2',
-        shared: false,
-        domain: 'some-domain@domain.me',
-        domainAssignment: true,
-        active: true,
-        note: '',
-        objectAttributeValues: [{ name: 'textarea', value: 'new value' }],
-      },
-    })
-    expect(closeDialog).toHaveBeenCalled()
-  })
-
-  test("doesn't call on cancel", async () => {
-    const mockApi = createUpdateMock()
-
-    const view = renderEditDialog()
-
-    await view.events.click(view.getByRole('button', { name: 'Cancel' }))
-
-    expect(closeDialog).toHaveBeenCalled()
-    expect(mockApi.spies.resolve).not.toHaveBeenCalled()
-  })
-})

Some files were not shown because too many files changed in this diff