Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions admin-ui/src/gql/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,8 @@ export type IEventStatistics = {
};

export enum IEventType {
AclDenied = 'ACL_DENIED',
AclGrantedSensitive = 'ACL_GRANTED_SENSITIVE',
ApiLoginTokenCreated = 'API_LOGIN_TOKEN_CREATED',
ApiLogout = 'API_LOGOUT',
AssortmentAddFilter = 'ASSORTMENT_ADD_FILTER',
Expand Down Expand Up @@ -1124,6 +1126,11 @@ export type IMutation = {
loginWithWebAuthn?: Maybe<ILoginMethodResponse>;
/** Log the user out. */
logout?: Maybe<ISuccessResponse>;
/**
* Log the user out of all sessions by invalidating all JWT tokens.
* This increments the token version, making all existing tokens invalid.
*/
logoutAllSessions?: Maybe<ISuccessResponse>;
/** Make a proposal as answer to the RFP by changing its status to PROCESSED */
makeQuotationProposal: IQuotation;
/** Make's the provided payment credential as the users preferred method of payment. */
Expand Down Expand Up @@ -3397,6 +3404,7 @@ export enum IRoleAction {
LoginWithPassword = 'loginWithPassword',
LoginWithWebAuthn = 'loginWithWebAuthn',
Logout = 'logout',
LogoutAllSessions = 'logoutAllSessions',
ManageAssortments = 'manageAssortments',
ManageBookmarks = 'manageBookmarks',
ManageCountries = 'manageCountries',
Expand Down Expand Up @@ -15738,6 +15746,16 @@ export type IVerifyQuotationMutation = {
};
};

export type ITagsCountQueryVariables = Exact<{
tag: Scalars['LowerCaseString']['input'];
}>;

export type ITagsCountQuery = {
productsCount: number;
assortmentsCount: number;
usersCount: number;
};

export type IInvalidateTokenMutationVariables = Exact<{
tokenId: Scalars['ID']['input'];
}>;
Expand Down
7 changes: 7 additions & 0 deletions admin-ui/src/modules/common/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
CubeIcon,
DocumentTextIcon,
FolderArrowDownIcon,
TagIcon,
} from '@heroicons/react/24/outline';
import Link from 'next/link';
import React, { useState } from 'react';
Expand Down Expand Up @@ -149,6 +150,12 @@ const Layout = ({
requiredRole: 'viewFilters',
href: '/filters',
},
{
name: formatMessage({ id: 'tags', defaultMessage: 'Tags' }),
icon: TagIcon,
requiredRole: 'manageTags',
href: '/tags',
},
isSystemReady && {
name: formatMessage({ id: 'users', defaultMessage: 'Users' }),
icon: UsersIcon,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import SelectField from '../../forms/components/SelectField';
import { IProductType } from '../../../gql/types';
import useApp from '../../common/hooks/useApp';

const ProductAssignmentScaffoldForm = ({ proxyProduct, vectors, onSuccess }) => {
const ProductAssignmentScaffoldForm = ({
proxyProduct,
vectors,
onSuccess,
}) => {
const { formatMessage } = useIntl();
const { selectedLocale } = useApp();
const scaffoldProduct = useScaffoldVariationProduct({
Expand All @@ -22,7 +26,10 @@ const ProductAssignmentScaffoldForm = ({ proxyProduct, vectors, onSuccess }) =>
});
const { hasRole } = useAuth();

const variations = useMemo(() => vectors.map(({ value }) => value), [vectors]);
const variations = useMemo(
() => vectors.map(({ value }) => value),
[vectors],
);

const defaultTitle = useMemo(() => {
const baseTitle = proxyProduct?.texts?.title ?? '';
Expand Down
150 changes: 150 additions & 0 deletions admin-ui/src/modules/tags/components/TagForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { useIntl } from 'react-intl';

import Button from '../../common/components/Button';
import Form from '../../forms/components/Form';
import TextField from '../../forms/components/TextField';
import TextAreaField from '../../forms/components/TextAreaField';
import SubmitButton from '../../forms/components/SubmitButton';
import { Validator } from '../../forms/lib/validators';
import useForm from '../../forms/hooks/useForm';

interface TagFormData {
name: string;
description?: string;
category?: string;
}

interface TagFormProps {
initialValues?: Partial<TagFormData>;
onSubmit: (data: TagFormData) => Promise<void>;
submitButtonText?: string;
isLoading?: boolean;
}

const TagForm = ({
initialValues = {},
onSubmit,
submitButtonText = 'Save',
isLoading = false,
}: TagFormProps) => {
const { formatMessage } = useIntl();

// Custom validator for tag name format
const validateTagName: Validator = {
isValid: (value) => {
if (!value || typeof value !== 'string') return false;
if (value.length < 2 || value.length > 50) return false;
return /^[a-z0-9\-_]+$/.test(value);
},
intlMessageDescriptor: {
id: 'tag_name_format',
defaultMessage:
'Tag name must be 2-50 characters and contain only lowercase letters, numbers, hyphens, and underscores',
},
};

const form = useForm({
initialValues: {
name: initialValues.name || '',
description: initialValues.description || '',
category: initialValues.category || '',
},
submit: async (values: TagFormData) => {
await onSubmit(values);
return { success: true };
},
successMessage: formatMessage({
id: 'tag_saved_successfully',
defaultMessage: 'Tag saved successfully',
}),
});

return (
<Form form={form} className="space-y-6">
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-6">
<h3 className="text-lg font-medium text-slate-900 dark:text-slate-100 mb-4">
{formatMessage({
id: 'tag_information',
defaultMessage: 'Tag Information',
})}
</h3>

<div className="space-y-4">
<TextField
name="name"
label={formatMessage({
id: 'tag_name',
defaultMessage: 'Tag Name',
})}
placeholder={formatMessage({
id: 'tag_name_placeholder',
defaultMessage: 'e.g., electronics, outdoor-gear, premium',
})}
help={formatMessage({
id: 'tag_name_help',
defaultMessage:
'Use lowercase letters, numbers, hyphens, and underscores only. This will be used in URLs and filters.',
})}
required
maxLength={50}
validators={[validateTagName]}
/>

<TextAreaField
name="description"
label={formatMessage({
id: 'tag_description',
defaultMessage: 'Description',
})}
placeholder={formatMessage({
id: 'tag_description_placeholder',
defaultMessage:
'Describe when and how this tag should be used...',
})}
help={formatMessage({
id: 'tag_description_help',
defaultMessage:
'Optional description to help team members understand when to use this tag.',
})}
maxLength={200}
rows={3}
/>

<TextField
name="category"
label={formatMessage({
id: 'tag_category',
defaultMessage: 'Category',
})}
placeholder={formatMessage({
id: 'tag_category_placeholder',
defaultMessage: 'e.g., product-type, theme, campaign',
})}
help={formatMessage({
id: 'tag_category_help',
defaultMessage:
'Optional category to group related tags together (e.g., "product-type", "theme", "campaign").',
})}
maxLength={50}
/>
</div>
</div>

<div className="flex justify-end space-x-3">
<Button
type="button"
variant="secondary"
onClick={() => window.history.back()}
>
{formatMessage({
id: 'cancel',
defaultMessage: 'Cancel',
})}
</Button>
<SubmitButton label={submitButtonText} />
</div>
</Form>
);
};

export default TagForm;
69 changes: 69 additions & 0 deletions admin-ui/src/modules/tags/components/TagList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { useIntl } from 'react-intl';
import Table from '../../common/components/Table';
import TagListItem from './TagListItem';

interface TagListProps {
tags: string[];
sortable?: boolean;
}

const TagList = ({ tags, sortable = false }: TagListProps) => {
const { formatMessage } = useIntl();

if (!tags || tags.length === 0) {
return (
<div className="text-center py-8 text-slate-500 dark:text-slate-400">
{formatMessage({
id: 'no_tags_found',
defaultMessage: 'No tags found',
})}
</div>
);
}

return (
<div className="overflow-x-auto">
<Table className="min-w-full">
{/* Table Header */}
<Table.Row header enablesort={sortable}>
<Table.Cell sortKey="name">
{formatMessage({
id: 'tag_name',
defaultMessage: 'Tag Name',
})}
</Table.Cell>
<Table.Cell sortKey="usage">
{formatMessage({
id: 'total_usage',
defaultMessage: 'Total Usage',
})}
</Table.Cell>
<Table.Cell>
{formatMessage({
id: 'products_usage',
defaultMessage: 'Products',
})}
</Table.Cell>
<Table.Cell>
{formatMessage({
id: 'assortments_usage',
defaultMessage: 'Assortments',
})}
</Table.Cell>
<Table.Cell>
{formatMessage({
id: 'users_usage',
defaultMessage: 'Users',
})}
</Table.Cell>
</Table.Row>

{tags.map((tag, index) => (
<TagListItem key={`${tag}-${index}`} tag={tag} />
))}
</Table>
</div>
);
};

export default TagList;
83 changes: 83 additions & 0 deletions admin-ui/src/modules/tags/components/TagListItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { useIntl } from 'react-intl';
import Link from 'next/link';
import Badge from '../../common/components/Badge';
import Table from '../../common/components/Table';
import useTagsCount from '../hooks/useTagsCount';

interface TagListItemProps {
tag: string;
}

const TagListItem = ({ tag }: TagListItemProps) => {
const { formatMessage } = useIntl();

const { productsCount, assortmentsCount, usersCount } = useTagsCount({ tag });

return (
<Table.Row className="group">
<Table.Cell>
<div className="flex items-center">
<Badge text={tag} color="slate" className="text-sm font-medium" />
</div>
</Table.Cell>

<Table.Cell className="whitespace-nowrap px-6">
<div className="text-sm font-medium text-slate-900 dark:text-slate-100">
{assortmentsCount + productsCount + usersCount}
</div>
</Table.Cell>

<Table.Cell className="whitespace-nowrap px-6">
<div className="text-sm text-slate-900 dark:text-slate-300">
{productsCount}
{productsCount > 0 && (
<Link
href={`/products?tags=${encodeURIComponent(tag)}`}
className="ml-2 text-blue-600 dark:text-blue-400 hover:underline"
>
{formatMessage({
id: 'view_products',
defaultMessage: 'view',
})}
</Link>
)}
</div>
</Table.Cell>

<Table.Cell className="whitespace-nowrap px-6">
<div className="text-sm text-slate-900 dark:text-slate-300">
{assortmentsCount}
{assortmentsCount > 0 && (
<Link
href={`/assortments?tags=${encodeURIComponent(tag)}`}
className="ml-2 text-blue-600 dark:text-blue-400 hover:underline"
>
{formatMessage({
id: 'view_assortments',
defaultMessage: 'view',
})}
</Link>
)}
</div>
</Table.Cell>
<Table.Cell className="whitespace-nowrap px-6">
<div className="text-sm text-slate-900 dark:text-slate-300">
{usersCount}
{usersCount > 0 && (
<Link
href={`/users?tags=${encodeURIComponent(tag)}`}
className="ml-2 text-blue-600 dark:text-blue-400 hover:underline"
>
{formatMessage({
id: 'view_users',
defaultMessage: 'view',
})}
</Link>
)}
</div>
</Table.Cell>
</Table.Row>
);
};

export default TagListItem;
Loading