mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Merge branch 'feature/member_features' of github.com:solidtime-io/solidtime into feature/update_billable_rate
# Conflicts: # e2e/members.spec.ts # e2e/organization.spec.ts
This commit is contained in:
@@ -1,4 +1,80 @@
|
||||
// TODO: Edit Billable Rate
|
||||
// TODO: Resend Email Invitation
|
||||
// TODO: Remove Invitation
|
||||
import { expect, test } from '../playwright/fixtures';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
|
||||
async function goToMembersPage(page) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
|
||||
}
|
||||
|
||||
async function openInviteMemberModal(page) {
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Invite Member' }).click(),
|
||||
expect(page.getByPlaceholder('Member Email')).toBeVisible(),
|
||||
]);
|
||||
}
|
||||
|
||||
test('test that new manager can be invited', async ({ page }) => {
|
||||
await goToMembersPage(page);
|
||||
await openInviteMemberModal(page);
|
||||
const editorId = Math.round(Math.random() * 10000);
|
||||
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
|
||||
await page.getByRole('button', { name: 'Manager' }).click();
|
||||
await Promise.all([
|
||||
page
|
||||
.getByRole('button', { name: 'Invite Member', exact: true })
|
||||
.click(),
|
||||
expect(page.getByRole('main')).toContainText(
|
||||
`new+${editorId}@editor.test`
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
test('test that new employee can be invited', async ({ page }) => {
|
||||
await goToMembersPage(page);
|
||||
await openInviteMemberModal(page);
|
||||
const editorId = Math.round(Math.random() * 10000);
|
||||
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
|
||||
await page.getByRole('button', { name: 'Employee' }).click();
|
||||
await Promise.all([
|
||||
page
|
||||
.getByRole('button', { name: 'Invite Member', exact: true })
|
||||
.click(),
|
||||
await expect(page.getByRole('main')).toContainText(
|
||||
`new+${editorId}@editor.test`
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
test('test that new admin can be invited', async ({ page }) => {
|
||||
await goToMembersPage(page);
|
||||
await openInviteMemberModal(page);
|
||||
const adminId = Math.round(Math.random() * 10000);
|
||||
await page.getByLabel('Email').fill(`new+${adminId}@admin.test`);
|
||||
await page.getByRole('button', { name: 'Administrator' }).click();
|
||||
await Promise.all([
|
||||
page
|
||||
.getByRole('button', { name: 'Invite Member', exact: true })
|
||||
.click(),
|
||||
expect(page.getByRole('main')).toContainText(
|
||||
`new+${adminId}@admin.test`
|
||||
),
|
||||
]);
|
||||
});
|
||||
test('test that error shows if no role is selected', async ({ page }) => {
|
||||
await goToMembersPage(page);
|
||||
await openInviteMemberModal(page);
|
||||
const noRoleId = Math.round(Math.random() * 10000);
|
||||
|
||||
await page.getByLabel('Email').fill(`new+${noRoleId}@norole.test`);
|
||||
await Promise.all([
|
||||
page
|
||||
.getByRole('button', { name: 'Invite Member', exact: true })
|
||||
.click(),
|
||||
expect(page.getByText('Please select a role')).toBeVisible(),
|
||||
]);
|
||||
});
|
||||
|
||||
import { test } from '../playwright/fixtures';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
|
||||
@@ -17,57 +17,6 @@ test('test that organization name can be updated', async ({ page }) => {
|
||||
).toContainText('NEW ORG NAME');
|
||||
});
|
||||
|
||||
test('test that new manager can be invited', async ({ page }) => {
|
||||
await goToOrganizationSettings(page);
|
||||
const editorId = Math.round(Math.random() * 10000);
|
||||
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
|
||||
await page.getByRole('button', { name: 'Manager' }).click();
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Add', exact: true }).click(),
|
||||
expect(page.getByRole('main')).toContainText(
|
||||
`new+${editorId}@editor.test`
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
test('test that new employee can be invited', async ({ page }) => {
|
||||
await goToOrganizationSettings(page);
|
||||
const editorId = Math.round(Math.random() * 10000);
|
||||
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
|
||||
await page.getByRole('button', { name: 'Employee' }).click();
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Add', exact: true }).click(),
|
||||
await expect(page.getByRole('main')).toContainText(
|
||||
`new+${editorId}@editor.test`
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
test('test that new admin can be invited', async ({ page }) => {
|
||||
await goToOrganizationSettings(page);
|
||||
const adminId = Math.round(Math.random() * 10000);
|
||||
await page.getByLabel('Email').fill(`new+${adminId}@admin.test`);
|
||||
await page.getByRole('button', { name: 'Administrator' }).click();
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Add', exact: true }).click(),
|
||||
expect(page.getByRole('main')).toContainText(
|
||||
`new+${adminId}@admin.test`
|
||||
),
|
||||
]);
|
||||
});
|
||||
test('test that error shows if no role is selected', async ({ page }) => {
|
||||
await goToOrganizationSettings(page);
|
||||
const noRoleId = Math.round(Math.random() * 10000);
|
||||
|
||||
await page.getByLabel('Email').fill(`new+${noRoleId}@norole.test`);
|
||||
await Promise.all([
|
||||
page.getByRole('button', { name: 'Add', exact: true }).click(),
|
||||
expect(page.getByRole('main')).toContainText(
|
||||
'The role field is required.'
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
test('test that organization billable rate can be updated', async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import Dropdown from '@/Components/Dropdown.vue';
|
||||
import { TrashIcon, ArrowPathIcon } from '@heroicons/vue/20/solid';
|
||||
const emit = defineEmits<{
|
||||
delete: [];
|
||||
resend: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropdown align="bottom-end">
|
||||
<template #trigger>
|
||||
<svg
|
||||
data-testid="invitation_actions"
|
||||
class="h-10 w-10 p-2 rounded-full hover:bg-card-background opacity-20 group-hover:opacity-100 transition"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 5.92A.96.96 0 1 0 12 4a.96.96 0 0 0 0 1.92m0 7.04a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92M12 20a.96.96 0 1 0 0-1.92a.96.96 0 0 0 0 1.92" />
|
||||
</svg>
|
||||
</template>
|
||||
<template #content>
|
||||
<button
|
||||
@click="emit('resend')"
|
||||
data-testid="invitation_delete"
|
||||
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
||||
<ArrowPathIcon class="w-5 text-icon-active"></ArrowPathIcon>
|
||||
<span>Resend Invitation</span>
|
||||
</button>
|
||||
<button
|
||||
@click="emit('delete')"
|
||||
data-testid="invitation_delete"
|
||||
class="flex items-center space-x-3 w-full px-3 py-2.5 text-start text-sm font-medium leading-5 text-white hover:bg-card-background-active focus:outline-none focus:bg-card-background-active transition duration-150 ease-in-out">
|
||||
<TrashIcon class="w-5 text-icon-active"></TrashIcon>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -18,7 +18,7 @@ onMounted(async () => {
|
||||
<div
|
||||
data-testid="client_table"
|
||||
class="grid min-w-full"
|
||||
style="grid-template-columns: 1fr 1fr">
|
||||
style="grid-template-columns: 1fr 1fr 80px">
|
||||
<InvitationTableHeading></InvitationTableHeading>
|
||||
<template
|
||||
v-for="invitation in invitations"
|
||||
|
||||
@@ -4,8 +4,15 @@ import TableHeading from '@/Components/Common/TableHeading.vue';
|
||||
|
||||
<template>
|
||||
<TableHeading>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-white">Email</div>
|
||||
<div
|
||||
class="px-3 py-1.5 text-left font-semibold text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
Email
|
||||
</div>
|
||||
<div class="px-3 py-1.5 text-left font-semibold text-white">Role</div>
|
||||
<div
|
||||
class="relative py-1.5 pl-3 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12 bg-row-heading-background">
|
||||
<span class="sr-only">Edit</span>
|
||||
</div>
|
||||
</TableHeading>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -2,20 +2,76 @@
|
||||
import type { Invitation } from '@/utils/api';
|
||||
import TableRow from '@/Components/TableRow.vue';
|
||||
import { capitalizeFirstLetter } from '../../../utils/format';
|
||||
import InvitationMoreOptionsDropdown from '@/Components/Common/Invitation/InvitationMoreOptionsDropdown.vue';
|
||||
import { api } from '../../../../../openapi.json.client';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { useNotificationsStore } from '@/utils/notification';
|
||||
import { useInvitationsStore } from '@/utils/useInvitations';
|
||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
invitation: Invitation;
|
||||
}>();
|
||||
|
||||
async function deleteInvitation() {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (organizationId) {
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.removeInvitation(
|
||||
{},
|
||||
{
|
||||
params: {
|
||||
invitation: props.invitation.id,
|
||||
organization: organizationId,
|
||||
},
|
||||
}
|
||||
),
|
||||
'Invitation removed successfully',
|
||||
'Error removing invitation',
|
||||
() => {
|
||||
useInvitationsStore().fetchInvitations();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function resendInvitation() {
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (organizationId) {
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.resendInvitationEmail(
|
||||
{},
|
||||
{
|
||||
params: {
|
||||
invitation: props.invitation.id,
|
||||
organization: organizationId,
|
||||
},
|
||||
}
|
||||
),
|
||||
'Invitation mail sent successfully',
|
||||
'Error sending invitation mail'
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableRow>
|
||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
|
||||
<div
|
||||
class="whitespace-nowrap px-3 py-4 text-sm text-muted pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
{{ invitation.email }}
|
||||
</div>
|
||||
<div class="whitespace-nowrap px-3 py-4 text-sm text-muted">
|
||||
{{ capitalizeFirstLetter(invitation.role) }}
|
||||
</div>
|
||||
<div
|
||||
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||
<InvitationMoreOptionsDropdown
|
||||
@delete="deleteInvitation"
|
||||
@resend="resendInvitation" />
|
||||
</div>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -8,9 +8,16 @@ import { useFocus } from '@vueuse/core';
|
||||
import InputLabel from '@/Components/InputLabel.vue';
|
||||
import InputError from '@/Components/InputError.vue';
|
||||
import type { Role } from '@/types/jetstream';
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { Link, useForm } from '@inertiajs/vue3';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { filterRoles } from '@/utils/roles';
|
||||
import { hasActiveSubscription, isBillingActivated } from '@/utils/billing';
|
||||
import { CreditCardIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
||||
import { canUpdateOrganization } from '@/utils/permissions';
|
||||
import { api } from '../../../../../openapi.json.client';
|
||||
import type { MemberRole } from '@/utils/api';
|
||||
import { z } from 'zod';
|
||||
import { useNotificationsStore } from '@/utils/notification';
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
const saving = ref(false);
|
||||
@@ -19,25 +26,55 @@ defineProps<{
|
||||
availableRoles: Role[];
|
||||
}>();
|
||||
|
||||
const errors = ref({
|
||||
email: '',
|
||||
role: '',
|
||||
});
|
||||
|
||||
const addTeamMemberForm = useForm({
|
||||
email: '',
|
||||
role: null as string | null,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||
|
||||
async function submit() {
|
||||
if (addTeamMemberForm.role === null || addTeamMemberForm.email === '') {
|
||||
errors.value.email = z
|
||||
.string()
|
||||
.email()
|
||||
.safeParse(addTeamMemberForm.email).success
|
||||
? ''
|
||||
: 'Please enter a valid email address';
|
||||
errors.value.role =
|
||||
addTeamMemberForm.role === null ? 'Please select a role' : '';
|
||||
return;
|
||||
}
|
||||
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (organizationId) {
|
||||
addTeamMemberForm.post(route('team-members.store', organizationId), {
|
||||
errorBag: 'addTeamMember',
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
await handleApiRequestNotifications(
|
||||
() =>
|
||||
api.invite(
|
||||
{
|
||||
email: addTeamMemberForm.email,
|
||||
role: addTeamMemberForm.role as MemberRole,
|
||||
},
|
||||
{
|
||||
params: {
|
||||
organization: organizationId,
|
||||
},
|
||||
}
|
||||
),
|
||||
'Member invited',
|
||||
'Failed to invite member',
|
||||
() => {
|
||||
addTeamMemberForm.reset();
|
||||
emit('close');
|
||||
show.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +91,34 @@ useFocus(clientNameInput, { initialValue: true });
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="space-y-4">
|
||||
<div v-if="isBillingActivated() && !hasActiveSubscription()">
|
||||
<div
|
||||
class="rounded-full flex items-center justify-center w-20 h-20 mx-auto border border-border-tertiary bg-secondary">
|
||||
<UserGroupIcon class="w-12"></UserGroupIcon>
|
||||
</div>
|
||||
<div class="max-w-sm text-center mx-auto py-4 text-base">
|
||||
<p class="py-1">
|
||||
The Free plan is <strong>limited to one member</strong>
|
||||
</p>
|
||||
<p class="py-1">
|
||||
To add new team members to your organization you,
|
||||
<strong>please upgrade to a paid plan</strong>.
|
||||
</p>
|
||||
|
||||
<Link href="/billing">
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
class="mt-6"
|
||||
v-if="
|
||||
isBillingActivated() && canUpdateOrganization()
|
||||
">
|
||||
<CreditCardIcon class="w-5 h-5 me-2" />
|
||||
Go to Billing
|
||||
</PrimaryButton>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<div class="col-span-6 sm:col-span-4 flex-1">
|
||||
<InputLabel for="email" value="Email" />
|
||||
<TextInput
|
||||
@@ -68,16 +132,12 @@ useFocus(clientNameInput, { initialValue: true });
|
||||
class="mt-1 block w-full"
|
||||
required
|
||||
autocomplete="memberName" />
|
||||
<InputError
|
||||
:message="addTeamMemberForm.errors.email"
|
||||
class="mt-2" />
|
||||
<InputError :message="errors.email" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div v-if="availableRoles.length > 0">
|
||||
<InputLabel for="roles" value="Role" />
|
||||
<InputError
|
||||
:message="addTeamMemberForm.errors.role"
|
||||
class="mt-2" />
|
||||
<InputError :message="errors.role" class="mt-2" />
|
||||
|
||||
<div
|
||||
class="relative z-0 mt-1 border border-card-border rounded-lg cursor-pointer">
|
||||
@@ -140,8 +200,8 @@ useFocus(clientNameInput, { initialValue: true });
|
||||
</template>
|
||||
<template #footer>
|
||||
<SecondaryButton @click="show = false"> Cancel</SecondaryButton>
|
||||
|
||||
<PrimaryButton
|
||||
v-if="!isBillingActivated() || hasActiveSubscription()"
|
||||
class="ms-3"
|
||||
:class="{ 'opacity-25': saving }"
|
||||
:disabled="saving"
|
||||
|
||||
@@ -49,7 +49,7 @@ async function invitePlaceholder(id: string) {
|
||||
<template>
|
||||
<TableRow>
|
||||
<div
|
||||
class="whitespace-nowrap flex items-center space-x-5 3xl:pl-12 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
class="whitespace-nowrap flex items-center space-x-5 py-4 pr-3 text-sm font-medium text-white pl-4 sm:pl-6 lg:pl-8 3xl:pl-12">
|
||||
<span>
|
||||
{{ member.name }}
|
||||
</span>
|
||||
|
||||
@@ -80,7 +80,7 @@ const showEditProjectModal = ref(false);
|
||||
<span>Active</span>
|
||||
</div>
|
||||
<div
|
||||
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium sm:pr-0 pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||
class="relative whitespace-nowrap flex items-center pl-3 text-right text-sm font-medium pr-4 sm:pr-6 lg:pr-8 3xl:pr-12">
|
||||
<ProjectMoreOptionsDropdown
|
||||
:project="project"
|
||||
@edit="showEditProjectModal = true"
|
||||
|
||||
@@ -18,7 +18,7 @@ const activeClass = computed(() => {
|
||||
<button
|
||||
:class="
|
||||
twMerge(
|
||||
'rounded-md transition px-2 sm:px-3 py-1 sm:py-1.5 text-xs sm:text-sm font-medium hover:text-white',
|
||||
'rounded-md transition px-2 sm:px-3 py-1 sm:py-1.5 text-xs sm:text-sm font-medium hover:text-white focus-visible:outline-none',
|
||||
activeClass
|
||||
)
|
||||
">
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import AppLayout from '@/Layouts/AppLayout.vue';
|
||||
import DeleteTeamForm from '@/Pages/Teams/Partials/DeleteTeamForm.vue';
|
||||
import SectionBorder from '@/Components/SectionBorder.vue';
|
||||
import TeamMemberManager from '@/Pages/Teams/Partials/TeamMemberManager.vue';
|
||||
import UpdateTeamNameForm from '@/Pages/Teams/Partials/UpdateTeamNameForm.vue';
|
||||
import type { Organization } from '@/types/models';
|
||||
import type { Permissions, Role } from '@/types/jetstream';
|
||||
@@ -33,13 +32,6 @@ defineProps<{
|
||||
<OrganizationBillableRate
|
||||
v-if="canUpdateOrganization()"
|
||||
:team="team" />
|
||||
|
||||
<TeamMemberManager
|
||||
class="mt-10 sm:mt-0"
|
||||
:team="team"
|
||||
:available-roles="availableRoles"
|
||||
:user-permissions="permissions" />
|
||||
|
||||
<SectionBorder />
|
||||
|
||||
<ImportData
|
||||
|
||||
@@ -89,6 +89,9 @@ export type Member = MemberIndexResponse['data'][0];
|
||||
|
||||
export type UpdateMemberBody = ZodiosBodyByAlias<SolidTimeApi, 'updateMember'>;
|
||||
|
||||
export type InviteMemberBody = ZodiosBodyByAlias<SolidTimeApi, 'invite'>;
|
||||
export type MemberRole = InviteMemberBody['role'];
|
||||
|
||||
export type CreateTagBody = ZodiosBodyByAlias<SolidTimeApi, 'createTag'>;
|
||||
|
||||
export type ImportType = ZodiosResponseByAlias<
|
||||
|
||||
@@ -7,3 +7,8 @@ export function isBillingActivated() {
|
||||
|
||||
return page.props.has_billing_extension;
|
||||
}
|
||||
|
||||
export function hasActiveSubscription() {
|
||||
// TODO: Replace with server side check
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -41,13 +41,17 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
||||
async function handleApiRequestNotifications<T>(
|
||||
apiRequest: () => Promise<T>,
|
||||
successMessage?: string,
|
||||
errorMessage?: string
|
||||
errorMessage?: string,
|
||||
onSuccess?: (response: T) => void
|
||||
) {
|
||||
try {
|
||||
const response = await apiRequest();
|
||||
if (successMessage) {
|
||||
addNotification('success', successMessage);
|
||||
}
|
||||
if (onSuccess) {
|
||||
onSuccess(response);
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
@@ -72,6 +76,9 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
||||
if (successMessage) {
|
||||
addNotification('success', successMessage);
|
||||
}
|
||||
if (onSuccess) {
|
||||
onSuccess(response);
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
router.get(route('login'));
|
||||
|
||||
Reference in New Issue
Block a user